Initial commit
@@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{json,yml,yaml}]
|
||||
indent_size = 2
|
||||
@@ -0,0 +1,25 @@
|
||||
# ---- API ----
|
||||
PORT=8787
|
||||
# When DATABASE_URL is set the API runs in async (queue) mode; otherwise it runs
|
||||
# clones inline in-memory (handy for a quick local single-page demo).
|
||||
# CLONE_TTL_MS=1800000 # in-memory mode: how long results are retained
|
||||
|
||||
# ---- Database + queue (Postgres; pg-boss uses the same DB) ----
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ditto_site
|
||||
|
||||
# ---- Worker ----
|
||||
ARTIFACTS_DIR=./local-data/artifacts
|
||||
# Cache time-to-stale: how long a cached clone is served before re-capture.
|
||||
# Duration units: ms/s/m/h/d. "0" disables caching.
|
||||
CACHE_STALE_AFTER=24h
|
||||
# VERIFY_TIER=stage2 # perceptual-gate tier when options.verify is set
|
||||
# HARNESS_DIR= # per-worker Next build harness for verify (M5)
|
||||
|
||||
# ---- Blob storage (S3 / R2 / MinIO) — used from M4 ----
|
||||
# S3_ENDPOINT=http://localhost:9000 # MinIO locally; omit for AWS S3
|
||||
# S3_REGION=auto
|
||||
# S3_BUCKET=ditto-site-artifacts
|
||||
# S3_ACCESS_KEY_ID=minioadmin
|
||||
# S3_SECRET_ACCESS_KEY=minioadmin
|
||||
# S3_FORCE_PATH_STYLE=true # required for MinIO
|
||||
# S3_PUBLIC_URL= # optional CDN/base url for objects
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report a problem with ditto.site CLI or service behavior
|
||||
title: ""
|
||||
labels: bug
|
||||
---
|
||||
|
||||
**What happened**
|
||||
<!-- A clear description of the bug. -->
|
||||
|
||||
**To reproduce**
|
||||
- Surface: <!-- CLI / REST / MCP -->
|
||||
- URL cloned (if applicable):
|
||||
- Command or API/MCP call:
|
||||
- Options (interactions/components/motion/multiPage/verify, …):
|
||||
|
||||
**Expected behavior**
|
||||
<!-- What you expected instead. -->
|
||||
|
||||
**Environment**
|
||||
- OS + Node version (`node -v`):
|
||||
- Commit / version:
|
||||
- Chromium installed (`npx playwright install chromium`)?
|
||||
|
||||
**Logs / artifacts**
|
||||
<!-- Relevant output, the validation report, or a run dir. For a "perfect score",
|
||||
check it isn't a blocked/degenerate capture (capture.pollution / capture.blocked). -->
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/ion-design/ditto.site/security/advisories/new
|
||||
about: Please report security issues privately — do not open a public issue.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an improvement to ditto.site
|
||||
title: ""
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
**Problem / motivation**
|
||||
<!-- What are you trying to do that's hard or impossible today? -->
|
||||
|
||||
**Proposed solution**
|
||||
<!-- What you'd like to see. -->
|
||||
|
||||
**Alternatives considered**
|
||||
<!-- Other approaches you thought about. -->
|
||||
|
||||
**Scope notes**
|
||||
<!-- Does this touch the deterministic compiler (clone fidelity/gates) or only the
|
||||
service layer? Anything that affects clone output should stay deterministic. -->
|
||||
@@ -0,0 +1,17 @@
|
||||
version: 2
|
||||
updates:
|
||||
# npm workspaces (single root lockfile).
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
dev-dependencies:
|
||||
dependency-type: "development"
|
||||
production-dependencies:
|
||||
dependency-type: "production"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -0,0 +1,23 @@
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR change, and why? -->
|
||||
|
||||
## Changes
|
||||
|
||||
-
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] `npm run typecheck` passes
|
||||
- [ ] `npm test` passes
|
||||
- [ ] Added/updated tests for the change
|
||||
|
||||
## Compiler determinism
|
||||
|
||||
- [ ] No change to the compiler's deterministic clone semantics, **or** the change
|
||||
is intentional and validated against the benchmarks (`npm run bench`) with
|
||||
results noted above.
|
||||
|
||||
## Notes
|
||||
|
||||
<!-- Anything reviewers should know: trade-offs, follow-ups, screenshots. -->
|
||||
@@ -0,0 +1,38 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: ditto_site
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
env:
|
||||
# DB/queue tests use this instead of an ephemeral instance.
|
||||
TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/ditto_site
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Install Chromium (for capture/browser-gated tests)
|
||||
run: npx playwright install --with-deps chromium
|
||||
- run: npm run typecheck
|
||||
- run: npm test
|
||||
@@ -0,0 +1,52 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
compiler/node_modules/
|
||||
|
||||
# Build output
|
||||
compiler/dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Run artifacts (immutable captures/generated apps/validation) — large, not committed
|
||||
runs/
|
||||
|
||||
# Build harness transient inputs (deps reinstalled from package.json/lockfile)
|
||||
compiler/.harness/src/
|
||||
compiler/.harness/public/
|
||||
compiler/.harness/out/
|
||||
compiler/.harness/.next/
|
||||
compiler/.harness/next.config.mjs
|
||||
compiler/.harness/tsconfig.json
|
||||
compiler/.harness/next-env.d.ts
|
||||
|
||||
# Generated app build artifacts inside runs are covered by runs/, but ignore stray ones
|
||||
.next/
|
||||
out/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
compiler/.harness2/src/
|
||||
compiler/.harness2/public/
|
||||
compiler/.harness2/out/
|
||||
compiler/.harness2/.next/
|
||||
compiler/.harness2/next.config.mjs
|
||||
compiler/.harness2/tsconfig.json
|
||||
compiler/.harness2/next-env.d.ts
|
||||
|
||||
# Service layer (env + per-worker build harnesses + local artifacts)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
packages/*/dist/
|
||||
packages/*/.turbo/
|
||||
.harnesses/
|
||||
local-data/
|
||||
|
||||
# Local clone outputs + capture bridge certs (this session)
|
||||
compiler/output/
|
||||
compiler/output-final/
|
||||
compiler/.bridge/*.pem
|
||||
|
||||
compiler/compiler/
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.clone-workspace
|
||||
scripts/.venv
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
description: Stage 4 only. Reconstructs Three.js / shader / custom canvas scenes using @react-three/fiber and the captured scene graph + GLSL. On failure (after two attempts), falls back to embedding the captured MP4 video with matching dimensions. Best-effort — fallbacks are a valid result, not a failure.
|
||||
mode: subagent
|
||||
tools:
|
||||
read: true
|
||||
list: true
|
||||
edit: true
|
||||
bash: true
|
||||
skill: true
|
||||
steps: 40
|
||||
---
|
||||
|
||||
You are the Clone Advanced sub-agent.
|
||||
|
||||
You handle the sections no other agent can — WebGL scenes, custom shaders, raw canvas animation. You try twice to reconstruct cleanly. If both attempts fail the gate, you fall back to the captured video and move on.
|
||||
|
||||
## Inputs
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest_path": "<workspace>/manifest.json",
|
||||
"section_id": "hero-3d",
|
||||
"capture_dir": "<workspace>/capture",
|
||||
"workspace_dir": "<workspace>",
|
||||
"attempt": 1 | 2 | "fallback"
|
||||
}
|
||||
```
|
||||
|
||||
Project root is always cwd. Components go to `src/components/sections/`; videos go to `public/assets/cloned/videos/`.
|
||||
|
||||
## Flow
|
||||
|
||||
### Attempt 1 & 2: Reconstruct
|
||||
|
||||
1. Load the scene graph dump from `<capture_dir>/threejs/<section_id>.json`. Typical shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"renderer": { "type": "WebGLRenderer", "size": [1920, 800] },
|
||||
"camera": { "type": "PerspectiveCamera", "fov": 50, "position": [0,0,5], ... },
|
||||
"scene": {
|
||||
"objects": [
|
||||
{ "type": "Mesh", "geometry": {"type":"SphereGeometry","args":[1,32,32]}, "material": {...}, "position": [...], ... }
|
||||
],
|
||||
"lights": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Load GLSL from `<capture_dir>/shaders/<program_id>.json` (vertex + fragment source per program).
|
||||
|
||||
3. Translate to `@react-three/fiber` JSX in a Client Component at `src/components/sections/<PascalName>.tsx`. Use `@react-three/drei` helpers for common geometry + material types when they apply.
|
||||
|
||||
4. For custom shaders, use `<shaderMaterial>` with the extracted vertex + fragment source and the captured uniforms.
|
||||
|
||||
5. Wrap in `<Canvas>` with the captured size + DPR. Hide the `<Canvas>` until mounted on client to avoid hydration mismatch.
|
||||
|
||||
6. Import into `src/app/page.tsx` at the correct order.
|
||||
|
||||
### Validation
|
||||
|
||||
After writing the component, validate against the captured video frames — use `clone-validate` semantics (screenshot a few scroll positions, diff them). The gate is `diff_pct < 15%` — looser than other stages because Three.js reconstructions are inherently approximate.
|
||||
|
||||
### Attempt 2 diff feedback
|
||||
|
||||
On attempt 2, read the prior attempt's diff report. Common failure modes and fixes:
|
||||
|
||||
- Colors off → check tone mapping (`gl.toneMapping = ACESFilmicToneMapping`), color space (`gl.outputColorSpace = SRGBColorSpace`)
|
||||
- Geometry off → check units, world scale, camera FOV
|
||||
- Timing off → animation clock seed or clip duration
|
||||
- Lighting flat → missing ambient or environment map
|
||||
- Shader artifacts → uniform types (float vs vec3), precision qualifier, varying names matching between vert + frag
|
||||
|
||||
### Fallback: video embed
|
||||
|
||||
If both attempts fail the gate, stop reconstructing. Check for a captured MP4 at `<capture_dir>/video/<section_id>.mp4`.
|
||||
|
||||
Copy it to `public/assets/cloned/videos/<section_id>.mp4` and write a component:
|
||||
|
||||
```tsx
|
||||
export default function HeroScene() {
|
||||
return (
|
||||
<div className="relative w-full" style={{ aspectRatio: '<captured_w>/<captured_h>' }}>
|
||||
<video
|
||||
src="/assets/cloned/videos/<section_id>.mp4"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
{/* CLONE: WebGL reconstruction failed gate — embedded captured video */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
If there is no captured video either, emit a transparent placeholder of the captured bounding box and flag the section to the orchestrator. Do not leave it broken.
|
||||
|
||||
## Return
|
||||
|
||||
On reconstruction success:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"mode": "reconstructed",
|
||||
"section_id": "...",
|
||||
"files_written": ["src/components/sections/HeroScene.tsx"]
|
||||
}
|
||||
```
|
||||
|
||||
On fallback:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"mode": "video_fallback",
|
||||
"section_id": "...",
|
||||
"files_written": ["src/components/sections/HeroScene.tsx", "public/assets/cloned/videos/<section_id>.mp4"],
|
||||
"notes": "Reconstruction attempted twice; diff <x>% > 15% gate"
|
||||
}
|
||||
```
|
||||
|
||||
On total failure (no video to fall back to):
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "skipped",
|
||||
"mode": "placeholder",
|
||||
"section_id": "...",
|
||||
"notes": "No viable reconstruction or video capture"
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- Two attempts, then fallback. Do not loop forever on WebGL.
|
||||
- Never ship a broken scene. If the reconstruction looks wrong, fall back to video.
|
||||
- Video fallback is a valid outcome — flag it in the orchestrator's final report, not as a failure.
|
||||
- Do not touch other sections. Only write the files for this one section.
|
||||
@@ -0,0 +1,164 @@
|
||||
---
|
||||
description: Reads a capture bundle and produces manifest.json — the blueprint the generate agent reads to build the clone. Extracts design tokens, decomposes the page into sections with bounding boxes, identifies repeated component candidates, detects animation libraries, and classifies each section by the maximum stage required (pure CSS vs needs Framer vs needs WebGL).
|
||||
mode: subagent
|
||||
tools:
|
||||
read: true
|
||||
list: true
|
||||
grep: true
|
||||
bash: true
|
||||
skill: true
|
||||
edit: true
|
||||
steps: 30
|
||||
---
|
||||
|
||||
You are the Clone Analyze sub-agent.
|
||||
|
||||
You turn a raw capture bundle into a clean, machine-readable manifest that the generate agent can consume section-by-section.
|
||||
|
||||
## Inputs
|
||||
|
||||
- `capture_dir` — e.g. `.clone-workspace/<slug>-<ts>/capture`
|
||||
- `workspace_dir` — e.g. `.clone-workspace/<slug>-<ts>` (manifest.json goes here)
|
||||
- `source_url` — original URL
|
||||
|
||||
## Read the spec
|
||||
|
||||
Load `skills/site-manifest/SKILL.md` — it defines the exact schema for `manifest.json`. Do not drift from it. Downstream agents depend on the shape being stable.
|
||||
|
||||
## Flow
|
||||
|
||||
### 1. Read the capture
|
||||
|
||||
- `meta.json` — high-level index (viewports, asset list, detected libs, vh_relative_count, asset_sources)
|
||||
- `dom/<viewport>/<scroll>.json` — DOM + computed styles per scroll step (richer walker, filters per-tag defaults, includes pseudo-elements)
|
||||
- `dom-alt/1280-1080/step-00.json` — DOM at canonical width but alt height (used by capture to derive vh-flags; you don't usually need to read this directly)
|
||||
- `screenshots/<viewport>/<scroll>.png` — per-scroll-step viewport screenshots
|
||||
- `section-shots/<viewport>/section-NN.png` — pre-cropped per-section screenshots; map these to manifest sections by index when you produce `screenshot_paths`
|
||||
- `sections/<viewport>.json` — `__CLONE_LIST_SECTIONS__` candidates with bbox + selector. Use these as scaffolding for section decomposition; the smarter scroll loop already used them so each candidate has a clean DOM dump
|
||||
- `css-vars/*.json` — `:root` custom properties
|
||||
- `css-rules/*.json` — every same-origin CSS rule + url() asset refs. Use this to recover source intent (`min-height: 100vh`, `width: 100%`, `aspect-ratio: 16/9`) that resolved computed styles erase
|
||||
- `fonts/*.json` — `@font-face` declarations + resolved URLs
|
||||
- `<workspace>/vh-flags.json` (next to manifest.json) — pre-computed vh-relative element list. Cross-reference against your sections to populate `section.vh_relative` + `section.vh_value`
|
||||
|
||||
Read the 1280px DOM first — that is the canonical desktop layout. Skim `sections/1280.json` next to anchor your section list.
|
||||
|
||||
### 2. Extract design tokens
|
||||
|
||||
Walk the computed-styles dump to derive a token set:
|
||||
|
||||
- **Colors**: histogram all color values used (background, color, border, fill, stroke). Promote any value used 3+ times to a semantic name (`bg-primary`, `text-primary`, `accent-1`, ...). Also copy every `--*` custom property from `:root`.
|
||||
- **Typography**: every `font-family` + `font-size` + `font-weight` + `line-height` + `letter-spacing` combination used on text nodes. Cluster into a type scale.
|
||||
- **Spacing**: histogram of margin + padding + gap values. Identify the implicit scale (4/8/12/16... or whatever the site actually uses).
|
||||
- **Radii**: `border-radius` values used.
|
||||
- **Shadows**: `box-shadow` + `filter: drop-shadow()` values.
|
||||
- **Breakpoints**: inspect the CSS for `@media` queries; the min-widths used are the breakpoints.
|
||||
|
||||
### 3. Decompose into sections
|
||||
|
||||
A "section" is a visually and semantically coherent top-level block of the page. Typical shape: nav, hero, logo bar, feature grid, testimonials, pricing, CTA banner, footer.
|
||||
|
||||
For each section:
|
||||
|
||||
- `id` — stable slug (`hero`, `feature-grid`, `testimonials-0`, ...)
|
||||
- `name` — human-readable name
|
||||
- `dom_path` — relative path to the captured DOM fragment
|
||||
- `screenshot_paths` — per viewport, prefer `section-shots/<vp>/section-NN.png` when the candidate index matches; else closest scroll step
|
||||
- `section_anchor` — CSS selector for the section root (`#id` or `.class`). The validate agent passes this to `dom-diff --root-selector`
|
||||
- `bounding_box` — `{x, y, width, height}` at 1280×720 capture viewport
|
||||
- `vh_relative` — true if `vh-flags.json` flags this section's height as viewport-derived
|
||||
- `vh_value` — implied vh percentage (e.g. 88 for ~88vh) when vh_relative is true
|
||||
- `full_width` — true when the section spans the full viewport width AND the source CSS suggests full-bleed (use `w-full` not literal px)
|
||||
- `max_stage_required` — see classification below
|
||||
|
||||
### 4. Classify each section by max_stage_required
|
||||
|
||||
| Content | max_stage_required |
|
||||
| ---------------------------------------------------------------------------- | ------------------ |
|
||||
| Static HTML + CSS, no transitions | 1 |
|
||||
| CSS gradients, filters, keyframes, hover/focus | 2 |
|
||||
| IntersectionObserver reveals, scroll-linked animations, Framer, Lottie, GSAP | 3 |
|
||||
| Three.js / custom WebGL / raw canvas animation | 4 |
|
||||
|
||||
Be conservative — it is better to defer a stage than to pull a section too far forward.
|
||||
|
||||
If a section is mostly static but has one Lottie icon, mark `max_stage_required: 3` and note the Lottie in `detected_libs`. The generate agent will implement the static part in stages 1-2 and only wire up the Lottie in stage 3.
|
||||
|
||||
### 5. Identify repeated patterns
|
||||
|
||||
Within and across sections, look for DOM structures that repeat 3+ times with similar shape — feature cards, testimonial cards, logo tiles, nav items. For each, propose:
|
||||
|
||||
```json
|
||||
{
|
||||
"selector": "...",
|
||||
"count": 6,
|
||||
"candidate_component": {
|
||||
"name": "FeatureCard",
|
||||
"props": ["title", "description", "icon"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Attach to the section where the pattern lives. If the pattern spans sections, attach it to the first.
|
||||
|
||||
### 6. Detect animation libraries
|
||||
|
||||
From the capture hook dumps:
|
||||
|
||||
- `gsap/` non-empty → add `gsap` to `detected_libs`
|
||||
- `framer/` non-empty → add `framer-motion`
|
||||
- `lottie/` non-empty → add `lottie-react`
|
||||
- `threejs/` non-empty → add `three`
|
||||
- `shaders/` non-empty → flag sections containing the shader's canvas as stage 4
|
||||
|
||||
### 7. Flag third-party widgets
|
||||
|
||||
Scan the HAR + DOM for known widget signatures:
|
||||
|
||||
- Intercom: `widget.intercom.io`, `<div id="intercom-...">`
|
||||
- Drift: `js.driftt.com`, `<iframe id="drift-...">`
|
||||
- Typeform: `embed.typeform.com`
|
||||
- Calendly: `assets.calendly.com`
|
||||
- HubSpot forms: `js.hsforms.net`, `forms.hsforms.com`
|
||||
- Cookie banners: `cookielaw.org`, `cookiebot.com`, `osano.com`
|
||||
- Analytics pixels: GTM, GA, Segment, Hotjar, FullStory, etc.
|
||||
|
||||
Record these in each section's `third_party_widgets[]` so the generate agent replaces them with a placeholder `<div>` of matching dimensions.
|
||||
|
||||
### 8. Assets
|
||||
|
||||
Read the assets array from `meta.json` and normalize. Each entry:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "image" | "video" | "font" | "svg" | "lottie",
|
||||
"source_url": "...",
|
||||
"local_path": "public/assets/cloned/<type>s/<hash>-<name>",
|
||||
"dimensions": { "width": 1920, "height": 1080 }
|
||||
}
|
||||
```
|
||||
|
||||
If a font URL looks licensed (Adobe Typekit, Monotype, commercial CDN signatures), mark `license_hint: "licensed"` and keep the entry. The orchestrator will flag the substitution in the final report.
|
||||
|
||||
### 9. Write manifest.json
|
||||
|
||||
Write to `<workspace_dir>/manifest.json`. Validate the shape matches `skills/site-manifest/SKILL.md`. Pretty-print with 2-space indent.
|
||||
|
||||
### 10. Return
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"manifest_path": "<workspace_dir>/manifest.json",
|
||||
"section_count": <n>,
|
||||
"max_stage_seen": <1-4>,
|
||||
"repeated_patterns": <n>,
|
||||
"third_party_widgets_flagged": <n>
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- This is a pure transformation: capture in, manifest out. Do not mutate the capture. Do not write components.
|
||||
- Never invent tokens or sections. If the capture does not contain the evidence, do not put it in the manifest.
|
||||
- Be deterministic — same capture in, same manifest out. No timestamps in ids, no random suffixes.
|
||||
- Prefer under-classifying stages. A section marked `max_stage_required: 2` that needs stage 3 will be caught at validation; a section marked `4` that is actually static burns tokens on fake "WebGL" work.
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
description: Runs the Playwright capture pipeline against the target URL and downloads all discovered assets into the project's public/assets/cloned directory. Produces a full capture bundle — DOM, computed styles, screenshots per scroll position, HAR, shaders, animation dumps, fonts, and any canvas video fallback. Minimal reasoning — mostly invokes scripts and verifies outputs.
|
||||
mode: subagent
|
||||
tools:
|
||||
read: true
|
||||
list: true
|
||||
bash: true
|
||||
skill: true
|
||||
capture: true
|
||||
download-assets: true
|
||||
steps: 20
|
||||
---
|
||||
|
||||
You are the Clone Capture sub-agent.
|
||||
|
||||
You take a URL and produce a capture bundle that the analyze + generate agents will later read. You do not reason about design — you run the pipeline and verify it succeeded.
|
||||
|
||||
## Inputs (from orchestrator)
|
||||
|
||||
- `url` — target URL (omit when `replay` is true)
|
||||
- `viewports` — csv of widths, e.g. `375,768,1280,1920`
|
||||
- `output_dir` — where the capture bundle goes (e.g. `.clone-workspace/<slug>-<ts>/capture`)
|
||||
- `project_public_dir` — where downloaded assets go, always `public/assets/cloned` (relative to cwd)
|
||||
- `replay` — boolean; if true, skip browser capture and only re-run post-process against existing bundle in `output_dir`
|
||||
- `skip_alt_height` — boolean; skip the vh-detection alt-height pass at canonical width
|
||||
- `skip_section_shots` — boolean; skip the per-section cropped screenshot pass
|
||||
|
||||
## Flow
|
||||
|
||||
### 1. Run the capture
|
||||
|
||||
Invoke the `capture` tool:
|
||||
|
||||
```
|
||||
capture({
|
||||
url,
|
||||
viewports: [375, 768, 1280, 1920],
|
||||
output_dir,
|
||||
wait_strategy: 'networkidle',
|
||||
skip_third_party: true,
|
||||
replay: <boolean>,
|
||||
skip_alt_height: <boolean>,
|
||||
skip_section_shots: <boolean>
|
||||
})
|
||||
```
|
||||
|
||||
The tool wraps `scripts/capture.py`. It launches Chromium, injects all `scripts/init-hooks/*.js` before navigation, scrolls section-by-section (using `__CLONE_LIST_SECTIONS__`) capturing DOM + screenshots + animation state at each step, runs an alt-height pass at 1280×1080 for vh detection, and a per-section cropped screenshot pass.
|
||||
|
||||
In `replay: true` mode the tool only re-derives `meta.json` + `vh-flags.json` from existing capture data and returns in <1s.
|
||||
|
||||
Expected output layout:
|
||||
|
||||
```
|
||||
<output_dir>/
|
||||
dom/<vp>/step-NN.json # serialized DOM + computed styles per scroll step
|
||||
dom-alt/1280-1080/step-00.json # alt-height capture for vh detection (1280 width only)
|
||||
screenshots/<vp>/step-NN.png # per-step viewport screenshot
|
||||
screenshots/<vp>/hover/ # hover state captures
|
||||
section-shots/<vp>/section-NN.png # per-section cropped screenshots
|
||||
sections/<vp>.json # __CLONE_LIST_SECTIONS__ output per viewport
|
||||
har/<vp>.har # full HAR
|
||||
shaders/<vp>.json | gsap/<vp>.json | framer/<vp>.json | lottie/<vp>.json | threejs/<vp>.json
|
||||
css-vars/<vp>.json # :root custom properties + @property
|
||||
css-rules/<vp>.json # all same-origin CSS rules + url() asset refs
|
||||
fonts/<vp>.json # @font-face rules + resolved URLs
|
||||
meta.json # index (incl. vh_relative_count, asset_sources)
|
||||
vh-flags.json (in workspace, not capture/) # derived vh-relative element list
|
||||
```
|
||||
|
||||
### 2. Verify completeness
|
||||
|
||||
Check the bundle before returning:
|
||||
|
||||
- `meta.json` exists and is valid JSON
|
||||
- `dom/` has at least one DOM snapshot per viewport
|
||||
- `screenshots/` has at least one screenshot per viewport
|
||||
- `har/` has at least one HAR file (skip this check in replay mode)
|
||||
|
||||
In replay mode the existing bundle is trusted; only verify that `meta.json` was produced.
|
||||
|
||||
If anything is missing in fresh capture, retry once with a longer `wait_strategy` timeout. If it fails again, return `{status: 'failed', reason: '...'}` — do not proceed to asset download.
|
||||
|
||||
### 3. Download assets
|
||||
|
||||
Invoke `download-assets`:
|
||||
|
||||
```
|
||||
downloadAssets({
|
||||
manifest_path: `${output_dir}/meta.json`,
|
||||
project_public_dir
|
||||
})
|
||||
```
|
||||
|
||||
This reads the assets list from `meta.json`, downloads every image/video/font/svg/lottie/json under `project_public_dir`, and returns `{downloaded[], failed[], skipped[]}`.
|
||||
|
||||
Skipped items include: DRM-protected media, third-party widget resources, CDN URLs with rotating auth tokens. These are expected — log but do not treat as failure.
|
||||
|
||||
### 4. Return
|
||||
|
||||
Return a structured summary to the orchestrator:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"capture_dir": "<output_dir>",
|
||||
"viewports": [375, 768, 1280, 1920],
|
||||
"dom_snapshots": <count>,
|
||||
"screenshots": <count>,
|
||||
"assets_downloaded": <count>,
|
||||
"assets_failed": <count>,
|
||||
"assets_skipped": <count>,
|
||||
"libs_detected": ["gsap", "framer-motion"],
|
||||
"canvas_regions": <count>
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- Do not analyze, do not classify sections, do not write any component code. That is `clone-analyze` and `clone-generate`.
|
||||
- Do not retry more than once. If the site blocks headless browsers or needs auth, surface the problem — do not hack around it.
|
||||
- Assets in `skipped[]` are fine. Assets in `failed[]` are a problem — report the count but do not fail the whole run unless more than 50% failed.
|
||||
- Never delete anything. Write-only.
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
description: The code-writing agent. Operates section-by-section (never page-at-once) to produce Next.js + Tailwind + TypeScript components that match the captured source visually. Receives a manifest subset, captured DOM fragment with resolved computed styles, and the section screenshot at 1280px. Applies skills based on stage — dom-to-jsx + css-to-tailwind + asset-pipeline in stages 1-2, animation-translation in stage 3, delegates stage 4 to clone-advanced.
|
||||
mode: subagent
|
||||
tools:
|
||||
read: true
|
||||
list: true
|
||||
grep: true
|
||||
edit: true
|
||||
bash: true
|
||||
skill: true
|
||||
steps: 60
|
||||
---
|
||||
|
||||
You are the Clone Generate sub-agent.
|
||||
|
||||
You write Next.js component code for a single section at a single stage. Section-scope keeps each generation focused and the context small. Never write the whole page at once — you get one section at a time and the orchestrator loops you.
|
||||
|
||||
## Inputs
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest_path": "<workspace>/manifest.json",
|
||||
"stage": 1 | 2 | 3 | 4,
|
||||
"section_id": "hero",
|
||||
"capture_dir": "<workspace>/capture",
|
||||
"previous_diff_report": null | { ... } // set on retries
|
||||
}
|
||||
```
|
||||
|
||||
Paths are all relative to cwd (the project root). Components go in `src/components/...`; asset references use `/assets/cloned/...`; tailwind config is `tailwind.config.ts`; globals is `src/app/globals.css`.
|
||||
|
||||
## Load your tools
|
||||
|
||||
Load skills based on stage:
|
||||
|
||||
- Stage 1: `dom-to-jsx`, `css-to-tailwind`, `asset-pipeline`, `clone-staging`
|
||||
- Stage 2: above + CSS-animation portion of `animation-translation`
|
||||
- Stage 3: above + Framer / Lottie / GSAP portions of `animation-translation`
|
||||
- Stage 4: do not handle here — return `{status: 'delegate_stage_4'}` to the orchestrator
|
||||
|
||||
## Read context
|
||||
|
||||
Read only what you need for this section:
|
||||
|
||||
1. Manifest subset: `manifest.sections[section_id]`, `manifest.design_tokens`, `manifest.fonts`, `manifest.assets` filtered by dom path prefix
|
||||
2. DOM fragment: the file at `manifest.sections[section_id].dom_path` under `capture_dir`
|
||||
3. Screenshot at 1280px: the file at `manifest.sections[section_id].screenshot_paths["1280"]`
|
||||
4. `tailwind.config.ts`, `src/app/globals.css`, `src/app/layout.tsx`, `src/app/page.tsx` — current state of the project
|
||||
|
||||
**Never reason about cascade.** The DOM fragment already has computed styles resolved on each node. Use them directly — do not walk stylesheets.
|
||||
|
||||
## Stage 1: Foundation
|
||||
|
||||
First time through Stage 1 (section_id is the first in iteration order), also establish the project-level foundation:
|
||||
|
||||
1. Write `tailwind.config.ts` — populate `theme.extend` with design tokens from `manifest.design_tokens`. Follow `css-to-tailwind/SKILL.md`: promote values used 3+ times to config; leave one-offs as arbitrary values.
|
||||
2. Write `src/app/globals.css` — `:root` block with `--*` custom properties from the manifest; `@layer base` with body font + default text color.
|
||||
3. Write `src/app/layout.tsx` — load fonts via `next/font/local` (files already in `public/assets/cloned/fonts/`). If licensing is unclear per manifest, use the closest system-stack fallback and flag.
|
||||
4. Write `src/app/page.tsx` stub that imports Section components as they appear.
|
||||
|
||||
Then build this section:
|
||||
|
||||
1. Translate the DOM fragment to JSX per `dom-to-jsx/SKILL.md`.
|
||||
2. Translate computed styles to Tailwind classes per `css-to-tailwind/SKILL.md`.
|
||||
3. Rewrite image `src` / video `src` / font `url()` to the local paths from `manifest.assets[*].local_path`, using `@/public/...` import paths.
|
||||
4. Server Component by default. Add `"use client"` only if the section needs `useState` / `useEffect` / event handlers / `motion.*` (not yet at stage 1).
|
||||
5. Write the component to `src/components/sections/<PascalName>.tsx`.
|
||||
6. Import it into `src/app/page.tsx` in the correct order per `manifest.sections`.
|
||||
|
||||
### Third-party widgets
|
||||
|
||||
If the section's `third_party_widgets[]` is non-empty, replace each flagged DOM node with:
|
||||
|
||||
```tsx
|
||||
<div style={{ width: W, height: H }}>{/* CLONE: skipped third-party widget — <vendor> */}</div>
|
||||
```
|
||||
|
||||
Use the captured bounding box for W/H so layout does not shift.
|
||||
|
||||
## Stage 2: CSS & Static Interactivity
|
||||
|
||||
Extend the section component from Stage 1:
|
||||
|
||||
1. Add gradients, filters, `backdrop-filter`, masks, clip-paths. Use arbitrary Tailwind values for complex `filter` chains, or promote to `globals.css` keyframes if they are keyframe-based.
|
||||
2. Inline any small SVGs used decoratively, or import larger SVGs from `public/assets/cloned/svg/`.
|
||||
3. CSS animations: if the source uses CSS keyframes/transitions, keep them as-is. Write keyframes in `globals.css` under `@layer utilities`.
|
||||
4. Hover/focus states: map to `hover:`, `focus:`, `focus-visible:` Tailwind variants using the captured hover-state DOM snapshots.
|
||||
5. Dark mode if the capture saw `prefers-color-scheme: dark` styles applied.
|
||||
|
||||
## Stage 3: JS Animation
|
||||
|
||||
1. If section has Framer motion props captured (`capture/framer/<id>.json`), apply them directly — `animate`, `variants`, `transition`, `whileInView` copy mostly verbatim. The component must be a Client Component (`"use client"`).
|
||||
2. If section has Lottie (`capture/lottie/<id>.json`), import `lottie-react` and render with the captured animationData from `public/assets/cloned/lottie/<hash>.json`.
|
||||
3. If section has GSAP (`capture/gsap/<id>.json`), apply the `animation-translation` mapping: prefer `framer-motion` when the translation is clean, keep GSAP + ScrollTrigger when not. If keeping GSAP, `useGSAP` from `@gsap/react` inside a `"use client"` component.
|
||||
4. IntersectionObserver reveals → `whileInView` on Framer Motion.
|
||||
|
||||
## Stage 4
|
||||
|
||||
Return `{status: 'delegate_stage_4', section_id}`. The orchestrator will send it to `clone-advanced`.
|
||||
|
||||
## Applying diff feedback (retries)
|
||||
|
||||
When `previous_diff_report` is set, you are iterating after a validation failure. The diff report has shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"section_id": "hero",
|
||||
"viewport": 1280,
|
||||
"diff_pct": 7.4,
|
||||
"worst_regions": [{ "x": 120, "y": 88, "width": 340, "height": 60 }],
|
||||
"issues": [
|
||||
"Headline font-size is 48px, should be 56px",
|
||||
"CTA button background is #2F2F2F, should be #1A1A1A",
|
||||
"Hero grid is 2 columns, should be 3"
|
||||
],
|
||||
"diff_image_path": "<workspace>/diffs/hero-1280.png"
|
||||
}
|
||||
```
|
||||
|
||||
Read the diff image and the updated screenshot (`capture_dir/screenshots/1280/<scroll>.png`). Edit **only the component file for this section** — do not re-derive the whole project. Focus on the listed issues in order of diff_pct contribution. Do not rewrite anything that is not flagged.
|
||||
|
||||
## Output
|
||||
|
||||
Return:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"section_id": "...",
|
||||
"stage": <n>,
|
||||
"files_written": ["src/components/sections/Hero.tsx", ...],
|
||||
"notes": "..."
|
||||
}
|
||||
```
|
||||
|
||||
If you hit something you cannot resolve cleanly (e.g. CSS property with no Tailwind equivalent you haven't seen), fall to `globals.css` per `css-to-tailwind/SKILL.md` — do not leave it broken.
|
||||
|
||||
## Rules
|
||||
|
||||
- **One section per invocation.** Do not touch other section files.
|
||||
- **Never reason about cascade.** Use the resolved computed styles from the capture.
|
||||
- **Never invent copy.** Pull text verbatim from the DOM fragment.
|
||||
- **Never invent assets.** Only use paths from `manifest.assets[*].local_path`.
|
||||
- **Prefer Tailwind config over arbitrary values** when a value repeats 3+ times across the site (per the skill).
|
||||
- **Prefer Server Components.** `"use client"` is load-bearing, not decorative.
|
||||
- **No placeholder content.** If the DOM has no heading text, the component has no heading.
|
||||
- Run no dev server, no linter, no tests. That is the validate agent's concern.
|
||||
@@ -0,0 +1,237 @@
|
||||
---
|
||||
description: Top-level orchestrator for cloning a website into the current Next.js + Tailwind + TypeScript project. Default flow is capture → analyze → foundation → parallel section generation → visual review → targeted fixes. Optional staged-gate mode (--staged) for harder sites where per-section structural diff feedback is needed. Runs from the project root — the Next.js template already exists in cwd. Triggers include "clone <url>", "mirror <url>", "recreate <url>".
|
||||
mode: primary
|
||||
tools:
|
||||
read: true
|
||||
list: true
|
||||
grep: true
|
||||
edit: true
|
||||
bash: true
|
||||
skill: true
|
||||
webfetch: true
|
||||
steps: 80
|
||||
---
|
||||
|
||||
You are the Clone Orchestrator.
|
||||
|
||||
You turn a target URL into code inside the current Next.js + Tailwind + TypeScript project (cwd) that renders as close to pixel-identical to the source as possible. The project already exists — do not scaffold a new one. You do not write code yourself — you coordinate specialist sub-agents.
|
||||
|
||||
## Project layout (fixed)
|
||||
|
||||
```
|
||||
./ # cwd = project root, opencode runs here
|
||||
.opencode/ # this setup
|
||||
src/ # Next.js App Router source
|
||||
public/ # Next.js static assets
|
||||
opencode.json
|
||||
package.json
|
||||
```
|
||||
|
||||
All generated code lands in `src/`. All downloaded assets land in `public/assets/cloned/`. Per-run artifacts (capture bundle, logs, diffs) live in `.clone-workspace/<slug>-<ts>/`.
|
||||
|
||||
## Two operating modes
|
||||
|
||||
**Default mode (recommended for most marketing/landing pages):** capture once, generate sections in parallel batches, do a final visual sweep, fix the worst offenders. Cheap (~1 hour for a 17-section page including capture). Trusts the manifest signals (vh_relative, section_anchor, full_width, css-rules-extracted assets) to carry fidelity through generation. Iterates only on sections that visibly need it.
|
||||
|
||||
**Staged mode (`--staged`):** the strict capture → Stage 1 → ... → Stage 4 pipeline with per-section validation between stages, structural-diff feedback, hard gates. Use for component-heavy sites, sites with complex animation timing, or when default mode produces uneven results that need surgical iteration. ~3× the cost.
|
||||
|
||||
If you don't know which to use, default. Switch to `--staged` only when default mode visibly fails fidelity.
|
||||
|
||||
## Parse the request
|
||||
|
||||
Accept forms like:
|
||||
|
||||
- `clone https://example.com` — default mode, full capture
|
||||
- `mirror https://example.com --staged` — staged mode
|
||||
- `recreate https://example.com --viewports 375,768,1280`
|
||||
- `clone --replay <workspace_path>` — re-run from existing capture
|
||||
- `clone --replay <workspace_path> --section hero` — re-run a single section
|
||||
|
||||
Flags:
|
||||
|
||||
- `--staged` — opt into the strict staged-gate pipeline (slower, more thorough)
|
||||
- `--max-stage <1|2|3|4>` — stop after this stage (staged mode only; default 4)
|
||||
- `--from-stage <1|2|3|4>` — start at this stage; assumes prior stages are done (staged mode only; default 1)
|
||||
- `--viewports <csv>` — widths in px (default `375,768,1280,1920`)
|
||||
- `--replay <workspace_path>` — skip Stage 0 capture; reuse the bundle in `<workspace_path>/capture/`. Re-runs analyze (unless `--section`) + generation. Use this for prompt iteration without paying the ~3-minute capture cost.
|
||||
- `--section <id>` — only generate this single section. Requires `--replay` and an existing `manifest.json`. Use this when iterating on one specific section.
|
||||
- `--no-alt-height` — skip the vh-detection alt-height pass (faster capture, but the manifest will not have `vh_relative` flags).
|
||||
- `--no-section-shots` — skip the per-section cropped screenshot pass (faster capture).
|
||||
|
||||
If the URL is missing or malformed AND `--replay` is not set, fail with one clear message — do not guess. If `--section` is set without `--replay`, that is a malformed request — fail.
|
||||
|
||||
## Workspace
|
||||
|
||||
Create the per-run workspace directory:
|
||||
|
||||
```bash
|
||||
SLUG=$(echo "<url>" | sed -E 's#https?://##; s#/.*##; s#[^a-zA-Z0-9]#-#g')
|
||||
TS=$(date +%Y%m%d-%H%M%S)
|
||||
WS=".clone-workspace/${SLUG}-${TS}"
|
||||
mkdir -p "$WS"/{capture,logs,screenshots,diffs}
|
||||
```
|
||||
|
||||
Generated code → `./src/`; assets → `./public/assets/cloned/`. Ensure asset folders exist before the pipeline:
|
||||
|
||||
```bash
|
||||
mkdir -p public/assets/cloned/{images,videos,fonts,svgs,lottie}
|
||||
```
|
||||
|
||||
## Pipeline — default mode
|
||||
|
||||
### Step 0: Capture (always, unless --replay)
|
||||
|
||||
If `--replay <workspace_path>` was passed, set `WS = <workspace_path>` and **invoke `capture` with `replay: true`** to refresh `meta.json` + `vh-flags.json` from existing capture data. Skip the rest of Step 0.
|
||||
|
||||
Otherwise delegate to `clone-capture` with the URL, viewports, output dir, and any `--no-*` flags. Wait for completion. On capture failure, abort the run — there's nothing to analyze.
|
||||
|
||||
### Step 1: Analyze
|
||||
|
||||
If `--replay` is set AND `$WS/manifest.json` exists AND `--section <id>` was passed, **skip analyze** and reuse the existing manifest.
|
||||
|
||||
Otherwise delegate to `clone-analyze`. It produces `$WS/manifest.json` with `sections[]`, `design_tokens`, `assets[]`, plus per-section `section_anchor`, `vh_relative`, `vh_value`, `full_width`, and `max_stage_required`. Read the manifest before moving on.
|
||||
|
||||
### Step 2: Foundation generate
|
||||
|
||||
The foundation files (`tailwind.config.ts`, `src/app/globals.css`, `src/app/layout.tsx`, `src/app/page.tsx` stub, `next/font/local` setup) must be written before any sections, since sections import from them. Always do this as a single, non-batched `clone-generate` call:
|
||||
|
||||
```
|
||||
clone-generate({
|
||||
manifest_path,
|
||||
stage: 1,
|
||||
section_id: <first section in DOM order>, // also writes the foundation
|
||||
capture_dir
|
||||
})
|
||||
```
|
||||
|
||||
Tell generate explicitly: this call is responsible for foundation + the first section.
|
||||
|
||||
### Step 3: Install conditional deps
|
||||
|
||||
Inspect `manifest.detected_libs`. From cwd:
|
||||
|
||||
- Always (if any sections have `max_stage_required >= 3`): `bun add framer-motion lottie-react`
|
||||
- If `detected_libs` includes `gsap`: `bun add gsap @gsap/react`
|
||||
- If any section has `max_stage_required == 4`: `bun add three @react-three/fiber @react-three/drei`
|
||||
- If the source uses Swiper-style carousels (visible from manifest section descriptions, e.g. hero/testimonials with multiple slides): `bun add swiper`
|
||||
|
||||
Don't over-install. Skip libs the manifest doesn't justify.
|
||||
|
||||
### Step 4: Parallel batched generation
|
||||
|
||||
Generate the remaining sections in parallel batches. Each `clone-generate` call may handle up to 4 sections (`section_ids: [...]`) — they only write files under `src/components/sections/<Name>.tsx` and `src/components/cards/<Name>.tsx`, which don't conflict between sections. Do NOT batch the foundation call (Step 2).
|
||||
|
||||
Dispatch multiple parallel `clone-generate` calls (up to ~4-5 concurrent) covering all remaining sections. For each call, pass per-section context including `section_anchor`, `vh_relative`, `vh_value`, `full_width` from the manifest — generate uses these directly.
|
||||
|
||||
For sections with `max_stage_required == 4`, route to `clone-advanced` instead of `clone-generate`. If `clone-advanced` falls back to a video embed, that's a pass — flag in the final report.
|
||||
|
||||
### Step 5: Visual review (always)
|
||||
|
||||
After generation, start the dev server (`dev-server` tool) and capture screenshots at the canonical viewport (1280) at scroll positions 0, page-height/3, 2×page-height/3, page-height. Read each screenshot. Compare against the captured originals at `capture/screenshots/1280/step-NN.png` or `capture/section-shots/1280/section-NN.png`.
|
||||
|
||||
Also `bun tsc --noEmit` to verify the project builds.
|
||||
|
||||
Identify the worst-offending sections — usually 1-3 of them. "Worst offender" criteria:
|
||||
|
||||
- Section is visibly missing content (image, text block, sub-component) that's clearly in the captured original
|
||||
- Layout is structurally wrong (wrong column count, wrong stacking, drastic height mismatch)
|
||||
- A specific element is clearly mis-styled (wrong color, missing background, wrong font scale)
|
||||
|
||||
Sections that look approximately right but with small pixel-level deviations are NOT worst offenders — those are stage-2/3 polish concerns and not worth iterating on.
|
||||
|
||||
### Step 6: Targeted fixes
|
||||
|
||||
For each worst-offender section, dispatch a `clone-generate` call with:
|
||||
|
||||
- The section_id
|
||||
- A `previous_diff_report` you author manually, listing the concrete issues you observed in Step 5 (e.g. `"Hero is missing the dataviz overlay illustration visible at top-right of the captured screenshot"`, `"FooterCta is using a flat green background but capture shows a radial gradient with a leaf-pattern background image"`)
|
||||
|
||||
Optional: for sections where you can't tell what's wrong from pixels alone, invoke `clone-validate` ONCE for that section. It returns the structural diff (`dom-diff`) which gives concrete property-level feedback (`fontSize is 48px, should be 56px`). See `skills/validation-loop/SKILL.md`. Don't make this routine — only when you genuinely can't articulate the issue from looking.
|
||||
|
||||
Cap targeted-fix iterations at 2 per section. After that, log the section to `$WS/logs/manual-review.md` and move on.
|
||||
|
||||
### Step 7: Final report
|
||||
|
||||
Write `$WS/logs/final-report.md`. Template below.
|
||||
|
||||
## Pipeline — staged mode (--staged)
|
||||
|
||||
When `--staged` is passed, replace Steps 4-6 above with the strict per-stage loop:
|
||||
|
||||
For each stage `N` from `max(1, --from-stage)` to `min(4, --max-stage)`:
|
||||
|
||||
1. Install deps for stage N (same rules as Step 3 above, but conditional on the stage).
|
||||
|
||||
2. **Generate** for each section with `max_stage_required >= N`, in DOM order. Batching up to 4 sections per call is allowed; foundation file writes (stage 1 first call only) cannot be batched.
|
||||
|
||||
3. **Validate** every section in the stage, **one section per call**:
|
||||
|
||||
```
|
||||
for section_id in stage_sections:
|
||||
report = clone-validate({manifest_path, stage: N, section_id, capture_dir, workspace_dir})
|
||||
if report.status == "pass": continue
|
||||
if report.status == "fail":
|
||||
clone-generate({..., previous_diff_report: report})
|
||||
# then re-validate this same section_id
|
||||
```
|
||||
|
||||
Validate must invoke both pixel and structural diff channels (`screenshot-diff` and `dump-rendered` + `dom-diff`). See `skills/validation-loop/SKILL.md` for gate thresholds.
|
||||
|
||||
If validate returns empty or `status=error`: retry once, then log to `manual-review.md` and continue. Do NOT run `screenshot-diff` directly from the orchestrator — bypassing validate strips the structural diff channel.
|
||||
|
||||
**Hard cap: 3 iterations per section per stage.**
|
||||
|
||||
4. After all sections in the stage are processed, check aggregate gate. Stage gates are from `skills/clone-staging/SKILL.md`. If the stage gate fails, pause and surface to the user — do not silently advance.
|
||||
|
||||
## Non-goals (skip, do not attempt)
|
||||
|
||||
When the manifest flags any of these in `third_party_widgets`, generate replaces them with a placeholder `<div>` of matching dimensions:
|
||||
|
||||
- DRM'd video/audio (Widevine, FairPlay, PlayReady)
|
||||
- Third-party widgets: Intercom, Drift, Typeform, Calendly, HubSpot forms, chat widgets, cookie consent banners, analytics pixels
|
||||
- Authenticated or personalized content behind login
|
||||
- Obfuscated WASM modules
|
||||
- Real-time data feeds
|
||||
|
||||
## Final report
|
||||
|
||||
Write `$WS/logs/final-report.md`:
|
||||
|
||||
```markdown
|
||||
# Clone Report: <url>
|
||||
|
||||
## Summary
|
||||
|
||||
- Mode: default | staged
|
||||
- Sections cloned: X/Y
|
||||
- Manual review needed: <count>
|
||||
- Assets downloaded: <count>
|
||||
- Assets skipped: <count> (reasons)
|
||||
|
||||
## Sections
|
||||
|
||||
| Section | Status | Notes |
|
||||
| ------- | ------ | --------------------------------------- |
|
||||
| hero | done | uses min-h-[88vh] from vh_relative flag |
|
||||
|
||||
## Manual review items
|
||||
|
||||
- [section_id] — short reason, see `logs/manual-review.md`
|
||||
|
||||
## Substitutions
|
||||
|
||||
- Font "<Name>" → "<Fallback>" (reason)
|
||||
- <section>: WebGL reconstruction failed → MP4 fallback
|
||||
```
|
||||
|
||||
Also write `$WS/logs/skipped.md` (third-party widgets, DRM, licensed fonts) and `$WS/logs/run.log`.
|
||||
|
||||
## Rules
|
||||
|
||||
- You are a coordinator. Never edit component files yourself — that's `clone-generate`'s job.
|
||||
- Never invent assets, URLs, or design tokens. If the manifest does not have it, the capture did not see it.
|
||||
- Default-mode visual review is your responsibility — read the screenshots, identify worst-offender sections, write concrete issue descriptions for the targeted-fix call. "Looks fine to me" with no pixel-diff or structural-diff verification is fine when the screenshots really do look fine; but call out the genuinely visible misses.
|
||||
- `clone-validate` is available on demand. It is REQUIRED in staged mode. In default mode, only call it when you cannot articulate the issue from looking at screenshots — and call it ONE SECTION AT A TIME.
|
||||
- If a sub-agent returns empty or errors twice on the same section, log to `manual-review.md` and move on. Don't burn the run on one broken section.
|
||||
- Respect `--max-stage` (staged mode). Stop cleanly there and still write the final report.
|
||||
- Be honest in the final report. If five sections needed manual review, say so.
|
||||
@@ -0,0 +1,199 @@
|
||||
---
|
||||
description: Two-channel validation for a SINGLE section — pixel diff (screenshots) plus structural DOM diff (rendered clone vs. captured source via __CLONE_DUMP_COMPUTED__). Returns pass/fail + ranked, actionable issues. In staged-mode runs the orchestrator calls this per-section per-stage. In default-mode runs the orchestrator calls this on-demand for specific sections that need investigation beyond visual review.
|
||||
mode: subagent
|
||||
tools:
|
||||
read: true
|
||||
list: true
|
||||
bash: true
|
||||
skill: true
|
||||
screenshot-diff: true
|
||||
dev-server: true
|
||||
agent-browser-shot: true
|
||||
dump-rendered: true
|
||||
dom-diff: true
|
||||
steps: 20
|
||||
---
|
||||
|
||||
You are the Clone Validate sub-agent.
|
||||
|
||||
You verify that a single section renders close enough to its captured source to pass the stage's gate (in staged mode) or to give the orchestrator concrete issues to act on (in default mode). You have **two diff channels**:
|
||||
|
||||
1. **Pixel diff** — `screenshot-diff` over PNG screenshots. Catches visual mismatches.
|
||||
2. **Structural diff** — `dom-diff` over JSON DOM snapshots produced by the same `__CLONE_DUMP_COMPUTED__` walker the capture used. Catches missing elements, wrong sizes, wrong styles with concrete property/value pairs.
|
||||
|
||||
Use both. The structural diff is the more actionable signal for the generate agent — the pixel diff is the fidelity check.
|
||||
|
||||
## When you get called
|
||||
|
||||
- **Staged-mode runs:** the orchestrator invokes you per-section per-stage as part of a strict generate→validate→iterate loop. Run both diff channels every time, return a structured report.
|
||||
- **Default-mode runs:** the orchestrator invokes you on-demand for sections it can't articulate issues on from visual review alone — typically 1-3 sections per run. Same behavior on your end: run both diff channels, return the report. The structural-diff `issues[]` is what the orchestrator will pass back to generate.
|
||||
|
||||
Either way: your contract is the same. One section per call, both channels, structured JSON return. Whether you're called once or seventeen times is the orchestrator's concern.
|
||||
|
||||
## Inputs
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest_path": "<workspace>/manifest.json",
|
||||
"stage": 1 | 2 | 3 | 4,
|
||||
"section_id": "hero",
|
||||
"capture_dir": "<workspace>/capture",
|
||||
"workspace_dir": "<workspace>"
|
||||
}
|
||||
```
|
||||
|
||||
**This agent is single-section by design.** `section_id` is singular, not an array. If the orchestrator passes ambiguous input ("validate all sections", missing `section_id`, etc.), return immediately with `{status: "error", reason: "section_id is required and must be a single string"}` rather than attempting batch work — the step budget cannot accommodate multi-section validation in one call.
|
||||
|
||||
The project under test is always cwd. Dev server runs at http://localhost:3000.
|
||||
|
||||
## Load the skill
|
||||
|
||||
Load `skills/validation-loop/SKILL.md` for gate thresholds and tool usage.
|
||||
|
||||
## Flow
|
||||
|
||||
### 1. Ensure the dev server is running
|
||||
|
||||
```
|
||||
devServer.health({ url: "http://localhost:3000" })
|
||||
```
|
||||
|
||||
If not healthy:
|
||||
|
||||
```
|
||||
devServer.start({ project_dir: "." })
|
||||
```
|
||||
|
||||
Wait up to 30 seconds. If still not responding, return `{status: 'error', reason: 'dev_server_unreachable'}`.
|
||||
|
||||
### 2. Read the manifest for this section
|
||||
|
||||
- `manifest.sections[section_id].bounding_box` — coordinates at 1280px
|
||||
- `manifest.sections[section_id].dom_path` — source DOM JSON path
|
||||
- `manifest.sections[section_id].screenshot_paths` — captured PNGs per viewport
|
||||
- `manifest.sections[section_id].section_anchor` — `#id` or `.class` selector for the section root (for `dom-diff --root-selector`)
|
||||
- `manifest.viewports` — which viewports to test
|
||||
|
||||
### 3. Pixel diff — screenshot the rendered section and diff
|
||||
|
||||
For each viewport in `manifest.viewports`:
|
||||
|
||||
```
|
||||
agentBrowserShot({
|
||||
url: "http://localhost:3000/",
|
||||
viewport: <width>,
|
||||
scroll_y: <bbox.y - 80>,
|
||||
output_path: "<workspace_dir>/screenshots/<section_id>-<viewport>.png",
|
||||
reduce_motion: <true if stage==1>,
|
||||
crop: { x: bbox.x, y: 80, width: bbox.width, height: bbox.height }
|
||||
})
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```
|
||||
diff({
|
||||
before: "<capture_dir>/screenshots/<viewport>/step-NN.png" or "<capture_dir>/section-shots/<viewport>/section-XX.png",
|
||||
after: "<workspace_dir>/screenshots/<section_id>-<viewport>.png",
|
||||
threshold: 0.1
|
||||
})
|
||||
```
|
||||
|
||||
Prefer `section-shots/<viewport>/section-XX.png` if available — those are pre-cropped to each section's bbox.
|
||||
|
||||
### 4. Structural diff — dump rendered DOM and diff vs. captured source (REQUIRED — do not skip)
|
||||
|
||||
This step is non-optional. The orchestrator depends on the `issues[]` array from `dom-diff` to feed actionable feedback to `clone-generate` on retries. Skipping this step (e.g. because pixel diff already failed) leaves generate guessing from pixels and the iteration loop fails to converge.
|
||||
|
||||
For the canonical viewport (1280px — or the smallest viewport in `manifest.viewports` if 1280 is not present):
|
||||
|
||||
```
|
||||
dumpRendered({
|
||||
url: "http://localhost:3000/",
|
||||
output: "<workspace_dir>/rendered-dom/<section_id>-1280.json",
|
||||
viewport: 1280,
|
||||
scroll_y: <bbox.y - 80>,
|
||||
reduce_motion: <true if stage==1>
|
||||
})
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```
|
||||
domDiff({
|
||||
captured: manifest.sections[section_id].dom_path,
|
||||
rendered: "<workspace_dir>/rendered-dom/<section_id>-1280.json",
|
||||
root_selector: manifest.sections[section_id].section_anchor || "#" + section_id
|
||||
})
|
||||
```
|
||||
|
||||
Returns `{ matched, counts, issues: [string], structured_issues: [{...}] }`.
|
||||
|
||||
If `dump-rendered` fails (init hooks didn't load, page errored, etc.) attempt it once more with a longer `settle_ms` (e.g. 1500). If it still fails, return `{status: "fail", reason: "rendered_dom_unavailable", issues: ["dump-rendered failed twice; structural diff unavailable for this section"]}` — do NOT silently fall back to pixel-only.
|
||||
|
||||
### 5. Apply stage gate
|
||||
|
||||
| Stage | Per-section gate |
|
||||
| ----- | ----------------------------------------------------------------------------------------------- |
|
||||
| 1 | `diff_pct < 5%` at 1280px reduced-motion AND structural counts.missing == 0 AND no tag_mismatch |
|
||||
| 2 | `diff_pct < 3%` at rest AND structural style mismatches < 5 |
|
||||
| 3 | mean `diff_pct < 8%` across 5 scroll samples (animated sections) |
|
||||
| 4 | `diff_pct < 15%` (best effort) |
|
||||
|
||||
For stage 3, additionally sample 5 scroll positions for animated sections — the gate averages across them.
|
||||
|
||||
### 6. On pass
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "pass",
|
||||
"section_id": "...",
|
||||
"stage": <n>,
|
||||
"viewports": { "375": {"diff_pct": 2.1, "structural_matched": 87, "issues": 0}, ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 7. On fail — emit actionable issues
|
||||
|
||||
The `dom-diff` `issues` list is your primary feedback channel — it's already plain language and ranked by impact (`fontSize is 48px, should be 56px`, `section.hero > h1: missing in rendered`, `size 480x300, should be 560x420 (Δ16.7%)`).
|
||||
|
||||
Take the top 3-6 issues from structural diff. If pixel diff caught anything structural didn't (color shifts inside a single matched element, gradient direction, image cropping), append 1-2 from looking at the diff PNG.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "fail",
|
||||
"section_id": "...",
|
||||
"stage": <n>,
|
||||
"viewport": 1280,
|
||||
"diff_pct": 7.4,
|
||||
"structural": {
|
||||
"matched": 76,
|
||||
"missing": 2,
|
||||
"style_mismatches": 5
|
||||
},
|
||||
"worst_regions": [{ "x": 120, "y": 88, "width": 340, "height": 60 }],
|
||||
"issues": [
|
||||
"section.hero > div[1]: missing in rendered (expected <img> #data-overlay, ~620x340)",
|
||||
"section.hero > h1[0]: fontSize is 48px, should be 56px",
|
||||
"section.hero: size 1280x634, should be 1280x792 (Δ22.4%) — likely vh-relative, use min-h-screen"
|
||||
],
|
||||
"structured_issues": [...],
|
||||
"diff_image_path": "<workspace>/diffs/hero-1280.png"
|
||||
}
|
||||
```
|
||||
|
||||
If the manifest lists this section as `vh_relative`, prepend that hint to the issues list.
|
||||
|
||||
The orchestrator passes this back to `clone-generate` as `previous_diff_report` for the next iteration.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Do not edit component files.** Ever. If the diff fails, you report it — `clone-generate` fixes it.
|
||||
- **Do not restart the dev server on every call.** Only start if `health` says down.
|
||||
- **Cap iterations is the orchestrator's job, not yours.** You run once per invocation.
|
||||
- **Run both diff channels every time.** Pixel-only is not a pass — `dom-diff` is what gives the next generate iteration something to act on. If you skip it, generate guesses from pixels and the loop won't converge.
|
||||
- **One section per invocation.** Do not attempt to validate multiple sections or "all sections at once" even if asked — return an error and let the orchestrator iterate.
|
||||
- **Lead with structural issues** — they are concrete and reproducible. Fall back to pixel-diff descriptions only when structural didn't catch a real visible issue.
|
||||
- **Be specific.** "Headline too big" is useless. "section.hero > h1[0]: fontSize is 48px, should be 56px" is the goal — and `dom-diff` produces exactly that form, so use its output verbatim where you can.
|
||||
- **Always return a parseable JSON object.** Even on internal failure, return `{status: "error", reason: "..."}` so the orchestrator can decide what to do. An empty / no-text return is a worst-case outcome — the orchestrator has no signal to act on.
|
||||
- **If diff_pct > 30% AND structural counts.missing > 0**, the structural mismatch is dominating — say so directly: `"Structural mismatch — clone is missing <N> elements present in source; layout will not converge until those are added"`.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Capture-fixture site
|
||||
|
||||
A small static site that exercises the capture-side ailing patterns we found on real WordPress / Elementor targets:
|
||||
|
||||
| Section | What it tests |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------------------------- |
|
||||
| `#hero` | `min-height: calc(100vh - var(--nav-h))` — vh detection should flag this |
|
||||
| `#features` | full-width gradient + content `max-width` inner — `full_width` detection |
|
||||
| `#features .card.reveal` | IntersectionObserver-driven opacity reveal — only renders correctly when capture scrolls into view |
|
||||
| `#bg` | lazy-loaded background-image (class toggle on intersection) — CSS-rule extraction must catch the URL |
|
||||
| `#bg::after` | `::before/::after` decorative bg-image — pseudo-element styles must be captured |
|
||||
| `#marquee` | CSS-keyframes infinite scroll with offscreen items — capture should still see all 8 logos |
|
||||
| `#detailed-svg` | inline SVG with `<defs>`, `<linearGradient>`, complex paths — must be preserved verbatim |
|
||||
|
||||
## Serve
|
||||
|
||||
```bash
|
||||
cd .opencode/fixtures/test-site
|
||||
python3 -m http.server 8765
|
||||
# http://localhost:8765
|
||||
```
|
||||
|
||||
## Run capture against the fixture
|
||||
|
||||
```bash
|
||||
SLUG=fixture
|
||||
TS=$(date +%Y%m%d-%H%M%S)
|
||||
WS=".clone-workspace/${SLUG}-${TS}"
|
||||
mkdir -p "$WS"
|
||||
python3 .opencode/scripts/capture.py \
|
||||
--url http://localhost:8765 \
|
||||
--viewports 1280 \
|
||||
--output "$WS/capture"
|
||||
```
|
||||
|
||||
Then verify the new signals are present:
|
||||
|
||||
```bash
|
||||
# vh detection
|
||||
jq '.[0:5]' "$WS/vh-flags.json" # expect #hero or .hero with vh ≈ 91 (= (720-64)/720*100)
|
||||
|
||||
# CSS asset extraction
|
||||
jq '.assets[] | select(.from_css)' "$WS/capture/meta.json" # expect ./hero-bg.svg + ./decoration.svg
|
||||
|
||||
# section discovery
|
||||
jq '.[0].selector,.[1].selector,.[2].selector' "$WS/capture/sections/1280.json"
|
||||
|
||||
# per-section screenshots
|
||||
ls "$WS/capture/section-shots/1280/"
|
||||
```
|
||||
|
||||
## Iterate fast
|
||||
|
||||
```bash
|
||||
# Replay only the post-process (vh-flags, meta.json) — sub-second
|
||||
python3 .opencode/scripts/capture.py --replay --output "$WS/capture"
|
||||
|
||||
# Diff a rendered clone vs the captured fixture
|
||||
python3 .opencode/scripts/dump-rendered.py \
|
||||
--url http://localhost:3000/ \
|
||||
--output /tmp/rendered.json \
|
||||
--viewport 1280 --scroll-y 0
|
||||
python3 .opencode/scripts/dom-diff.py \
|
||||
--captured "$WS/capture/dom/1280/step-00.json" \
|
||||
--rendered /tmp/rendered.json \
|
||||
--root-selector "#hero"
|
||||
```
|
||||
|
||||
The fixture is intentionally tiny so a full capture takes <5s. Use it to validate any capture-side change before re-running against a real site.
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<circle cx="32" cy="32" r="28" fill="none" stroke="#22c55e" stroke-width="3" />
|
||||
<path d="M20 36 L28 44 L46 22" fill="none" stroke="#22c55e" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 318 B |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 600" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#0c4a6e" />
|
||||
<stop offset="1" stop-color="#082f49" />
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="50%" cy="40%" r="50%">
|
||||
<stop offset="0" stop-color="#22c55e" stop-opacity="0.5" />
|
||||
<stop offset="1" stop-color="#22c55e" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="600" fill="url(#sky)" />
|
||||
<circle cx="600" cy="240" r="220" fill="url(#glow)" />
|
||||
<g stroke="#22c55e" stroke-width="2" fill="none" opacity="0.4">
|
||||
<path d="M0 480 Q 300 360, 600 480 T 1200 480" />
|
||||
<path d="M0 520 Q 300 420, 600 520 T 1200 520" />
|
||||
<path d="M0 560 Q 300 480, 600 560 T 1200 560" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 895 B |
@@ -0,0 +1,132 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Capture Fixture — vh, lazy-bg, marquee, SVG, IO reveal</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="nav">
|
||||
<a href="#" class="logo">Fixture</a>
|
||||
<nav>
|
||||
<a href="#hero">Hero</a>
|
||||
<a href="#features">Features</a>
|
||||
<a href="#bg">Lazy BG</a>
|
||||
<a href="#marquee">Marquee</a>
|
||||
<a href="#footer">Footer</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Section A: vh-relative hero. 100vh - 64px nav. -->
|
||||
<section id="hero" class="hero">
|
||||
<h1>Full-fold hero</h1>
|
||||
<p>
|
||||
This section's height is <code>min-height: calc(100vh - 64px)</code>. At a 1280×720 capture it resolves to
|
||||
656px; at 1280×1080 it resolves to 1016px. The <code>vh-flags.json</code> output should pick this up and set
|
||||
<code>vh_value: 91</code>.
|
||||
</p>
|
||||
<a href="#" class="cta">Get started</a>
|
||||
</section>
|
||||
|
||||
<!-- Section B: full-width gradient, content max-width inside -->
|
||||
<section id="features" class="features">
|
||||
<div class="inner">
|
||||
<h2>Three cards</h2>
|
||||
<ul class="grid">
|
||||
<li class="card reveal">
|
||||
<svg class="icon" viewBox="0 0 32 32">
|
||||
<path d="M4 16 L14 26 L28 6" stroke="currentColor" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
</svg>
|
||||
<h3>Reveal me</h3>
|
||||
<p>
|
||||
I fade in via IntersectionObserver. A capture that doesn't trigger the IO callback will leave me at
|
||||
opacity 0.
|
||||
</p>
|
||||
</li>
|
||||
<li class="card reveal">
|
||||
<svg class="icon" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="16" r="12" stroke="currentColor" stroke-width="3" fill="none" />
|
||||
</svg>
|
||||
<h3>Reveal me too</h3>
|
||||
<p>Same pattern. Stagger delay applied via inline style.</p>
|
||||
</li>
|
||||
<li class="card reveal">
|
||||
<svg class="icon" viewBox="0 0 32 32">
|
||||
<rect x="6" y="6" width="20" height="20" rx="3" stroke="currentColor" stroke-width="3" fill="none" />
|
||||
</svg>
|
||||
<h3>And me</h3>
|
||||
<p>Three cards = repeated_pattern candidate.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section C: lazy-loaded background image. The CSS sets bg via a class that we toggle with IO. -->
|
||||
<section id="bg" class="lazy-bg">
|
||||
<div class="inner">
|
||||
<h2>Background loads on scroll</h2>
|
||||
<p>
|
||||
Until I'm intersected, my background-image is "none". After IO fires I get a CSS-driven background. Capture
|
||||
must either scroll into view or follow the CSS rule from
|
||||
<code>css-rules/<vp>.json</code> to know the bg URL.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section D: Swiper-style marquee with offscreen items the response listener misses -->
|
||||
<section id="marquee" class="marquee">
|
||||
<h2>Marquee</h2>
|
||||
<div class="track">
|
||||
<!-- 8 logos; the first 4 are visible at the capture viewport, the rest drift in via translate -->
|
||||
<div class="logo-tile" data-idx="1">
|
||||
<svg viewBox="0 0 80 32"><text x="0" y="22" font-size="20" fill="currentColor">A1</text></svg>
|
||||
</div>
|
||||
<div class="logo-tile" data-idx="2">
|
||||
<svg viewBox="0 0 80 32"><text x="0" y="22" font-size="20" fill="currentColor">A2</text></svg>
|
||||
</div>
|
||||
<div class="logo-tile" data-idx="3">
|
||||
<svg viewBox="0 0 80 32"><text x="0" y="22" font-size="20" fill="currentColor">A3</text></svg>
|
||||
</div>
|
||||
<div class="logo-tile" data-idx="4">
|
||||
<svg viewBox="0 0 80 32"><text x="0" y="22" font-size="20" fill="currentColor">A4</text></svg>
|
||||
</div>
|
||||
<div class="logo-tile" data-idx="5">
|
||||
<svg viewBox="0 0 80 32"><text x="0" y="22" font-size="20" fill="currentColor">B1</text></svg>
|
||||
</div>
|
||||
<div class="logo-tile" data-idx="6">
|
||||
<svg viewBox="0 0 80 32"><text x="0" y="22" font-size="20" fill="currentColor">B2</text></svg>
|
||||
</div>
|
||||
<div class="logo-tile" data-idx="7">
|
||||
<svg viewBox="0 0 80 32"><text x="0" y="22" font-size="20" fill="currentColor">B3</text></svg>
|
||||
</div>
|
||||
<div class="logo-tile" data-idx="8">
|
||||
<svg viewBox="0 0 80 32"><text x="0" y="22" font-size="20" fill="currentColor">B4</text></svg>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section E: detailed SVG. Inline; the agent should preserve every path. -->
|
||||
<section id="detailed-svg" class="detailed-svg">
|
||||
<h2>Detailed SVG</h2>
|
||||
<svg viewBox="0 0 200 100" width="400" height="200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="g1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#0ea5e9" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="200" height="100" fill="url(#g1)" rx="8" />
|
||||
<path d="M10 80 Q 50 20, 100 50 T 190 30" stroke="#fff" stroke-width="3" fill="none" />
|
||||
<circle cx="100" cy="50" r="6" fill="#fff" />
|
||||
<text x="50%" y="92%" text-anchor="middle" font-size="12" fill="#fff">cluster</text>
|
||||
</svg>
|
||||
</section>
|
||||
|
||||
<footer id="footer" class="footer">
|
||||
<p>© Fixture site — for capture-side regression testing only.</p>
|
||||
</footer>
|
||||
|
||||
<script src="./reveal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
// Minimal IntersectionObserver-driven reveal + lazy-bg trigger.
|
||||
// The capture pipeline must scroll into view (or follow the CSS rule) for
|
||||
// these to fire. This is the same pattern Elementor and most WP themes use.
|
||||
|
||||
(() => {
|
||||
const reveals = document.querySelectorAll('.reveal');
|
||||
const lazyBg = document.querySelector('.lazy-bg');
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
if (e.target.classList.contains('reveal')) e.target.classList.add('is-visible');
|
||||
if (e.target === lazyBg) e.target.classList.add('is-loaded');
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.15 }
|
||||
);
|
||||
|
||||
reveals.forEach((r, i) => {
|
||||
r.style.transitionDelay = `${i * 120}ms`;
|
||||
io.observe(r);
|
||||
});
|
||||
if (lazyBg) io.observe(lazyBg);
|
||||
})();
|
||||
@@ -0,0 +1,265 @@
|
||||
/* Capture fixture stylesheet — deliberately includes patterns that the cropin clone got wrong:
|
||||
vh-based heights, full-bleed widths, lazy-load gating via class toggle, marquee transform,
|
||||
pseudo-element decoration, multi-stop gradient. */
|
||||
|
||||
:root {
|
||||
--nav-h: 64px;
|
||||
--accent: #22c55e;
|
||||
--accent-dark: #166534;
|
||||
--bg: #f8fafc;
|
||||
--text: #0f172a;
|
||||
--muted: #64748b;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
height: var(--nav-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.nav .logo {
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
.nav nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.nav nav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav nav a:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* === Section A: vh-relative hero ===
|
||||
The point of this section: computed.minHeight resolves to 656px at capture,
|
||||
1016px on the user's 1080-tall viewport. The capture's vh-detection pass
|
||||
should flag this as ~91vh. */
|
||||
.hero {
|
||||
min-height: calc(100vh - var(--nav-h));
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #ecfdf5 0%, #f0f9ff 100%);
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: clamp(36px, 6vw, 64px);
|
||||
margin: 0;
|
||||
}
|
||||
.hero p {
|
||||
max-width: 56ch;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.hero .cta {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 9999px;
|
||||
text-decoration: none;
|
||||
transition: transform 200ms;
|
||||
}
|
||||
.hero .cta:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* === Section B: full-width gradient with inner max-width container === */
|
||||
.features {
|
||||
width: 100vw;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f1f5f9 100%);
|
||||
padding: 80px 0;
|
||||
}
|
||||
.features .inner {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.features h2 {
|
||||
font-size: 36px;
|
||||
margin: 0 0 32px;
|
||||
}
|
||||
.grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
.card {
|
||||
padding: 24px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.card .icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--accent-dark);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.card p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* IntersectionObserver reveal — class .is-visible toggled via reveal.js */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
transition: opacity 600ms ease, transform 600ms ease;
|
||||
}
|
||||
.reveal.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* === Section C: lazy-loaded background image ===
|
||||
The class .is-loaded is added once IO fires. Until then, no bg image — and the
|
||||
browser does NOT GET-request the URL. capture/css-rules/<vp>.json will still
|
||||
contain the rule though, so a CSS-asset-extraction pass should pick it up. */
|
||||
.lazy-bg {
|
||||
width: 100%;
|
||||
min-height: 480px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
background-color: #0f172a;
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 64px 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lazy-bg::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: linear-gradient(180deg, rgba(15, 23, 42, 0.4) 0%, rgba(15, 23, 42, 0.7) 100%);
|
||||
z-index: 1;
|
||||
}
|
||||
.lazy-bg .inner {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 720px;
|
||||
}
|
||||
.lazy-bg h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 36px;
|
||||
}
|
||||
.lazy-bg p {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.lazy-bg.is-loaded {
|
||||
background-image: url('./hero-bg.svg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
/* Decorative content-image ::before that uses url() but is set in the stylesheet only. */
|
||||
.lazy-bg::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-image: url('./decoration.svg');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
z-index: 2;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* === Section D: marquee with overflow + transform translation === */
|
||||
.marquee {
|
||||
padding: 64px 0;
|
||||
background: white;
|
||||
text-align: center;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.marquee h2 {
|
||||
margin: 0 0 24px;
|
||||
font-size: 36px;
|
||||
}
|
||||
.marquee .track {
|
||||
display: flex;
|
||||
gap: 48px;
|
||||
animation: scroll 20s linear infinite;
|
||||
width: max-content;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.logo-tile {
|
||||
flex: 0 0 auto;
|
||||
height: 32px;
|
||||
width: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
.logo-tile svg {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
@keyframes scroll {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* === Section E: detailed inline SVG === */
|
||||
.detailed-svg {
|
||||
padding: 64px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.detailed-svg h2 {
|
||||
margin: 0 0 24px;
|
||||
font-size: 36px;
|
||||
}
|
||||
.detailed-svg svg {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* === Footer === */
|
||||
.footer {
|
||||
padding: 32px 24px;
|
||||
background: #0f172a;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.16.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
capture.py — Playwright-based site capture for the clone pipeline.
|
||||
|
||||
Produces a full capture bundle at --output. Layout is documented in
|
||||
.opencode/skills/site-manifest/SKILL.md and consumed by the analyze + generate
|
||||
agents.
|
||||
|
||||
Pipeline:
|
||||
Stage 0: Per-viewport scroll-loop capture — DOM + computed styles, screenshots,
|
||||
HAR, animation library dumps, fonts, css-vars, css-rules.
|
||||
Stage 1: Alt-height capture at the canonical desktop width (1280) for
|
||||
vh-relative detection.
|
||||
Stage 2: Per-section cropped screenshot pass — scroll each candidate section
|
||||
into view and crop to its bbox.
|
||||
Stage 3: Post-process — derive vh-relative flags, merge HAR + CSS asset URLs,
|
||||
write meta.json.
|
||||
|
||||
Replay mode: --replay skips stages 0-2 and only re-runs stage 3 against existing
|
||||
capture data. Use this when iterating on prompts/agents/skills without paying the
|
||||
~3-minute capture cost again.
|
||||
|
||||
Dependencies: playwright (pip install playwright && playwright install chromium)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout
|
||||
except ImportError:
|
||||
print(json.dumps({"error": "Missing dependencies. pip install playwright && playwright install chromium"}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
THIRD_PARTY_HOST_RE = re.compile(
|
||||
r"(intercom\.io|driftt\.com|drift\.com|typeform\.com|calendly\.com|hsforms\.(net|com)|"
|
||||
r"cookielaw\.org|cookiebot\.com|osano\.com|googletagmanager\.com|google-analytics\.com|"
|
||||
r"segment\.(io|com)|hotjar\.com|fullstory\.com)"
|
||||
)
|
||||
|
||||
# Viewport that the analyze agent treats as canonical desktop. We capture a second
|
||||
# DOM dump at this width but a different height to detect vh-relative dimensions.
|
||||
CANONICAL_WIDTH = 1280
|
||||
CANONICAL_HEIGHT_PRIMARY = 720
|
||||
CANONICAL_HEIGHT_ALT = 1080
|
||||
|
||||
|
||||
def sha1_8(s: str) -> str:
|
||||
return hashlib.sha1(s.encode("utf-8")).hexdigest()[:8]
|
||||
|
||||
|
||||
def sanitize(name: str) -> str:
|
||||
name = name.lower()
|
||||
name = re.sub(r"\s+", "-", name)
|
||||
name = re.sub(r"[^a-z0-9.\-]", "", name)
|
||||
return name[:80] or "asset"
|
||||
|
||||
|
||||
def load_init_hooks(hooks_dir: Path) -> list[str]:
|
||||
sources: list[str] = []
|
||||
for path in sorted(hooks_dir.glob("*.js")):
|
||||
sources.append(path.read_text())
|
||||
return sources
|
||||
|
||||
|
||||
def asset_type_from_url(url: str, content_type: str | None) -> str | None:
|
||||
u = url.lower().split("?", 1)[0]
|
||||
ct = (content_type or "").lower()
|
||||
# SVG must be checked before generic image/* — content-type "image/svg+xml" matches both.
|
||||
if u.endswith(".svg") or ct == "image/svg+xml":
|
||||
return "svg"
|
||||
if any(u.endswith(ext) for ext in (".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif")) or ct.startswith("image/"):
|
||||
return "image"
|
||||
if any(u.endswith(ext) for ext in (".mp4", ".mov", ".webm")) or ct.startswith("video/"):
|
||||
return "video"
|
||||
if any(u.endswith(ext) for ext in (".woff2", ".woff", ".ttf", ".otf")) or ct in (
|
||||
"font/woff2", "font/woff", "font/ttf", "application/font-woff2", "application/font-woff", "application/x-font-ttf"
|
||||
):
|
||||
return "font"
|
||||
if u.endswith(".json") and ("lottie" in u or "animations" in u):
|
||||
return "lottie"
|
||||
return None
|
||||
|
||||
|
||||
def asset_type_from_url_only(url: str) -> str | None:
|
||||
return asset_type_from_url(url, None)
|
||||
|
||||
|
||||
def viewport_height_for(width: int) -> int:
|
||||
"""Default capture height — 16:9 ratio, but rounded sensibly."""
|
||||
return round(width * 9 / 16)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Browser helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_context(browser, viewport_w: int, viewport_h: int, har_path: Path | None, init_sources: list[str], skip_third_party: bool):
|
||||
ctx_kwargs = {"viewport": {"width": viewport_w, "height": viewport_h}}
|
||||
if har_path is not None:
|
||||
ctx_kwargs["record_har_path"] = str(har_path)
|
||||
ctx = browser.new_context(**ctx_kwargs)
|
||||
|
||||
if skip_third_party:
|
||||
def route_handler(route):
|
||||
if THIRD_PARTY_HOST_RE.search(route.request.url):
|
||||
route.abort()
|
||||
else:
|
||||
route.continue_()
|
||||
ctx.route("**/*", route_handler)
|
||||
|
||||
for src in init_sources:
|
||||
ctx.add_init_script(src)
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
def goto_and_settle(page, url: str, wait_strategy: str) -> None:
|
||||
try:
|
||||
page.goto(url, wait_until=wait_strategy, timeout=60_000)
|
||||
except PWTimeout:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=60_000)
|
||||
|
||||
try:
|
||||
page.evaluate("() => document.fonts.ready")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
page.wait_for_function("window.__CLONE_READY__ === true", timeout=10_000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def dump_dom(page) -> dict:
|
||||
"""Use the rich __CLONE_DUMP_COMPUTED__ walker (which filters per-tag defaults
|
||||
and captures pseudo-elements). Falls back to a minimal walk if the hook
|
||||
didn't load."""
|
||||
try:
|
||||
out = page.evaluate("() => (typeof window.__CLONE_DUMP_COMPUTED__ === 'function') ? window.__CLONE_DUMP_COMPUTED__() : null")
|
||||
if out is not None:
|
||||
return out
|
||||
except Exception:
|
||||
pass
|
||||
return page.evaluate(
|
||||
"""() => {
|
||||
const serialize = (el) => {
|
||||
if (el.nodeType === Node.TEXT_NODE) {
|
||||
const t = el.textContent;
|
||||
return t && t.trim() ? { text: t } : null;
|
||||
}
|
||||
if (el.nodeType !== Node.ELEMENT_NODE) return null;
|
||||
const cs = getComputedStyle(el);
|
||||
const computed = {};
|
||||
for (const p of ['color','backgroundColor','backgroundImage','fontFamily','fontSize','fontWeight','lineHeight','display','width','height','padding','margin','position']) {
|
||||
const v = cs[p];
|
||||
if (v && v !== 'normal' && v !== 'none' && v !== 'auto' && v !== '0px' && v !== 'rgba(0, 0, 0, 0)') computed[p] = v;
|
||||
}
|
||||
const attrs = {};
|
||||
for (const a of el.attributes) attrs[a.name] = a.value;
|
||||
const r = el.getBoundingClientRect();
|
||||
const children = [];
|
||||
for (const c of el.childNodes) { const s = serialize(c); if (s) children.push(s); }
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
attrs,
|
||||
computed,
|
||||
bbox: { x: r.x, y: r.y + window.scrollY, width: r.width, height: r.height },
|
||||
children,
|
||||
};
|
||||
};
|
||||
return serialize(document.body);
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
def list_sections(page) -> list[dict]:
|
||||
try:
|
||||
return page.evaluate("() => (typeof window.__CLONE_LIST_SECTIONS__ === 'function') ? window.__CLONE_LIST_SECTIONS__() : []") or []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def smart_scroll_targets(sections: list[dict], page_height: int, viewport_h: int) -> list[int]:
|
||||
"""Compute scroll y positions to visit. We always include 0 (top), then
|
||||
each section's y minus a small offset, deduped. Also fill any gap larger
|
||||
than 1.5x viewport_h with intermediate stops so we don't skip content
|
||||
between sections.
|
||||
"""
|
||||
raw = [0]
|
||||
for sec in sections:
|
||||
y = max(int(sec.get("y") or 0) - 80, 0)
|
||||
raw.append(y)
|
||||
raw.append(max(page_height - viewport_h, 0))
|
||||
|
||||
# Sort + dedupe + fill gaps
|
||||
raw = sorted(set(raw))
|
||||
filled: list[int] = []
|
||||
for i, y in enumerate(raw):
|
||||
if filled and y - filled[-1] > int(viewport_h * 1.5):
|
||||
mid = filled[-1] + viewport_h
|
||||
while mid < y - viewport_h:
|
||||
filled.append(mid)
|
||||
mid += viewport_h
|
||||
filled.append(y)
|
||||
# Cap to avoid pathological cases
|
||||
return filled[:50]
|
||||
|
||||
|
||||
def settle(page, max_ms: int = 1500) -> None:
|
||||
"""Wait for the layout to settle: networkidle attempt + a short fixed delay
|
||||
+ waitForFunction on document.fonts.ready. If a MutationObserver fires
|
||||
rapidly, we yield more time up to max_ms."""
|
||||
deadline = time.monotonic() + (max_ms / 1000.0)
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=max(int(max_ms / 2), 500))
|
||||
except Exception:
|
||||
pass
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining > 0:
|
||||
# Watch for mutation activity; if quiet for 250ms, return
|
||||
try:
|
||||
page.evaluate(
|
||||
"""async (maxMs) => {
|
||||
return await new Promise((resolve) => {
|
||||
let lastMutation = performance.now();
|
||||
const start = lastMutation;
|
||||
const obs = new MutationObserver(() => { lastMutation = performance.now(); });
|
||||
obs.observe(document.body, { subtree: true, childList: true, attributes: true });
|
||||
const tick = () => {
|
||||
const now = performance.now();
|
||||
if (now - lastMutation > 250 || now - start > maxMs) { obs.disconnect(); resolve(true); }
|
||||
else requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}""",
|
||||
int(remaining * 1000),
|
||||
)
|
||||
except Exception:
|
||||
time.sleep(min(remaining, 0.5))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 0 — per-viewport scroll-loop capture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def capture_viewport(
|
||||
browser,
|
||||
viewport_width: int,
|
||||
url: str,
|
||||
wait_strategy: str,
|
||||
init_sources: list[str],
|
||||
output_dir: Path,
|
||||
skip_third_party: bool,
|
||||
) -> dict:
|
||||
viewport_height = viewport_height_for(viewport_width)
|
||||
vp_dir = output_dir / "screenshots" / str(viewport_width)
|
||||
dom_dir = output_dir / "dom" / str(viewport_width)
|
||||
har_dir = output_dir / "har"
|
||||
for d in (vp_dir, dom_dir, har_dir):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
har_path = har_dir / f"{viewport_width}.har"
|
||||
page_context = make_context(browser, viewport_width, viewport_height, har_path, init_sources, skip_third_party)
|
||||
|
||||
page = page_context.new_page()
|
||||
discovered_assets: dict[str, dict] = {}
|
||||
|
||||
def on_response(response):
|
||||
try:
|
||||
url_ = response.url
|
||||
content_type = response.headers.get("content-type")
|
||||
t = asset_type_from_url(url_, content_type)
|
||||
if t is None:
|
||||
return
|
||||
if url_ in discovered_assets:
|
||||
return
|
||||
discovered_assets[url_] = {
|
||||
"type": t,
|
||||
"source_url": url_,
|
||||
"content_type": content_type,
|
||||
"status": response.status,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
page.on("response", on_response)
|
||||
goto_and_settle(page, url, wait_strategy)
|
||||
|
||||
sections = list_sections(page)
|
||||
page_height = page.evaluate("() => document.documentElement.scrollHeight") or 0
|
||||
targets = smart_scroll_targets(sections, page_height, viewport_height)
|
||||
|
||||
for step, y in enumerate(targets):
|
||||
page.evaluate(f"window.scrollTo({{ top: {y}, behavior: 'instant' }})")
|
||||
settle(page, max_ms=1200)
|
||||
page.screenshot(path=str(vp_dir / f"step-{step:02d}.png"), full_page=False)
|
||||
snapshot = dump_dom(page)
|
||||
(dom_dir / f"step-{step:02d}.json").write_text(json.dumps(snapshot, separators=(",", ":")))
|
||||
|
||||
# Pull animation/css-rules hook output
|
||||
hook_output = page.evaluate("() => window.__CLONE_CAPTURE__ || {}") or {}
|
||||
for kind in ("shaders", "gsap", "framer", "lottie", "threejs"):
|
||||
d = output_dir / kind
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / f"{viewport_width}.json").write_text(json.dumps(hook_output.get(kind, []), indent=2))
|
||||
for kind, dirname in (("cssVars", "css-vars"), ("fonts", "fonts"), ("cssRules", "css-rules")):
|
||||
d = output_dir / dirname
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / f"{viewport_width}.json").write_text(json.dumps(hook_output.get(kind, {}), indent=2))
|
||||
|
||||
# Dump candidate sections per viewport (analyze agent uses these)
|
||||
sec_dir = output_dir / "sections"
|
||||
sec_dir.mkdir(parents=True, exist_ok=True)
|
||||
(sec_dir / f"{viewport_width}.json").write_text(json.dumps(sections, indent=2))
|
||||
|
||||
# Hover capture
|
||||
try:
|
||||
hover_rects = page.evaluate(
|
||||
"""() => {
|
||||
const els = document.querySelectorAll('a, button, [role="button"], [data-cta]');
|
||||
const out = [];
|
||||
for (const el of els) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 40 || r.height < 20) continue;
|
||||
out.push({ x: r.x + r.width/2, y: r.y + r.height/2, tag: el.tagName });
|
||||
if (out.length >= 10) break;
|
||||
}
|
||||
return out;
|
||||
}"""
|
||||
)
|
||||
hover_dir = output_dir / "screenshots" / str(viewport_width) / "hover"
|
||||
hover_dir.mkdir(parents=True, exist_ok=True)
|
||||
for idx, pos in enumerate(hover_rects):
|
||||
try:
|
||||
page.mouse.move(pos["x"], pos["y"])
|
||||
time.sleep(0.2)
|
||||
page.screenshot(path=str(hover_dir / f"hover-{idx:02d}.png"), full_page=False)
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
page.close()
|
||||
page_context.close()
|
||||
|
||||
return {
|
||||
"viewport": viewport_width,
|
||||
"height": viewport_height,
|
||||
"steps": len(targets),
|
||||
"section_candidates": len(sections),
|
||||
"hook_output_keys": [k for k in hook_output.keys()],
|
||||
"har": str(har_path.relative_to(output_dir)),
|
||||
"assets": discovered_assets,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1 — alt-height capture at canonical width (vh detection)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def capture_alt_height(browser, url: str, wait_strategy: str, init_sources: list[str], output_dir: Path, skip_third_party: bool) -> dict:
|
||||
"""Capture only DOM at scroll 0 at (CANONICAL_WIDTH x CANONICAL_HEIGHT_ALT).
|
||||
The post-process compares this to the primary capture to flag vh-relative
|
||||
dimensions."""
|
||||
page_context = make_context(browser, CANONICAL_WIDTH, CANONICAL_HEIGHT_ALT, None, init_sources, skip_third_party)
|
||||
page = page_context.new_page()
|
||||
goto_and_settle(page, url, wait_strategy)
|
||||
settle(page, max_ms=1200)
|
||||
snapshot = dump_dom(page)
|
||||
sections = list_sections(page)
|
||||
|
||||
out_dir = output_dir / "dom-alt" / f"{CANONICAL_WIDTH}-{CANONICAL_HEIGHT_ALT}"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
(out_dir / "step-00.json").write_text(json.dumps(snapshot, separators=(",", ":")))
|
||||
(out_dir / "sections.json").write_text(json.dumps(sections, indent=2))
|
||||
|
||||
page.close()
|
||||
page_context.close()
|
||||
return {"viewport": CANONICAL_WIDTH, "alt_height": CANONICAL_HEIGHT_ALT, "section_candidates": len(sections)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2 — per-section cropped screenshots
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def capture_section_screenshots(browser, url: str, wait_strategy: str, init_sources: list[str], output_dir: Path, skip_third_party: bool, viewport_widths: list[int]) -> int:
|
||||
"""For each viewport width, scroll each candidate section into view and
|
||||
save a screenshot cropped to its bbox. The analyze agent maps these to
|
||||
section ids; the validate agent uses them as the diff reference."""
|
||||
total = 0
|
||||
for vw in viewport_widths:
|
||||
sections_path = output_dir / "sections" / f"{vw}.json"
|
||||
if not sections_path.exists():
|
||||
continue
|
||||
sections = json.loads(sections_path.read_text())
|
||||
if not sections:
|
||||
continue
|
||||
vh = viewport_height_for(vw)
|
||||
page_context = make_context(browser, vw, vh, None, init_sources, skip_third_party)
|
||||
page = page_context.new_page()
|
||||
goto_and_settle(page, url, wait_strategy)
|
||||
out_dir = output_dir / "section-shots" / str(vw)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
for idx, sec in enumerate(sections):
|
||||
try:
|
||||
# Scroll so the section's top is ~80px below the viewport top
|
||||
target_y = max(int(sec.get("y") or 0) - 80, 0)
|
||||
page.evaluate(f"window.scrollTo({{ top: {target_y}, behavior: 'instant' }})")
|
||||
settle(page, max_ms=1000)
|
||||
clip = {
|
||||
"x": max(int(sec.get("x") or 0), 0),
|
||||
"y": max(int(sec.get("y") or 0) - target_y, 0),
|
||||
"width": min(int(sec.get("width") or vw), vw),
|
||||
"height": min(int(sec.get("height") or vh), vh * 4),
|
||||
}
|
||||
# Playwright clip cannot exceed the page viewport vertically; if the section is
|
||||
# taller than the viewport, capture as-is and analyze handles the partial.
|
||||
clip["height"] = min(clip["height"], vh)
|
||||
page.screenshot(path=str(out_dir / f"section-{idx:02d}.png"), clip=clip)
|
||||
total += 1
|
||||
except Exception:
|
||||
continue
|
||||
page.close()
|
||||
page_context.close()
|
||||
return total
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 3 — post-process: build assets list, derive vh flags, write meta.json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def merge_css_assets(output_dir: Path, har_assets: dict[str, dict]) -> dict[str, dict]:
|
||||
"""Walk css-rules/<vp>.json across viewports, append every url() reference
|
||||
not already in har_assets. Type is inferred from the URL alone since CSS
|
||||
references don't carry content-type."""
|
||||
merged = dict(har_assets)
|
||||
css_rules_dir = output_dir / "css-rules"
|
||||
if css_rules_dir.exists():
|
||||
for f in css_rules_dir.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(f.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
for asset in data.get("assets", []) or []:
|
||||
src = asset.get("source_url")
|
||||
if not src or src in merged:
|
||||
continue
|
||||
t = asset_type_from_url_only(src)
|
||||
if t is None:
|
||||
continue
|
||||
merged[src] = {
|
||||
"type": t,
|
||||
"source_url": src,
|
||||
"content_type": None,
|
||||
"status": None,
|
||||
"from_css": True,
|
||||
}
|
||||
return merged
|
||||
|
||||
|
||||
def build_assets_list(all_assets: dict[str, dict]) -> list[dict]:
|
||||
out = []
|
||||
for url, a in all_assets.items():
|
||||
name = sanitize(unquote(os.path.basename(urlparse(url).path)) or "asset")
|
||||
local_path = f"public/assets/cloned/{a['type']}s/{sha1_8(url)}-{name}"
|
||||
entry = {
|
||||
"type": a["type"],
|
||||
"source_url": url,
|
||||
"local_path": local_path,
|
||||
}
|
||||
if a.get("from_css"):
|
||||
entry["from_css"] = True
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def detect_vh_flags(output_dir: Path) -> list[dict]:
|
||||
"""Compare DOM at (1280x720) vs (1280x1080). For each element matchable by
|
||||
structural path, if `h_alt / h_primary ≈ alt_height / primary_height`, flag
|
||||
it as vh-relative and record the implied vh percentage.
|
||||
Output: a flat list of { path, primary_h, alt_h, vh } entries.
|
||||
"""
|
||||
primary = output_dir / "dom" / str(CANONICAL_WIDTH) / "step-00.json"
|
||||
alt = output_dir / "dom-alt" / f"{CANONICAL_WIDTH}-{CANONICAL_HEIGHT_ALT}" / "step-00.json"
|
||||
if not primary.exists() or not alt.exists():
|
||||
return []
|
||||
try:
|
||||
a = json.loads(primary.read_text())
|
||||
b = json.loads(alt.read_text())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
expected_ratio = CANONICAL_HEIGHT_ALT / CANONICAL_HEIGHT_PRIMARY # 1.5
|
||||
tolerance = 0.06 # 6% — accounts for content-driven elements that happen to be near vh
|
||||
|
||||
flags: list[dict] = []
|
||||
|
||||
def walk(na, nb, path):
|
||||
if not isinstance(na, dict) or not isinstance(nb, dict):
|
||||
return
|
||||
if na.get("tag") != nb.get("tag"):
|
||||
return
|
||||
ha = (na.get("bbox") or {}).get("height") or 0
|
||||
hb = (nb.get("bbox") or {}).get("height") or 0
|
||||
if ha > 40 and hb > 40:
|
||||
ratio = hb / ha
|
||||
if abs(ratio - expected_ratio) / expected_ratio < tolerance:
|
||||
vh_pct = round((ha / CANONICAL_HEIGHT_PRIMARY) * 100)
|
||||
flags.append({
|
||||
"path": path,
|
||||
"tag": na.get("tag"),
|
||||
"id": (na.get("attrs") or {}).get("id") or None,
|
||||
"class": (na.get("attrs") or {}).get("class") or None,
|
||||
"primary_h": round(ha, 2),
|
||||
"alt_h": round(hb, 2),
|
||||
"ratio": round(ratio, 3),
|
||||
"vh": vh_pct,
|
||||
})
|
||||
ca = na.get("children") or []
|
||||
cb = nb.get("children") or []
|
||||
# Only walk matched element children; pair by tag-aware index
|
||||
ai = bi = 0
|
||||
idx = 0
|
||||
while ai < len(ca) and bi < len(cb):
|
||||
ea = ca[ai]
|
||||
eb = cb[bi]
|
||||
if not isinstance(ea, dict) or not isinstance(eb, dict) or ea.get("tag") != eb.get("tag"):
|
||||
# Skip text-only / mismatched
|
||||
if not isinstance(ea, dict) or "tag" not in ea:
|
||||
ai += 1
|
||||
continue
|
||||
if not isinstance(eb, dict) or "tag" not in eb:
|
||||
bi += 1
|
||||
continue
|
||||
# Different tags at same index — skip both
|
||||
ai += 1
|
||||
bi += 1
|
||||
continue
|
||||
walk(ea, eb, f"{path}>{ea.get('tag')}[{idx}]")
|
||||
ai += 1
|
||||
bi += 1
|
||||
idx += 1
|
||||
|
||||
walk(a, b, a.get("tag", "body"))
|
||||
return flags
|
||||
|
||||
|
||||
SECTION_LIKE_TAGS = {"section", "header", "footer", "nav", "main", "article", "aside"}
|
||||
SECTION_CLASS_RE = re.compile(r"(section|hero|banner|elementor-element|e-parent|e-con|footer|header|nav|main)", re.I)
|
||||
SECTION_ID_RE = re.compile(r"(hero|banner|section|footer|nav|header|main)", re.I)
|
||||
|
||||
|
||||
def _is_section_like(node: dict) -> bool:
|
||||
tag = (node.get("tag") or "").lower()
|
||||
if tag in SECTION_LIKE_TAGS:
|
||||
return True
|
||||
attrs = node.get("attrs") or {}
|
||||
eid = attrs.get("id") or ""
|
||||
if eid and SECTION_ID_RE.search(eid):
|
||||
return True
|
||||
cls = attrs.get("class") or ""
|
||||
if cls and SECTION_CLASS_RE.search(cls):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_visible(node: dict) -> bool:
|
||||
cs = node.get("computed") or {}
|
||||
if cs.get("display") == "none" or cs.get("visibility") == "hidden":
|
||||
return False
|
||||
op = cs.get("opacity")
|
||||
if op is not None and op != "":
|
||||
try:
|
||||
if float(op) == 0:
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _build_selector(node: dict) -> str:
|
||||
attrs = node.get("attrs") or {}
|
||||
if attrs.get("id"):
|
||||
return f"#{attrs['id']}"
|
||||
cls = (attrs.get("class") or "").strip().split()
|
||||
if cls:
|
||||
# First class is usually the most distinctive (Elementor style)
|
||||
return f"{node.get('tag')}.{cls[0]}"
|
||||
return node.get("tag") or "?"
|
||||
|
||||
|
||||
def _walk_section_candidates(node: dict, vw: int, path: str, idx: int, out: list[dict]) -> None:
|
||||
if not isinstance(node, dict) or "tag" not in node:
|
||||
return
|
||||
cur_path = f"{path}>{node.get('tag')}[{idx}]" if path else (node.get("tag") or "?")
|
||||
bbox = node.get("bbox") or {}
|
||||
w = bbox.get("width") or 0
|
||||
h = bbox.get("height") or 0
|
||||
if _is_visible(node) and _is_section_like(node) and w >= vw * 0.5 and h >= 80:
|
||||
attrs = node.get("attrs") or {}
|
||||
out.append({
|
||||
"_path": cur_path,
|
||||
"_node": node,
|
||||
"selector": _build_selector(node),
|
||||
"tag": node.get("tag"),
|
||||
"id": attrs.get("id") or None,
|
||||
"className": (attrs.get("class") or "")[:200] or None,
|
||||
"x": round(bbox.get("x") or 0, 1),
|
||||
"y": round(bbox.get("y") or 0, 1),
|
||||
"width": round(w, 1),
|
||||
"height": round(h, 1),
|
||||
})
|
||||
for i, c in enumerate(node.get("children") or []):
|
||||
_walk_section_candidates(c, vw, cur_path, i, out)
|
||||
|
||||
|
||||
def _outermost_wins(candidates: list[dict]) -> list[dict]:
|
||||
# Sort by path depth — shallowest first; only keep candidates that are not
|
||||
# descendants of an already-kept candidate.
|
||||
by_depth = sorted(candidates, key=lambda c: c["_path"].count(">"))
|
||||
kept: list[dict] = []
|
||||
for c in by_depth:
|
||||
if any(c["_path"].startswith(k["_path"] + ">") for k in kept):
|
||||
continue
|
||||
kept.append(c)
|
||||
return kept
|
||||
|
||||
|
||||
def _expand_oversized(candidates: list[dict], vw: int, vh: int, doc_h: float) -> list[dict]:
|
||||
"""For wrapper-shaped candidates (>2x viewport_h with >=3 inner sections),
|
||||
replace the wrapper with its section-like children. Without this step,
|
||||
Elementor / WP layouts where everything is wrapped in a single <main id="main">
|
||||
or <div class="elementor-section-wrap"> collapse to a single huge candidate
|
||||
and the smart-scroll loop never visits the actual sections."""
|
||||
threshold = max(vh * 2, doc_h * 0.5)
|
||||
expanded: list[dict] = []
|
||||
for c in candidates:
|
||||
if c["height"] < threshold:
|
||||
expanded.append(c)
|
||||
continue
|
||||
inner: list[dict] = []
|
||||
for i, child in enumerate(c["_node"].get("children") or []):
|
||||
_walk_section_candidates(child, vw, c["_path"], i, inner)
|
||||
inner = _outermost_wins(inner)
|
||||
# Recurse: an inner section may itself be an oversized wrapper.
|
||||
inner = _expand_oversized(inner, vw, vh, doc_h)
|
||||
if len(inner) >= 3:
|
||||
expanded.extend(inner)
|
||||
else:
|
||||
expanded.append(c)
|
||||
return expanded
|
||||
|
||||
|
||||
def enrich_sections(output_dir: Path) -> int:
|
||||
"""Re-derive sections/<vp>.json from the captured dom/<vp>/step-00.json so
|
||||
the JS section-scan's outermost-wins rule is corrected for wrapper-shaped
|
||||
layouts. Idempotent — safe to run in both fresh-capture and replay paths.
|
||||
Returns the total candidate count across viewports."""
|
||||
dom_root = output_dir / "dom"
|
||||
sec_root = output_dir / "sections"
|
||||
if not dom_root.exists():
|
||||
return 0
|
||||
sec_root.mkdir(parents=True, exist_ok=True)
|
||||
total = 0
|
||||
for vp_dir in sorted(dom_root.iterdir()):
|
||||
if not vp_dir.is_dir() or not vp_dir.name.isdigit():
|
||||
continue
|
||||
vp = int(vp_dir.name)
|
||||
step0 = vp_dir / "step-00.json"
|
||||
if not step0.exists():
|
||||
continue
|
||||
try:
|
||||
root = json.loads(step0.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
doc_h = (root.get("bbox") or {}).get("height") or 0
|
||||
vh = viewport_height_for(vp)
|
||||
candidates: list[dict] = []
|
||||
_walk_section_candidates(root, vp, "", 0, candidates)
|
||||
kept = _outermost_wins(candidates)
|
||||
expanded = _expand_oversized(kept, vp, vh, doc_h)
|
||||
# Strip private fields, sort by y
|
||||
public = [{k: v for k, v in c.items() if not k.startswith("_")} for c in expanded]
|
||||
public = [s for s in public if s.get("height", 0) > 80]
|
||||
public.sort(key=lambda s: s.get("y") or 0)
|
||||
(sec_root / f"{vp}.json").write_text(json.dumps(public, indent=2))
|
||||
total += len(public)
|
||||
return total
|
||||
|
||||
|
||||
def detect_libs(output_dir: Path) -> list[str]:
|
||||
libs_detected = set()
|
||||
for kind in ("gsap", "framer", "lottie", "three", "shaders"):
|
||||
dirname = "threejs" if kind == "three" else kind
|
||||
d = output_dir / dirname
|
||||
if not d.exists():
|
||||
continue
|
||||
for f in d.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(f.read_text())
|
||||
if data:
|
||||
libs_detected.add(kind)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
return sorted(libs_detected)
|
||||
|
||||
|
||||
def collect_har_assets_from_disk(output_dir: Path) -> dict[str, dict]:
|
||||
"""Fallback for replay mode — read each har/<vp>.har and extract response URLs."""
|
||||
har_dir = output_dir / "har"
|
||||
out: dict[str, dict] = {}
|
||||
if not har_dir.exists():
|
||||
return out
|
||||
for f in har_dir.glob("*.har"):
|
||||
try:
|
||||
har = json.loads(f.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
for entry in (har.get("log") or {}).get("entries", []):
|
||||
req = entry.get("request") or {}
|
||||
res = entry.get("response") or {}
|
||||
url = req.get("url")
|
||||
if not url:
|
||||
continue
|
||||
ct = None
|
||||
for h in (res.get("headers") or []):
|
||||
if (h.get("name") or "").lower() == "content-type":
|
||||
ct = h.get("value")
|
||||
break
|
||||
t = asset_type_from_url(url, ct)
|
||||
if t and url not in out:
|
||||
out[url] = {"type": t, "source_url": url, "content_type": ct, "status": res.get("status")}
|
||||
return out
|
||||
|
||||
|
||||
def write_meta(output_dir: Path, source_url: str, viewports: list[int], per_viewport_summary: list[dict], all_assets: dict[str, dict]) -> Path:
|
||||
merged = merge_css_assets(output_dir, all_assets)
|
||||
assets_list = build_assets_list(merged)
|
||||
vh_flags = detect_vh_flags(output_dir)
|
||||
# Emit vh-flags.json at the workspace level (parent of capture/) so it lives next to manifest.json.
|
||||
if vh_flags:
|
||||
workspace_root = output_dir.parent
|
||||
(workspace_root / "vh-flags.json").write_text(json.dumps(vh_flags, indent=2))
|
||||
# Re-derive sections/<vp>.json from the DOM dumps. Replaces the JS section-scan
|
||||
# output with a Python pass that recurses into oversized wrappers (the JS hook
|
||||
# also has the recursion now, but Python being authoritative makes the fix
|
||||
# available in --replay mode against existing capture data).
|
||||
enriched_section_total = enrich_sections(output_dir)
|
||||
|
||||
meta = {
|
||||
"source_url": source_url,
|
||||
"captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"viewports": viewports,
|
||||
"canonical_viewport": {"width": CANONICAL_WIDTH, "height": CANONICAL_HEIGHT_PRIMARY, "alt_height": CANONICAL_HEIGHT_ALT},
|
||||
"per_viewport": per_viewport_summary,
|
||||
"assets": assets_list,
|
||||
"asset_sources": {
|
||||
"har": sum(1 for a in assets_list if not a.get("from_css")),
|
||||
"css": sum(1 for a in assets_list if a.get("from_css")),
|
||||
},
|
||||
"libs_detected": detect_libs(output_dir),
|
||||
"dom_snapshots": sum(s.get("steps", 0) for s in per_viewport_summary),
|
||||
"screenshots": sum(s.get("steps", 0) for s in per_viewport_summary),
|
||||
"vh_relative_count": len(vh_flags),
|
||||
"section_shots": len(list((output_dir / "section-shots").rglob("*.png"))) if (output_dir / "section-shots").exists() else 0,
|
||||
"section_candidates_total": enriched_section_total,
|
||||
"canvas_regions": 0,
|
||||
}
|
||||
out_path = output_dir / "meta.json"
|
||||
out_path.write_text(json.dumps(meta, indent=2))
|
||||
return out_path
|
||||
|
||||
|
||||
def replay(output_dir: Path, source_url: str | None) -> Path:
|
||||
"""Replay mode: re-derive meta.json + vh-flags.json from existing capture data.
|
||||
Useful for iterating on the analyze/generate/validate agents without re-running
|
||||
the (slow) browser pipeline."""
|
||||
if not output_dir.exists():
|
||||
raise SystemExit(f"replay: output dir does not exist: {output_dir}")
|
||||
|
||||
# Try existing meta.json for source_url + viewports
|
||||
existing_meta_path = output_dir / "meta.json"
|
||||
existing_meta = {}
|
||||
if existing_meta_path.exists():
|
||||
try:
|
||||
existing_meta = json.loads(existing_meta_path.read_text())
|
||||
except Exception:
|
||||
existing_meta = {}
|
||||
if not source_url:
|
||||
source_url = existing_meta.get("source_url") or "unknown://replay"
|
||||
|
||||
# Infer viewports from existing dom/<vp>/ folders
|
||||
dom_root = output_dir / "dom"
|
||||
viewports: list[int] = []
|
||||
if dom_root.exists():
|
||||
for child in dom_root.iterdir():
|
||||
if child.is_dir() and child.name.isdigit():
|
||||
viewports.append(int(child.name))
|
||||
viewports.sort()
|
||||
|
||||
per_viewport_summary: list[dict] = []
|
||||
for vp in viewports:
|
||||
steps = len(list((dom_root / str(vp)).glob("step-*.json")))
|
||||
sections_path = output_dir / "sections" / f"{vp}.json"
|
||||
section_count = 0
|
||||
if sections_path.exists():
|
||||
try:
|
||||
section_count = len(json.loads(sections_path.read_text()))
|
||||
except Exception:
|
||||
pass
|
||||
per_viewport_summary.append({
|
||||
"viewport": vp,
|
||||
"height": viewport_height_for(vp),
|
||||
"steps": steps,
|
||||
"section_candidates": section_count,
|
||||
"har": f"har/{vp}.har" if (output_dir / "har" / f"{vp}.har").exists() else None,
|
||||
})
|
||||
|
||||
har_assets = collect_har_assets_from_disk(output_dir)
|
||||
return write_meta(output_dir, source_url, viewports, per_viewport_summary, har_assets)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--url", required=False, help="Required unless --replay is set")
|
||||
parser.add_argument("--viewports", required=False, default="375,768,1280,1920", help="csv of widths")
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--wait-strategy", default="networkidle")
|
||||
parser.add_argument("--scroll-step", type=int, default=900, help="(legacy; ignored — scroll is now section-driven)")
|
||||
parser.add_argument("--skip-third-party", action="store_true")
|
||||
parser.add_argument("--replay", action="store_true", help="Skip browser capture; only re-run post-process")
|
||||
parser.add_argument("--skip-alt-height", action="store_true", help="Skip the vh-detection alt-height pass")
|
||||
parser.add_argument("--skip-section-shots", action="store_true", help="Skip the per-section cropped screenshot pass")
|
||||
args = parser.parse_args()
|
||||
|
||||
output_dir = Path(args.output).resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.replay:
|
||||
meta_path = replay(output_dir, args.url)
|
||||
print(json.dumps({"status": "ok", "mode": "replay", "meta": str(meta_path)}))
|
||||
return
|
||||
|
||||
if not args.url:
|
||||
raise SystemExit("--url is required (unless --replay)")
|
||||
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
init_sources = load_init_hooks(script_dir / "init-hooks")
|
||||
|
||||
viewports = [int(v.strip()) for v in args.viewports.split(",") if v.strip()]
|
||||
|
||||
all_assets: dict[str, dict] = {}
|
||||
per_viewport_summary: list[dict] = []
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, args=["--disable-blink-features=AutomationControlled"])
|
||||
|
||||
# Stage 0
|
||||
for vp in viewports:
|
||||
summary = capture_viewport(browser, vp, args.url, args.wait_strategy, init_sources, output_dir, args.skip_third_party)
|
||||
for url, a in summary.pop("assets", {}).items():
|
||||
if url not in all_assets:
|
||||
all_assets[url] = a
|
||||
per_viewport_summary.append(summary)
|
||||
|
||||
# Stage 1
|
||||
if not args.skip_alt_height and CANONICAL_WIDTH in viewports:
|
||||
try:
|
||||
alt_summary = capture_alt_height(browser, args.url, args.wait_strategy, init_sources, output_dir, args.skip_third_party)
|
||||
per_viewport_summary.append({"alt_pass": True, **alt_summary})
|
||||
except Exception as e:
|
||||
print(json.dumps({"warning": f"alt-height capture failed: {e}"}), file=sys.stderr)
|
||||
|
||||
# Stage 2
|
||||
if not args.skip_section_shots:
|
||||
try:
|
||||
shots = capture_section_screenshots(browser, args.url, args.wait_strategy, init_sources, output_dir, args.skip_third_party, viewports)
|
||||
per_viewport_summary.append({"section_shots_total": shots})
|
||||
except Exception as e:
|
||||
print(json.dumps({"warning": f"section-shots pass failed: {e}"}), file=sys.stderr)
|
||||
|
||||
browser.close()
|
||||
|
||||
# Stage 3
|
||||
meta_path = write_meta(output_dir, args.url, viewports, per_viewport_summary, all_assets)
|
||||
print(json.dumps({"status": "ok", "mode": "capture", "meta": str(meta_path)}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
diff.py — Pixel diff two PNGs, producing a highlighted diff image and stats.
|
||||
|
||||
Uses the `pixelmatch` Python port (pip install pixelmatch pillow). Returns JSON to stdout:
|
||||
{ diff_pct, diff_image_path, worst_regions: [ { x, y, width, height } ] }
|
||||
|
||||
Worst regions are computed via connected-component analysis on the diff mask, sorted by area desc.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
from pixelmatch.contrib.PIL import pixelmatch
|
||||
except ImportError:
|
||||
print(json.dumps({"error": "Missing dependencies. pip install pixelmatch pillow numpy"}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
from scipy import ndimage
|
||||
HAVE_SCIPY = True
|
||||
except ImportError:
|
||||
HAVE_SCIPY = False
|
||||
|
||||
|
||||
def resize_to_match(a: Image.Image, b: Image.Image) -> tuple[Image.Image, Image.Image]:
|
||||
if a.size == b.size:
|
||||
return a, b
|
||||
target = (min(a.size[0], b.size[0]), min(a.size[1], b.size[1]))
|
||||
return a.resize(target), b.resize(target)
|
||||
|
||||
|
||||
def worst_regions_from_diff(diff_img: Image.Image, top_n: int = 5) -> list[dict]:
|
||||
if not HAVE_SCIPY:
|
||||
return []
|
||||
arr = np.array(diff_img)
|
||||
if arr.ndim == 3:
|
||||
mask = (arr[..., :3].sum(axis=-1) > 0).astype(np.uint8)
|
||||
else:
|
||||
mask = (arr > 0).astype(np.uint8)
|
||||
labels, n = ndimage.label(mask)
|
||||
if n == 0:
|
||||
return []
|
||||
regions = []
|
||||
for i in range(1, n + 1):
|
||||
ys, xs = np.where(labels == i)
|
||||
if len(xs) < 20:
|
||||
continue
|
||||
regions.append({
|
||||
"x": int(xs.min()),
|
||||
"y": int(ys.min()),
|
||||
"width": int(xs.max() - xs.min() + 1),
|
||||
"height": int(ys.max() - ys.min() + 1),
|
||||
"area": int(len(xs)),
|
||||
})
|
||||
regions.sort(key=lambda r: r["area"], reverse=True)
|
||||
out = []
|
||||
for r in regions[:top_n]:
|
||||
r.pop("area", None)
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--before", required=True)
|
||||
parser.add_argument("--after", required=True)
|
||||
parser.add_argument("--diff-out", required=True)
|
||||
parser.add_argument("--threshold", type=float, default=0.1)
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
a = Image.open(args.before).convert("RGBA")
|
||||
b = Image.open(args.after).convert("RGBA")
|
||||
a, b = resize_to_match(a, b)
|
||||
diff = Image.new("RGBA", a.size, (0, 0, 0, 0))
|
||||
|
||||
mismatched = pixelmatch(a, b, diff, threshold=args.threshold, includeAA=False)
|
||||
total = a.size[0] * a.size[1]
|
||||
diff_pct = (mismatched / total) * 100.0 if total else 0.0
|
||||
|
||||
Path(args.diff_out).parent.mkdir(parents=True, exist_ok=True)
|
||||
diff.save(args.diff_out)
|
||||
|
||||
worst = worst_regions_from_diff(diff)
|
||||
|
||||
result = {
|
||||
"diff_pct": round(diff_pct, 3),
|
||||
"diff_image_path": args.diff_out,
|
||||
"worst_regions": worst,
|
||||
"size": {"width": a.size[0], "height": a.size[1]},
|
||||
}
|
||||
if args.json:
|
||||
print(json.dumps(result))
|
||||
else:
|
||||
print(f"diff_pct={result['diff_pct']}% diff_image={result['diff_image_path']} regions={len(worst)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,342 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
dom-diff.py — Structural diff between two DOM JSON snapshots produced by the
|
||||
__CLONE_DUMP_COMPUTED__ walker. Used by the validate agent to surface concrete,
|
||||
actionable issues to the generate agent (font sizes, missing elements, wrong
|
||||
dimensions) instead of relying solely on PNG pixel diffs.
|
||||
|
||||
Inputs: --captured <captured DOM JSON>, --rendered <rendered DOM JSON>.
|
||||
Optionally --root-selector to scope the comparison to a specific subtree
|
||||
(typically a section's id or class), and --max-depth to cap recursion.
|
||||
|
||||
Output (JSON to stdout): {
|
||||
matched: <int>,
|
||||
missing_in_rendered: [{ path, tag, id, class, expected_h, expected_w }],
|
||||
extra_in_rendered: [{ path, tag, id, class }],
|
||||
style_mismatches: [{ path, property, expected, actual, severity }],
|
||||
size_mismatches: [{ path, expected, actual, delta_pct }],
|
||||
issues: [string, string, ...] // top human-readable issues
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Properties we treat as load-bearing for visual fidelity. Reported in this
|
||||
# order in the human-readable `issues` list.
|
||||
COMPARE_STYLE_PROPS = [
|
||||
"fontSize",
|
||||
"fontFamily",
|
||||
"fontWeight",
|
||||
"lineHeight",
|
||||
"color",
|
||||
"backgroundColor",
|
||||
"backgroundImage",
|
||||
"padding",
|
||||
"margin",
|
||||
"borderRadius",
|
||||
"boxShadow",
|
||||
"display",
|
||||
"flexDirection",
|
||||
"justifyContent",
|
||||
"alignItems",
|
||||
"gridTemplateColumns",
|
||||
"gap",
|
||||
"textAlign",
|
||||
"letterSpacing",
|
||||
"textTransform",
|
||||
]
|
||||
|
||||
# How far apart two values can be before we flag them.
|
||||
NUMERIC_TOLERANCE_PX = 2
|
||||
SIZE_TOLERANCE_PCT = 5.0
|
||||
|
||||
|
||||
def find_subtree(node: dict, selector: str | None) -> dict | None:
|
||||
if not selector:
|
||||
return node
|
||||
target = selector.lstrip("#.")
|
||||
is_id = selector.startswith("#")
|
||||
is_class = selector.startswith(".")
|
||||
|
||||
def walk(n: dict) -> dict | None:
|
||||
if not isinstance(n, dict):
|
||||
return None
|
||||
attrs = n.get("attrs") or {}
|
||||
if is_id and attrs.get("id") == target:
|
||||
return n
|
||||
if is_class:
|
||||
cls = attrs.get("class") or ""
|
||||
if target in cls.split():
|
||||
return n
|
||||
if not is_id and not is_class:
|
||||
# Tag selector
|
||||
if n.get("tag") == target:
|
||||
return n
|
||||
for c in n.get("children") or []:
|
||||
r = walk(c)
|
||||
if r is not None:
|
||||
return r
|
||||
return None
|
||||
|
||||
return walk(node)
|
||||
|
||||
|
||||
def parse_px(v: str | None) -> float | None:
|
||||
if not v:
|
||||
return None
|
||||
m = re.match(r"^(-?\d+(?:\.\d+)?)px$", str(v).strip())
|
||||
if m:
|
||||
return float(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def normalize_style_value(v: str | None) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
s = str(v).strip()
|
||||
s = re.sub(r"\s+", " ", s)
|
||||
return s
|
||||
|
||||
|
||||
def style_close(prop: str, expected: str | None, actual: str | None) -> bool:
|
||||
if expected == actual:
|
||||
return True
|
||||
e_px = parse_px(expected)
|
||||
a_px = parse_px(actual)
|
||||
if e_px is not None and a_px is not None:
|
||||
return abs(e_px - a_px) <= NUMERIC_TOLERANCE_PX
|
||||
if normalize_style_value(expected) == normalize_style_value(actual):
|
||||
return True
|
||||
# backgroundImage: if both reference url(), only flag if the url paths differ
|
||||
if prop == "backgroundImage" and expected and actual:
|
||||
e_url = re.search(r"url\([^)]+\)", expected)
|
||||
a_url = re.search(r"url\([^)]+\)", actual)
|
||||
if e_url and a_url:
|
||||
return False # both have urls but different — flag
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def child_key(n: dict, idx: int) -> str:
|
||||
if not isinstance(n, dict):
|
||||
return f"text[{idx}]"
|
||||
tag = n.get("tag") or "?"
|
||||
attrs = n.get("attrs") or {}
|
||||
if attrs.get("id"):
|
||||
return f"{tag}#{attrs['id']}"
|
||||
return f"{tag}[{idx}]"
|
||||
|
||||
|
||||
def compare(
|
||||
a: dict | None,
|
||||
b: dict | None,
|
||||
path: str,
|
||||
issues: list[dict],
|
||||
counters: dict,
|
||||
max_depth: int,
|
||||
depth: int = 0,
|
||||
) -> None:
|
||||
if depth > max_depth:
|
||||
return
|
||||
|
||||
if a is None and b is None:
|
||||
return
|
||||
if a is None or not isinstance(a, dict):
|
||||
return # only flag when source had it
|
||||
if b is None or not isinstance(b, dict):
|
||||
# Element missing in rendered
|
||||
attrs = a.get("attrs") or {}
|
||||
bbox = a.get("bbox") or {}
|
||||
issues.append({
|
||||
"kind": "missing_in_rendered",
|
||||
"path": path,
|
||||
"tag": a.get("tag"),
|
||||
"id": attrs.get("id"),
|
||||
"class": attrs.get("class"),
|
||||
"expected_w": round(bbox.get("width") or 0, 1),
|
||||
"expected_h": round(bbox.get("height") or 0, 1),
|
||||
})
|
||||
counters["missing"] += 1
|
||||
return
|
||||
|
||||
# Tag mismatch
|
||||
if a.get("tag") != b.get("tag"):
|
||||
attrs_a = a.get("attrs") or {}
|
||||
attrs_b = b.get("attrs") or {}
|
||||
issues.append({
|
||||
"kind": "tag_mismatch",
|
||||
"path": path,
|
||||
"expected_tag": a.get("tag"),
|
||||
"actual_tag": b.get("tag"),
|
||||
"expected_id": attrs_a.get("id"),
|
||||
"actual_id": attrs_b.get("id"),
|
||||
})
|
||||
counters["tag_mismatch"] += 1
|
||||
return
|
||||
|
||||
counters["matched"] += 1
|
||||
|
||||
# Style comparison
|
||||
ca = (a.get("computed") or {})
|
||||
cb = (b.get("computed") or {})
|
||||
for prop in COMPARE_STYLE_PROPS:
|
||||
ev, av = ca.get(prop), cb.get(prop)
|
||||
if ev is None and av is None:
|
||||
continue
|
||||
# If property only exists on rendered, that's a divergence too — but
|
||||
# we only flag when source explicitly set it (prevents noise).
|
||||
if ev is None:
|
||||
continue
|
||||
if not style_close(prop, ev, av):
|
||||
issues.append({
|
||||
"kind": "style_mismatch",
|
||||
"path": path,
|
||||
"property": prop,
|
||||
"expected": ev,
|
||||
"actual": av,
|
||||
})
|
||||
counters["style"] += 1
|
||||
|
||||
# Size comparison
|
||||
ba = a.get("bbox") or {}
|
||||
bb = b.get("bbox") or {}
|
||||
ew, eh = ba.get("width") or 0, ba.get("height") or 0
|
||||
aw, ah = bb.get("width") or 0, bb.get("height") or 0
|
||||
if ew > 8 and eh > 8:
|
||||
wdelta = abs(ew - aw) / max(ew, 1) * 100
|
||||
hdelta = abs(eh - ah) / max(eh, 1) * 100
|
||||
if wdelta > SIZE_TOLERANCE_PCT or hdelta > SIZE_TOLERANCE_PCT:
|
||||
issues.append({
|
||||
"kind": "size_mismatch",
|
||||
"path": path,
|
||||
"expected": [round(ew, 1), round(eh, 1)],
|
||||
"actual": [round(aw, 1), round(ah, 1)],
|
||||
"delta_pct": round(max(wdelta, hdelta), 1),
|
||||
})
|
||||
counters["size"] += 1
|
||||
|
||||
# Children — match by (tag, id) tuple when ids exist, else positional
|
||||
ka = a.get("children") or []
|
||||
kb = b.get("children") or []
|
||||
# Filter to element children (skip pure text)
|
||||
ka_el = [(i, c) for i, c in enumerate(ka) if isinstance(c, dict) and "tag" in c]
|
||||
kb_el = [(i, c) for i, c in enumerate(kb) if isinstance(c, dict) and "tag" in c]
|
||||
|
||||
# Build a lookup of rendered children by id (cheap) and by (tag, idx) fallback
|
||||
used_b: set[int] = set()
|
||||
b_by_id: dict[str, int] = {}
|
||||
for j, (_, ch) in enumerate(kb_el):
|
||||
cid = (ch.get("attrs") or {}).get("id")
|
||||
if cid:
|
||||
b_by_id[cid] = j
|
||||
|
||||
for ai, (_, ch) in enumerate(ka_el):
|
||||
a_id = (ch.get("attrs") or {}).get("id")
|
||||
match_idx = None
|
||||
if a_id and a_id in b_by_id and b_by_id[a_id] not in used_b:
|
||||
match_idx = b_by_id[a_id]
|
||||
elif ai < len(kb_el) and ai not in used_b:
|
||||
cand = kb_el[ai][1]
|
||||
if cand.get("tag") == ch.get("tag"):
|
||||
match_idx = ai
|
||||
if match_idx is None:
|
||||
compare(ch, None, f"{path} > {child_key(ch, ai)}", issues, counters, max_depth, depth + 1)
|
||||
else:
|
||||
used_b.add(match_idx)
|
||||
compare(ch, kb_el[match_idx][1], f"{path} > {child_key(ch, ai)}", issues, counters, max_depth, depth + 1)
|
||||
|
||||
# Extra children in rendered (no source counterpart) — only flag at top levels
|
||||
# to avoid noise from minor wrappers
|
||||
if depth < 3:
|
||||
for j, (_, ch) in enumerate(kb_el):
|
||||
if j in used_b:
|
||||
continue
|
||||
attrs = ch.get("attrs") or {}
|
||||
issues.append({
|
||||
"kind": "extra_in_rendered",
|
||||
"path": f"{path} > {child_key(ch, j)}",
|
||||
"tag": ch.get("tag"),
|
||||
"id": attrs.get("id"),
|
||||
"class": attrs.get("class"),
|
||||
})
|
||||
counters["extra"] += 1
|
||||
|
||||
|
||||
def humanize(issues: list[dict], top_n: int = 12) -> list[str]:
|
||||
"""Convert structured issues to human-readable strings, ranked by impact."""
|
||||
# Rank: missing > tag_mismatch > size_mismatch > style_mismatch > extra
|
||||
weights = {"missing_in_rendered": 100, "tag_mismatch": 80, "size_mismatch": 50, "style_mismatch": 25, "extra_in_rendered": 10}
|
||||
style_weights = {"backgroundImage": 4, "fontSize": 3, "color": 2, "backgroundColor": 2, "fontFamily": 2}
|
||||
|
||||
def score(i: dict) -> float:
|
||||
base = weights.get(i["kind"], 0)
|
||||
if i["kind"] == "size_mismatch":
|
||||
base += min(i.get("delta_pct", 0), 50)
|
||||
if i["kind"] == "style_mismatch":
|
||||
base += style_weights.get(i.get("property", ""), 1)
|
||||
return base
|
||||
|
||||
issues_sorted = sorted(issues, key=score, reverse=True)
|
||||
out: list[str] = []
|
||||
for i in issues_sorted[:top_n]:
|
||||
path = i.get("path", "?")
|
||||
if i["kind"] == "missing_in_rendered":
|
||||
out.append(f"{path}: missing in rendered (expected {i.get('tag')}{' #' + i['id'] if i.get('id') else ''}, ~{i.get('expected_w')}x{i.get('expected_h')})")
|
||||
elif i["kind"] == "tag_mismatch":
|
||||
out.append(f"{path}: expected <{i.get('expected_tag')}>, rendered <{i.get('actual_tag')}>")
|
||||
elif i["kind"] == "size_mismatch":
|
||||
ew, eh = i.get("expected", [0, 0])
|
||||
aw, ah = i.get("actual", [0, 0])
|
||||
out.append(f"{path}: size {aw}x{ah}, should be {ew}x{eh} (Δ{i.get('delta_pct')}%)")
|
||||
elif i["kind"] == "style_mismatch":
|
||||
out.append(f"{path}: {i.get('property')} is {i.get('actual')}, should be {i.get('expected')}")
|
||||
elif i["kind"] == "extra_in_rendered":
|
||||
out.append(f"{path}: extra element in rendered (<{i.get('tag')}>{' #' + i['id'] if i.get('id') else ''}) — not in source")
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--captured", required=True)
|
||||
parser.add_argument("--rendered", required=True)
|
||||
parser.add_argument("--root-selector", default=None, help="e.g. #hero or .ecosystem to scope the diff to a section")
|
||||
parser.add_argument("--max-depth", type=int, default=8)
|
||||
parser.add_argument("--max-issues", type=int, default=200)
|
||||
args = parser.parse_args()
|
||||
|
||||
captured = json.loads(Path(args.captured).read_text())
|
||||
rendered = json.loads(Path(args.rendered).read_text())
|
||||
|
||||
a_root = find_subtree(captured, args.root_selector)
|
||||
b_root = find_subtree(rendered, args.root_selector)
|
||||
|
||||
if a_root is None:
|
||||
print(json.dumps({"error": f"root selector not found in captured: {args.root_selector}"}))
|
||||
sys.exit(2)
|
||||
if b_root is None:
|
||||
print(json.dumps({"error": f"root selector not found in rendered: {args.root_selector}"}))
|
||||
sys.exit(3)
|
||||
|
||||
issues: list[dict] = []
|
||||
counters = {"matched": 0, "missing": 0, "extra": 0, "tag_mismatch": 0, "style": 0, "size": 0}
|
||||
compare(a_root, b_root, args.root_selector or a_root.get("tag", "body"), issues, counters, args.max_depth)
|
||||
|
||||
issues = issues[: args.max_issues]
|
||||
humanized = humanize(issues, top_n=12)
|
||||
|
||||
print(json.dumps({
|
||||
"matched": counters["matched"],
|
||||
"counts": counters,
|
||||
"issues": humanized,
|
||||
"structured_issues": issues,
|
||||
}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
download-assets.py — Download all assets referenced in a capture meta.json (or manifest.json)
|
||||
into <project>/public/assets/cloned/ using hash-based filenames.
|
||||
|
||||
Input: JSON file with `assets[]` of shape { type, source_url, local_path }.
|
||||
Output (to --json stdout): { downloaded: [...], failed: [...], skipped: [...] }
|
||||
|
||||
Dependencies: httpx (pip install httpx).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
print(json.dumps({"error": "Missing dependencies. pip install httpx"}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
LICENSED_FONT_HOST_RE = re.compile(r"(use\.typekit\.net|fonts\.adobe\.com|fast\.fonts\.net|cloud\.typography\.com)")
|
||||
ROTATING_AUTH_RE = re.compile(r"[?&](token|signature|expires)=")
|
||||
USER_AGENT = "Mozilla/5.0 (compatible; IonCloneBot/1.0)"
|
||||
|
||||
|
||||
def download_one(client: httpx.Client, url: str, out_path: Path) -> str:
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with client.stream("GET", url, timeout=30, headers={"User-Agent": USER_AGENT}) as r:
|
||||
r.raise_for_status()
|
||||
with out_path.open("wb") as f:
|
||||
for chunk in r.iter_bytes():
|
||||
f.write(chunk)
|
||||
return "downloaded"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--manifest", required=True, help="Path to meta.json or manifest.json")
|
||||
parser.add_argument("--public-dir", required=True, help="Path to <project>/public/assets/cloned")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
manifest_path = Path(args.manifest)
|
||||
public_dir = Path(args.public_dir)
|
||||
public_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
assets = manifest.get("assets", [])
|
||||
|
||||
downloaded: list[str] = []
|
||||
failed: list[dict] = []
|
||||
skipped: list[dict] = []
|
||||
|
||||
with httpx.Client(follow_redirects=True) as client:
|
||||
for a in assets:
|
||||
url = a.get("source_url")
|
||||
local_path = a.get("local_path")
|
||||
atype = a.get("type")
|
||||
if not url or not local_path:
|
||||
failed.append({"url": url, "reason": "malformed_asset"})
|
||||
continue
|
||||
|
||||
if atype == "font" and LICENSED_FONT_HOST_RE.search(url):
|
||||
skipped.append({"url": url, "reason": "licensed_font_unclear"})
|
||||
continue
|
||||
|
||||
if ROTATING_AUTH_RE.search(url):
|
||||
skipped.append({"url": url, "reason": "rotating_auth_token"})
|
||||
continue
|
||||
|
||||
# local_path in the asset is relative to the project root; public-dir is
|
||||
# <project>/public/assets/cloned, so strip that prefix.
|
||||
rel = local_path
|
||||
for prefix in ("public/assets/cloned/", "assets/cloned/"):
|
||||
if rel.startswith(prefix):
|
||||
rel = rel[len(prefix):]
|
||||
break
|
||||
out_path = public_dir / rel
|
||||
|
||||
try:
|
||||
download_one(client, url, out_path)
|
||||
downloaded.append(str(out_path))
|
||||
except httpx.HTTPError as e:
|
||||
failed.append({"url": url, "reason": str(e)})
|
||||
except Exception as e:
|
||||
failed.append({"url": url, "reason": str(e)})
|
||||
|
||||
result = {"downloaded": downloaded, "failed": failed, "skipped": skipped}
|
||||
if args.json:
|
||||
print(json.dumps(result))
|
||||
else:
|
||||
print(f"downloaded={len(downloaded)} failed={len(failed)} skipped={len(skipped)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
dump-rendered.py — Open a URL (typically the local dev server) and dump its DOM
|
||||
using the same __CLONE_DUMP_COMPUTED__ walker the capture pipeline uses for the
|
||||
source. This produces a directly-comparable structural snapshot the validate
|
||||
agent can diff against the captured original.
|
||||
|
||||
Usage:
|
||||
dump-rendered.py --url http://localhost:3000/ --output workspace/rendered/1280-step-00.json \
|
||||
--viewport 1280 --scroll-y 0
|
||||
|
||||
Dependencies: playwright.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout
|
||||
except ImportError:
|
||||
print(json.dumps({"error": "Missing dependencies. pip install playwright && playwright install chromium"}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def load_init_hooks(hooks_dir: Path) -> list[str]:
|
||||
sources: list[str] = []
|
||||
# We only need computed-styles + section-scan for rendered-DOM purposes.
|
||||
for name in ("bootstrap.js", "computed-styles.js", "section-scan.js"):
|
||||
p = hooks_dir / name
|
||||
if p.exists():
|
||||
sources.append(p.read_text())
|
||||
return sources
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--url", required=True)
|
||||
parser.add_argument("--output", required=True, help="Path to write the rendered DOM JSON")
|
||||
parser.add_argument("--viewport", type=int, default=1280)
|
||||
parser.add_argument("--viewport-height", type=int, default=None, help="Default: 16:9 of viewport width")
|
||||
parser.add_argument("--scroll-y", type=int, default=0)
|
||||
parser.add_argument("--reduce-motion", action="store_true")
|
||||
parser.add_argument("--settle-ms", type=int, default=800)
|
||||
args = parser.parse_args()
|
||||
|
||||
output = Path(args.output).resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
height = args.viewport_height or round(args.viewport * 9 / 16)
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
init_sources = load_init_hooks(script_dir / "init-hooks")
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, args=["--disable-blink-features=AutomationControlled"])
|
||||
ctx = browser.new_context(viewport={"width": args.viewport, "height": height})
|
||||
for src in init_sources:
|
||||
ctx.add_init_script(src)
|
||||
page = ctx.new_page()
|
||||
|
||||
try:
|
||||
page.goto(args.url, wait_until="networkidle", timeout=30_000)
|
||||
except PWTimeout:
|
||||
page.goto(args.url, wait_until="domcontentloaded", timeout=30_000)
|
||||
|
||||
if args.reduce_motion:
|
||||
try:
|
||||
page.evaluate("document.documentElement.classList.add('reduce-motion')")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
page.evaluate("() => document.fonts.ready")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
page.wait_for_function("window.__CLONE_READY__ === true", timeout=5_000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if args.scroll_y:
|
||||
page.evaluate(f"window.scrollTo({{ top: {args.scroll_y}, behavior: 'instant' }})")
|
||||
|
||||
time.sleep(args.settle_ms / 1000.0)
|
||||
|
||||
snapshot = page.evaluate(
|
||||
"() => (typeof window.__CLONE_DUMP_COMPUTED__ === 'function') ? window.__CLONE_DUMP_COMPUTED__() : null"
|
||||
)
|
||||
sections = page.evaluate(
|
||||
"() => (typeof window.__CLONE_LIST_SECTIONS__ === 'function') ? window.__CLONE_LIST_SECTIONS__() : []"
|
||||
) or []
|
||||
|
||||
if snapshot is None:
|
||||
raise SystemExit("dump-rendered: __CLONE_DUMP_COMPUTED__ unavailable — init hooks failed to load")
|
||||
|
||||
output.write_text(json.dumps(snapshot, separators=(",", ":")))
|
||||
sections_path = output.with_name(output.stem + "-sections.json")
|
||||
sections_path.write_text(json.dumps(sections, indent=2))
|
||||
|
||||
page.close()
|
||||
ctx.close()
|
||||
browser.close()
|
||||
|
||||
print(json.dumps({
|
||||
"status": "ok",
|
||||
"output": str(output),
|
||||
"sections_path": str(sections_path),
|
||||
"section_count": len(sections),
|
||||
}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
extract-fonts.py — Parse @font-face rules from captured CSS, resolve URLs, download,
|
||||
and write license_hint into the manifest.
|
||||
|
||||
Run after capture.py and before generation. Input: capture directory with fonts/*.json.
|
||||
Output: writes font files into <public_dir>/fonts/ and an updated JSON summary.
|
||||
|
||||
Dependencies: httpx, tinycss2 (pip install httpx tinycss2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
try:
|
||||
import httpx
|
||||
import tinycss2
|
||||
except ImportError:
|
||||
print(json.dumps({"error": "Missing deps. pip install httpx tinycss2"}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
LICENSED_HOSTS = re.compile(r"(use\.typekit\.net|fonts\.adobe\.com|fast\.fonts\.net|cloud\.typography\.com)")
|
||||
OPEN_HOSTS = re.compile(r"(fonts\.googleapis\.com|fonts\.gstatic\.com)")
|
||||
|
||||
|
||||
def sha1_8(s: str) -> str:
|
||||
return hashlib.sha1(s.encode("utf-8")).hexdigest()[:8]
|
||||
|
||||
|
||||
def classify(url: str) -> str:
|
||||
if LICENSED_HOSTS.search(url):
|
||||
return "licensed"
|
||||
if OPEN_HOSTS.search(url):
|
||||
return "open"
|
||||
return "unclear"
|
||||
|
||||
|
||||
def parse_face_rules(css_text: str, base_url: str) -> list[dict]:
|
||||
out = []
|
||||
rules = tinycss2.parse_stylesheet(css_text, skip_comments=True, skip_whitespace=True)
|
||||
for rule in rules:
|
||||
if rule.type != "at-rule" or rule.lower_at_keyword != "font-face":
|
||||
continue
|
||||
block = tinycss2.parse_declaration_list(rule.content or [])
|
||||
face: dict = {}
|
||||
for decl in block:
|
||||
if decl.type != "declaration":
|
||||
continue
|
||||
name = decl.lower_name
|
||||
value = tinycss2.serialize(decl.value).strip().strip('"').strip("'")
|
||||
if name == "font-family":
|
||||
face["family"] = value
|
||||
elif name == "font-weight":
|
||||
face["weight"] = value
|
||||
elif name == "font-style":
|
||||
face["style"] = value
|
||||
elif name == "src":
|
||||
urls = re.findall(r"url\(\s*['\"]?([^'\")]+)['\"]?\s*\)", tinycss2.serialize(decl.value))
|
||||
face["urls"] = [urljoin(base_url, u) for u in urls]
|
||||
if face.get("family") and face.get("urls"):
|
||||
out.append(face)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--capture-dir", required=True)
|
||||
parser.add_argument("--public-dir", required=True)
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
capture_dir = Path(args.capture_dir)
|
||||
public_dir = Path(args.public_dir)
|
||||
fonts_out = public_dir / "fonts"
|
||||
fonts_out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
face_dumps = list((capture_dir / "fonts").glob("*.json"))
|
||||
|
||||
all_faces: list[dict] = []
|
||||
for dump in face_dumps:
|
||||
data = json.loads(dump.read_text())
|
||||
for entry in data if isinstance(data, list) else []:
|
||||
css = entry.get("cssText") or ""
|
||||
base = entry.get("baseUrl") or ""
|
||||
all_faces.extend(parse_face_rules(css, base))
|
||||
|
||||
downloaded: list[dict] = []
|
||||
skipped: list[dict] = []
|
||||
|
||||
with httpx.Client(follow_redirects=True) as client:
|
||||
for face in all_faces:
|
||||
family = face["family"]
|
||||
license_hint = "unclear"
|
||||
for url in face["urls"]:
|
||||
license_hint = classify(url)
|
||||
if license_hint == "licensed":
|
||||
skipped.append({"family": family, "url": url, "reason": "licensed"})
|
||||
break
|
||||
try:
|
||||
ext = url.rsplit(".", 1)[-1].split("?", 1)[0].lower()
|
||||
if ext not in ("woff2", "woff", "ttf", "otf"):
|
||||
ext = "woff2"
|
||||
fname = f"{sha1_8(url)}-{re.sub(r'[^a-z0-9]+', '-', family.lower())}.{ext}"
|
||||
out_path = fonts_out / fname
|
||||
r = client.get(url, timeout=30, headers={"User-Agent": "Mozilla/5.0 (compatible; IonCloneBot/1.0)"})
|
||||
r.raise_for_status()
|
||||
out_path.write_bytes(r.content)
|
||||
downloaded.append({
|
||||
"family": family,
|
||||
"weight": face.get("weight"),
|
||||
"style": face.get("style"),
|
||||
"source_url": url,
|
||||
"local_path": str(out_path),
|
||||
"license_hint": license_hint,
|
||||
})
|
||||
except Exception as e:
|
||||
skipped.append({"family": family, "url": url, "reason": str(e)})
|
||||
|
||||
result = {"downloaded": downloaded, "skipped": skipped}
|
||||
if args.json:
|
||||
print(json.dumps(result))
|
||||
else:
|
||||
print(f"downloaded={len(downloaded)} skipped={len(skipped)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,25 @@
|
||||
// Initializes the shared capture bucket that every other hook writes into.
|
||||
// Runs first because hooks load in lexicographic order.
|
||||
(() => {
|
||||
if (window.__CLONE_CAPTURE__) return;
|
||||
window.__CLONE_CAPTURE__ = {
|
||||
shaders: [],
|
||||
gsap: [],
|
||||
framer: [],
|
||||
lottie: [],
|
||||
threejs: [],
|
||||
cssVars: {},
|
||||
fonts: [],
|
||||
};
|
||||
// Sentinel the capture script waits on after DOMContentLoaded + a tick.
|
||||
// Hooks that dump state on demand (gsap, framer) set __CLONE_READY__ when they've run.
|
||||
window.__CLONE_READY__ = false;
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (typeof window.__CLONE_FINALIZE__ === 'function') window.__CLONE_FINALIZE__();
|
||||
} catch {}
|
||||
window.__CLONE_READY__ = true;
|
||||
}, 800);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,193 @@
|
||||
// Exposes window.__CLONE_DUMP_COMPUTED__() which walks the DOM and returns a minimal tree
|
||||
// of resolved computed styles (only non-default values). The capture script calls this on demand
|
||||
// after each scroll step — cheap to call repeatedly, much richer than the inline per-step snapshot
|
||||
// the Playwright script does as a fallback.
|
||||
(() => {
|
||||
const PROPS = [
|
||||
'color',
|
||||
'backgroundColor',
|
||||
'backgroundImage',
|
||||
'backgroundSize',
|
||||
'backgroundPosition',
|
||||
'backgroundRepeat',
|
||||
'backgroundAttachment',
|
||||
'backgroundClip',
|
||||
'fontFamily',
|
||||
'fontSize',
|
||||
'fontWeight',
|
||||
'lineHeight',
|
||||
'letterSpacing',
|
||||
'textTransform',
|
||||
'textAlign',
|
||||
'textDecoration',
|
||||
'textShadow',
|
||||
'whiteSpace',
|
||||
'display',
|
||||
'flexDirection',
|
||||
'flexWrap',
|
||||
'justifyContent',
|
||||
'alignItems',
|
||||
'alignContent',
|
||||
'alignSelf',
|
||||
'gap',
|
||||
'rowGap',
|
||||
'columnGap',
|
||||
'gridTemplateColumns',
|
||||
'gridTemplateRows',
|
||||
'gridTemplateAreas',
|
||||
'gridColumn',
|
||||
'gridRow',
|
||||
'gridAutoFlow',
|
||||
'padding',
|
||||
'paddingTop',
|
||||
'paddingRight',
|
||||
'paddingBottom',
|
||||
'paddingLeft',
|
||||
'margin',
|
||||
'marginTop',
|
||||
'marginRight',
|
||||
'marginBottom',
|
||||
'marginLeft',
|
||||
'width',
|
||||
'height',
|
||||
'maxWidth',
|
||||
'maxHeight',
|
||||
'minWidth',
|
||||
'minHeight',
|
||||
'aspectRatio',
|
||||
'position',
|
||||
'top',
|
||||
'right',
|
||||
'bottom',
|
||||
'left',
|
||||
'zIndex',
|
||||
'inset',
|
||||
'border',
|
||||
'borderTop',
|
||||
'borderRight',
|
||||
'borderBottom',
|
||||
'borderLeft',
|
||||
'borderRadius',
|
||||
'borderColor',
|
||||
'borderWidth',
|
||||
'borderStyle',
|
||||
'boxShadow',
|
||||
'opacity',
|
||||
'transform',
|
||||
'transformOrigin',
|
||||
'filter',
|
||||
'backdropFilter',
|
||||
'clipPath',
|
||||
'mask',
|
||||
'maskImage',
|
||||
'mixBlendMode',
|
||||
'isolation',
|
||||
'overflow',
|
||||
'overflowX',
|
||||
'overflowY',
|
||||
'scrollBehavior',
|
||||
'scrollSnapType',
|
||||
'cursor',
|
||||
'pointerEvents',
|
||||
'userSelect',
|
||||
'willChange',
|
||||
'contain',
|
||||
];
|
||||
|
||||
// Compute defaults once per tag by creating a hidden iframe so we don't treat
|
||||
// inherited defaults as "used styles". The iframe is created lazily — at
|
||||
// init-script time, document.documentElement may still be null (Playwright
|
||||
// fires add_init_script before the HTML parser materialises <html>).
|
||||
let iframe = null;
|
||||
function ensureIframe() {
|
||||
if (iframe) return iframe;
|
||||
if (!document.documentElement) return null;
|
||||
try {
|
||||
iframe = document.createElement('iframe');
|
||||
iframe.style.cssText = 'position:absolute;width:0;height:0;border:0;opacity:0;pointer-events:none';
|
||||
iframe.srcdoc = '<!doctype html><html><body></body></html>';
|
||||
document.documentElement.appendChild(iframe);
|
||||
} catch {
|
||||
iframe = null;
|
||||
}
|
||||
return iframe;
|
||||
}
|
||||
|
||||
const defaultsByTag = new Map();
|
||||
function defaultsFor(tag) {
|
||||
if (defaultsByTag.has(tag)) return defaultsByTag.get(tag);
|
||||
const ifr = ensureIframe();
|
||||
const doc = ifr ? ifr.contentDocument : null;
|
||||
if (!doc || !doc.body) return {};
|
||||
const el = doc.createElement(tag);
|
||||
doc.body.appendChild(el);
|
||||
const cs = doc.defaultView.getComputedStyle(el);
|
||||
const d = {};
|
||||
for (const p of PROPS) d[p] = cs[p];
|
||||
doc.body.removeChild(el);
|
||||
defaultsByTag.set(tag, d);
|
||||
return d;
|
||||
}
|
||||
|
||||
function nonDefault(el) {
|
||||
const cs = getComputedStyle(el);
|
||||
const defaults = defaultsFor(el.tagName.toLowerCase());
|
||||
const out = {};
|
||||
for (const p of PROPS) {
|
||||
const v = cs[p];
|
||||
if (v == null || v === '') continue;
|
||||
if (v === defaults[p]) continue;
|
||||
out[p] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pseudoStyles(el) {
|
||||
const out = {};
|
||||
for (const pseudo of ['::before', '::after']) {
|
||||
try {
|
||||
const cs = getComputedStyle(el, pseudo);
|
||||
const content = cs.content;
|
||||
if (content && content !== 'normal' && content !== 'none') {
|
||||
const block = {};
|
||||
for (const p of PROPS) {
|
||||
const v = cs[p];
|
||||
if (v && v !== 'none' && v !== 'normal' && v !== 'auto') block[p] = v;
|
||||
}
|
||||
block.content = content;
|
||||
out[pseudo] = block;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
|
||||
function serialize(el) {
|
||||
if (el.nodeType === Node.TEXT_NODE) {
|
||||
const t = el.textContent;
|
||||
return t && t.trim() ? { text: t } : null;
|
||||
}
|
||||
if (el.nodeType !== Node.ELEMENT_NODE) return null;
|
||||
if (el.tagName === 'SCRIPT' || el.tagName === 'STYLE' || el.tagName === 'NOSCRIPT') return null;
|
||||
const attrs = {};
|
||||
for (const a of el.attributes) attrs[a.name] = a.value;
|
||||
const computed = nonDefault(el);
|
||||
const pseudo = pseudoStyles(el);
|
||||
if (pseudo) computed.pseudo = pseudo;
|
||||
const r = el.getBoundingClientRect();
|
||||
const children = [];
|
||||
for (const c of el.childNodes) {
|
||||
const s = serialize(c);
|
||||
if (s) children.push(s);
|
||||
}
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
attrs,
|
||||
computed,
|
||||
bbox: { x: r.x, y: r.y + window.scrollY, width: r.width, height: r.height },
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
window.__CLONE_DUMP_COMPUTED__ = () => serialize(document.body);
|
||||
})();
|
||||
@@ -0,0 +1,121 @@
|
||||
// Dumps every same-origin CSS rule as { selector, cssText } into __CLONE_CAPTURE__.cssRules,
|
||||
// and extracts every url(...) asset reference (resolved to absolute URLs) into
|
||||
// __CLONE_CAPTURE__.cssAssets. Catches background-images set in stylesheets that the
|
||||
// response listener missed (e.g. @media-gated, ::before, mask-image, list-style-image, cursor).
|
||||
(() => {
|
||||
function isSameOrigin(href) {
|
||||
if (!href) return true;
|
||||
try {
|
||||
const u = new URL(href, location.href);
|
||||
return u.origin === location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUrl(raw, baseHref) {
|
||||
if (!raw) return null;
|
||||
let s = raw.trim();
|
||||
if (s.startsWith('"') || s.startsWith("'")) s = s.slice(1, -1);
|
||||
if (s.startsWith('data:') || s.startsWith('#')) return null;
|
||||
try {
|
||||
return new URL(s, baseHref || location.href).href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function collectFromRules(rules, baseHref, out) {
|
||||
if (!rules) return;
|
||||
for (const rule of rules) {
|
||||
const type = rule.constructor?.name || '';
|
||||
if (type === 'CSSStyleRule') {
|
||||
const text = rule.cssText || '';
|
||||
out.rules.push({ selector: rule.selectorText, cssText: text, baseHref });
|
||||
const urls = text.match(/url\(\s*([^)]+?)\s*\)/g) || [];
|
||||
for (const m of urls) {
|
||||
const inner = m
|
||||
.slice(4, -1)
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
const abs = resolveUrl(inner, baseHref);
|
||||
if (abs && !out.assetSet.has(abs)) {
|
||||
out.assetSet.add(abs);
|
||||
out.assets.push({ source_url: abs, selector: rule.selectorText });
|
||||
}
|
||||
}
|
||||
} else if (type === 'CSSMediaRule' || type === 'CSSSupportsRule' || type === 'CSSContainerRule') {
|
||||
const condition = rule.conditionText || rule.media?.mediaText;
|
||||
out.media.push({ condition, type });
|
||||
collectFromRules(rule.cssRules, baseHref, out);
|
||||
} else if (type === 'CSSImportRule') {
|
||||
// Recurse into imported stylesheet if same-origin and accessible
|
||||
try {
|
||||
const importedHref = rule.styleSheet?.href || baseHref;
|
||||
if (isSameOrigin(importedHref)) {
|
||||
collectFromRules(rule.styleSheet?.cssRules, importedHref, out);
|
||||
}
|
||||
} catch {}
|
||||
} else if (type === 'CSSFontFaceRule') {
|
||||
const text = rule.cssText || '';
|
||||
out.fontFaces.push({ cssText: text, baseHref });
|
||||
const urls = text.match(/url\(\s*([^)]+?)\s*\)/g) || [];
|
||||
for (const m of urls) {
|
||||
const inner = m
|
||||
.slice(4, -1)
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
const abs = resolveUrl(inner, baseHref);
|
||||
if (abs && !out.assetSet.has(abs)) {
|
||||
out.assetSet.add(abs);
|
||||
out.assets.push({ source_url: abs, selector: '@font-face' });
|
||||
}
|
||||
}
|
||||
} else if (type === 'CSSKeyframesRule') {
|
||||
const frames = [];
|
||||
for (const k of rule.cssRules || []) frames.push({ key: k.keyText, cssText: k.style?.cssText || '' });
|
||||
out.keyframes.push({ name: rule.name, frames, baseHref });
|
||||
} else if (rule.cssRules) {
|
||||
collectFromRules(rule.cssRules, baseHref, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collect() {
|
||||
const out = {
|
||||
rules: [],
|
||||
media: [],
|
||||
fontFaces: [],
|
||||
keyframes: [],
|
||||
assets: [],
|
||||
assetSet: new Set(),
|
||||
crossOrigin: 0,
|
||||
};
|
||||
for (const sheet of document.styleSheets) {
|
||||
const baseHref = sheet.href || location.href;
|
||||
let rules;
|
||||
try {
|
||||
rules = sheet.cssRules;
|
||||
} catch {
|
||||
out.crossOrigin += 1;
|
||||
continue;
|
||||
}
|
||||
collectFromRules(rules, baseHref, out);
|
||||
}
|
||||
delete out.assetSet;
|
||||
window.__CLONE_CAPTURE__.cssRules = out;
|
||||
}
|
||||
|
||||
const prevFinalize = window.__CLONE_FINALIZE__;
|
||||
window.__CLONE_FINALIZE__ = function () {
|
||||
if (prevFinalize)
|
||||
try {
|
||||
prevFinalize();
|
||||
} catch {}
|
||||
try {
|
||||
collect();
|
||||
} catch (e) {
|
||||
window.__CLONE_CAPTURE__.cssRules = { error: String(e) };
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,51 @@
|
||||
// Dumps every :root --custom-property and every @property registration into
|
||||
// window.__CLONE_CAPTURE__.cssVars, keyed under { custom, registered }.
|
||||
(() => {
|
||||
function collect() {
|
||||
const custom = {};
|
||||
try {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
for (let i = 0; i < cs.length; i++) {
|
||||
const name = cs[i];
|
||||
if (!name.startsWith('--')) continue;
|
||||
try {
|
||||
custom[name] = cs.getPropertyValue(name).trim();
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const registered = [];
|
||||
try {
|
||||
for (const sheet of document.styleSheets) {
|
||||
let rules;
|
||||
try {
|
||||
rules = sheet.cssRules;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!rules) continue;
|
||||
for (const rule of rules) {
|
||||
if (rule.constructor?.name === 'CSSPropertyRule' || rule.type === 18 /* @property */) {
|
||||
registered.push({
|
||||
name: rule.name,
|
||||
syntax: rule.syntax,
|
||||
initialValue: rule.initialValue,
|
||||
inherits: rule.inherits,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
window.__CLONE_CAPTURE__.cssVars = { custom, registered };
|
||||
}
|
||||
|
||||
const prevFinalize = window.__CLONE_FINALIZE__;
|
||||
window.__CLONE_FINALIZE__ = function () {
|
||||
if (prevFinalize)
|
||||
try {
|
||||
prevFinalize();
|
||||
} catch {}
|
||||
collect();
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,107 @@
|
||||
// Walks the React fiber tree (via __REACT_DEVTOOLS_GLOBAL_HOOK__) to find framer-motion
|
||||
// components and dump their animate/variants/transition/whileInView/whileHover props.
|
||||
// Called from __CLONE_FINALIZE__.
|
||||
(() => {
|
||||
function cssPath(el) {
|
||||
if (!el || el.nodeType !== 1) return null;
|
||||
if (el.id) return `#${el.id}`;
|
||||
const parts = [];
|
||||
while (el && el.nodeType === 1 && parts.length < 10) {
|
||||
let s = el.tagName.toLowerCase();
|
||||
const p = el.parentNode;
|
||||
if (p) {
|
||||
const sibs = Array.from(p.children).filter((c) => c.tagName === el.tagName);
|
||||
if (sibs.length > 1) s += `:nth-of-type(${sibs.indexOf(el) + 1})`;
|
||||
}
|
||||
parts.unshift(s);
|
||||
el = el.parentNode;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
}
|
||||
|
||||
function isMotionComponent(fiber) {
|
||||
const type = fiber?.type;
|
||||
if (!type) return false;
|
||||
if (type.$$typeof && type.render && type.render.displayName?.startsWith('motion.')) return true;
|
||||
const dn = type.displayName || type.name;
|
||||
if (typeof dn === 'string' && (dn.startsWith('motion.') || dn === 'MotionComponent')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function serializeProp(v) {
|
||||
try {
|
||||
if (v == null) return v;
|
||||
if (typeof v === 'function') return null;
|
||||
if (typeof v === 'object') {
|
||||
const out = {};
|
||||
for (const k of Object.keys(v)) {
|
||||
const val = v[k];
|
||||
if (typeof val === 'function') continue;
|
||||
if (val && typeof val === 'object' && val.nodeType) continue;
|
||||
out[k] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findFibers(fiber, out) {
|
||||
if (!fiber) return;
|
||||
if (isMotionComponent(fiber)) {
|
||||
const props = fiber.memoizedProps || {};
|
||||
let node = fiber.stateNode;
|
||||
if (!(node instanceof Element)) {
|
||||
// Walk down to the first DOM host for selector extraction.
|
||||
let cursor = fiber.child;
|
||||
while (cursor) {
|
||||
if (cursor.stateNode instanceof Element) {
|
||||
node = cursor.stateNode;
|
||||
break;
|
||||
}
|
||||
cursor = cursor.child;
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
selector: node instanceof Element ? cssPath(node) : null,
|
||||
initial: serializeProp(props.initial),
|
||||
animate: serializeProp(props.animate),
|
||||
exit: serializeProp(props.exit),
|
||||
variants: serializeProp(props.variants),
|
||||
transition: serializeProp(props.transition),
|
||||
whileHover: serializeProp(props.whileHover),
|
||||
whileTap: serializeProp(props.whileTap),
|
||||
whileInView: serializeProp(props.whileInView),
|
||||
viewport: serializeProp(props.viewport),
|
||||
});
|
||||
}
|
||||
if (fiber.child) findFibers(fiber.child, out);
|
||||
if (fiber.sibling) findFibers(fiber.sibling, out);
|
||||
}
|
||||
|
||||
const prevFinalize = window.__CLONE_FINALIZE__;
|
||||
window.__CLONE_FINALIZE__ = function () {
|
||||
if (prevFinalize)
|
||||
try {
|
||||
prevFinalize();
|
||||
} catch {}
|
||||
try {
|
||||
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
if (!hook || !hook.renderers) return;
|
||||
for (const [, renderer] of hook.renderers) {
|
||||
if (!renderer) continue;
|
||||
const roots = renderer.findFiberByHostInstance ? null : renderer.roots || null;
|
||||
const scanFrom = [];
|
||||
if (hook.getFiberRoots) {
|
||||
const rootSet = hook.getFiberRoots(1) || new Set();
|
||||
for (const r of rootSet) scanFrom.push(r.current);
|
||||
}
|
||||
for (const fiber of scanFrom) findFibers(fiber, window.__CLONE_CAPTURE__.framer);
|
||||
}
|
||||
} catch (e) {
|
||||
window.__CLONE_CAPTURE__.framer.push({ error: String(e) });
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,103 @@
|
||||
// Dumps GSAP timelines + tweens into window.__CLONE_CAPTURE__.gsap after load.
|
||||
// Runs on __CLONE_FINALIZE__ so that page-load animations have had time to register.
|
||||
(() => {
|
||||
function cssPath(el) {
|
||||
if (!el || el.nodeType !== 1) return null;
|
||||
if (el.id) return `#${el.id}`;
|
||||
const parts = [];
|
||||
while (el && el.nodeType === 1 && parts.length < 10) {
|
||||
let s = el.tagName.toLowerCase();
|
||||
const p = el.parentNode;
|
||||
if (p) {
|
||||
const sibs = Array.from(p.children).filter((c) => c.tagName === el.tagName);
|
||||
if (sibs.length > 1) s += `:nth-of-type(${sibs.indexOf(el) + 1})`;
|
||||
}
|
||||
parts.unshift(s);
|
||||
el = el.parentNode;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
}
|
||||
|
||||
function targetsToSelectors(targets) {
|
||||
if (!targets) return [];
|
||||
if (targets instanceof Element) return [cssPath(targets)];
|
||||
if (Array.isArray(targets) || targets.length != null) {
|
||||
const out = [];
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
const t = targets[i];
|
||||
if (t instanceof Element) out.push(cssPath(t));
|
||||
else if (typeof t === 'string') out.push(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (typeof targets === 'string') return [targets];
|
||||
return [];
|
||||
}
|
||||
|
||||
function serializeEase(ease) {
|
||||
if (!ease) return null;
|
||||
if (typeof ease === 'string') return ease;
|
||||
if (typeof ease === 'function') return ease.name || ease.toString().slice(0, 40);
|
||||
return String(ease);
|
||||
}
|
||||
|
||||
function serializeTween(t) {
|
||||
const vars = {};
|
||||
if (t.vars) {
|
||||
for (const k of Object.keys(t.vars)) {
|
||||
if (['onComplete', 'onStart', 'onUpdate', 'onRepeat', 'scrollTrigger'].includes(k)) continue;
|
||||
try {
|
||||
const v = t.vars[k];
|
||||
if (typeof v === 'function') continue;
|
||||
if (v && typeof v === 'object' && v.nodeType) continue;
|
||||
vars[k] = v;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'tween',
|
||||
targets: targetsToSelectors(t._targets || t.targets?.()),
|
||||
duration: t.duration?.() ?? t._duration,
|
||||
ease: serializeEase(t.vars?.ease),
|
||||
vars,
|
||||
scrollTrigger: t.vars?.scrollTrigger
|
||||
? {
|
||||
trigger:
|
||||
t.vars.scrollTrigger.trigger instanceof Element
|
||||
? cssPath(t.vars.scrollTrigger.trigger)
|
||||
: t.vars.scrollTrigger.trigger,
|
||||
start: t.vars.scrollTrigger.start,
|
||||
end: t.vars.scrollTrigger.end,
|
||||
scrub: t.vars.scrollTrigger.scrub,
|
||||
pin: t.vars.scrollTrigger.pin,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function walk(tl) {
|
||||
const out = { type: 'timeline', duration: tl.duration?.(), children: [] };
|
||||
const kids = tl.getChildren ? tl.getChildren(true, true, true) : [];
|
||||
for (const c of kids) {
|
||||
if (c.getChildren) out.children.push(walk(c));
|
||||
else out.children.push(serializeTween(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const prevFinalize = window.__CLONE_FINALIZE__;
|
||||
window.__CLONE_FINALIZE__ = function () {
|
||||
if (prevFinalize)
|
||||
try {
|
||||
prevFinalize();
|
||||
} catch {}
|
||||
try {
|
||||
if (!window.gsap) return;
|
||||
const root = window.gsap.globalTimeline;
|
||||
if (!root) return;
|
||||
window.__CLONE_CAPTURE__.gsap.push(walk(root));
|
||||
} catch (e) {
|
||||
window.__CLONE_CAPTURE__.gsap.push({ error: String(e) });
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,47 @@
|
||||
// Pulls registered Lottie animations + their animationData into window.__CLONE_CAPTURE__.lottie.
|
||||
// Called from __CLONE_FINALIZE__. Supports lottie-web directly; lottie-react uses lottie-web internally.
|
||||
(() => {
|
||||
function cssPath(el) {
|
||||
if (!el || el.nodeType !== 1) return null;
|
||||
if (el.id) return `#${el.id}`;
|
||||
const parts = [];
|
||||
while (el && el.nodeType === 1 && parts.length < 10) {
|
||||
let s = el.tagName.toLowerCase();
|
||||
const p = el.parentNode;
|
||||
if (p) {
|
||||
const sibs = Array.from(p.children).filter((c) => c.tagName === el.tagName);
|
||||
if (sibs.length > 1) s += `:nth-of-type(${sibs.indexOf(el) + 1})`;
|
||||
}
|
||||
parts.unshift(s);
|
||||
el = el.parentNode;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
}
|
||||
|
||||
const prevFinalize = window.__CLONE_FINALIZE__;
|
||||
window.__CLONE_FINALIZE__ = function () {
|
||||
if (prevFinalize)
|
||||
try {
|
||||
prevFinalize();
|
||||
} catch {}
|
||||
try {
|
||||
const lottie = window.lottie || window.bodymovin;
|
||||
if (!lottie || typeof lottie.getRegisteredAnimations !== 'function') return;
|
||||
const anims = lottie.getRegisteredAnimations();
|
||||
for (const a of anims) {
|
||||
try {
|
||||
window.__CLONE_CAPTURE__.lottie.push({
|
||||
selector: a.wrapper instanceof Element ? cssPath(a.wrapper) : null,
|
||||
renderer: a.renderer?.name || 'unknown',
|
||||
animationData: a.animationData ? JSON.parse(JSON.stringify(a.animationData)) : null,
|
||||
loop: a.loop,
|
||||
autoplay: a.autoplay,
|
||||
name: a.name,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
window.__CLONE_CAPTURE__.lottie.push({ error: String(e) });
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,147 @@
|
||||
// Exposes window.__CLONE_LIST_SECTIONS__() which returns a list of candidate top-level
|
||||
// section bounding boxes for the capture script's scroll planner. We emit one entry per
|
||||
// large flex/block child of <body>, <main>, or any element classed as section/container/etc.
|
||||
// The capture script uses these to scroll-to-section instead of fixed-pixel stepping.
|
||||
(() => {
|
||||
const SECTIONLIKE_TAGS = new Set(['section', 'header', 'footer', 'nav', 'main', 'article', 'aside']);
|
||||
const SECTIONLIKE_CLASS_RE =
|
||||
/(^|\s)(section|hero|container|banner|elementor-section|elementor-element-[^\s]+|e-con|e-parent|wp-block-[^\s]+)(\s|$)/i;
|
||||
|
||||
function isViewportWide(el, vw) {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width >= vw * 0.5 && r.height >= 80;
|
||||
}
|
||||
|
||||
function isVisible(el) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
|
||||
if (parseFloat(cs.opacity) === 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function looksLikeSection(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SECTIONLIKE_TAGS.has(tag)) return true;
|
||||
if (el.id && /(hero|banner|section|footer|nav|header)/i.test(el.id)) return true;
|
||||
if (typeof el.className === 'string' && SECTIONLIKE_CLASS_RE.test(el.className)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function cssPath(el) {
|
||||
if (!el || el.nodeType !== 1) return null;
|
||||
if (el.id) return `#${el.id}`;
|
||||
const parts = [];
|
||||
while (el && el.nodeType === 1 && parts.length < 8) {
|
||||
let s = el.tagName.toLowerCase();
|
||||
const p = el.parentNode;
|
||||
if (p && p.nodeType === 1) {
|
||||
const sibs = Array.from(p.children).filter((c) => c.tagName === el.tagName);
|
||||
if (sibs.length > 1) s += `:nth-of-type(${sibs.indexOf(el) + 1})`;
|
||||
}
|
||||
parts.unshift(s);
|
||||
el = el.parentNode;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
}
|
||||
|
||||
const SECTION_QUERY =
|
||||
'section, header, footer, nav, main, article, ' +
|
||||
'[class*="section"], [class*="elementor-element"], [class*="e-parent"], [class*="e-con"], ' +
|
||||
'[class*="hero"], [class*="banner"], [id*="section"], [id*="hero"], [id*="banner"]';
|
||||
|
||||
function snapshotEntry(el, vw) {
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
element: el,
|
||||
selector: cssPath(el),
|
||||
tag: el.tagName.toLowerCase(),
|
||||
id: el.id || null,
|
||||
className: typeof el.className === 'string' ? el.className.slice(0, 200) : null,
|
||||
x: r.x,
|
||||
y: r.y + window.scrollY,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
looksLikeSection: looksLikeSection(el),
|
||||
};
|
||||
}
|
||||
|
||||
function collectInScope(scope, vw, out, candidates) {
|
||||
for (const el of candidates) {
|
||||
if (!scope.contains(el) || el === scope) continue;
|
||||
if (!isVisible(el) || !isViewportWide(el, vw)) continue;
|
||||
// Outermost-wins inside this scope
|
||||
let skip = false;
|
||||
for (const o of out) {
|
||||
if (o.element.contains(el)) {
|
||||
skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (skip) continue;
|
||||
for (let i = out.length - 1; i >= 0; i--) {
|
||||
if (el.contains(out[i].element)) out.splice(i, 1);
|
||||
}
|
||||
out.push(snapshotEntry(el, vw));
|
||||
}
|
||||
}
|
||||
|
||||
window.__CLONE_LIST_SECTIONS__ = () => {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const docHeight = Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0);
|
||||
|
||||
// Pass 1: known section-like elements
|
||||
const candidates = Array.from(document.querySelectorAll(SECTION_QUERY));
|
||||
|
||||
// Pass 2: large direct children of body/main as a fallback
|
||||
const root = document.querySelector('main') || document.body;
|
||||
if (root) {
|
||||
for (const child of root.children) {
|
||||
if (!candidates.includes(child)) candidates.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
// First-pass dedup with outermost-wins
|
||||
const out = [];
|
||||
for (const el of candidates) {
|
||||
if (!isVisible(el) || !isViewportWide(el, vw)) continue;
|
||||
let skip = false;
|
||||
for (const o of out) {
|
||||
if (o.element.contains(el)) {
|
||||
skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (skip) continue;
|
||||
for (let i = out.length - 1; i >= 0; i--) {
|
||||
if (el.contains(out[i].element)) out.splice(i, 1);
|
||||
}
|
||||
out.push(snapshotEntry(el, vw));
|
||||
}
|
||||
|
||||
// Pass 3: oversized-wrapper expansion. WordPress / Elementor sites commonly
|
||||
// wrap the whole page in <main id="main">. Without this step the outer
|
||||
// wrapper wins and individual sections are dropped, so the smart-scroll
|
||||
// loop never visits them and lazy backgrounds never fire.
|
||||
const oversizedThreshold = Math.max(vh * 2, docHeight * 0.5);
|
||||
const expanded = [];
|
||||
for (const sec of out) {
|
||||
if (sec.height < oversizedThreshold) {
|
||||
expanded.push(sec);
|
||||
continue;
|
||||
}
|
||||
const inner = [];
|
||||
const innerCandidates = Array.from(sec.element.querySelectorAll(SECTION_QUERY));
|
||||
collectInScope(sec.element, vw, inner, innerCandidates);
|
||||
// Only replace the wrapper if recursion produced enough sections to be
|
||||
// meaningful — otherwise this is a genuine large section, not a wrapper.
|
||||
if (inner.length >= 3) expanded.push(...inner);
|
||||
else expanded.push(sec);
|
||||
}
|
||||
|
||||
return expanded
|
||||
.filter((s) => s.height > 80)
|
||||
.sort((a, b) => a.y - b.y)
|
||||
.map(({ element, ...rest }) => rest);
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,97 @@
|
||||
// Extract GLSL from WebGL1 + WebGL2 by intercepting shaderSource / compileShader / linkProgram / useProgram.
|
||||
// Stashes into window.__CLONE_CAPTURE__.shaders as
|
||||
// { programId, vertex, fragment, attributes, uniforms, canvasSelector }
|
||||
(() => {
|
||||
const CAPTURE = () => window.__CLONE_CAPTURE__;
|
||||
|
||||
const shaderMap = new WeakMap(); // WebGLShader -> { type, source }
|
||||
const programMap = new WeakMap(); // WebGLProgram -> { vertex, fragment }
|
||||
let nextProgramId = 1;
|
||||
const contextCanvas = new WeakMap(); // WebGL(2)RenderingContext -> canvas
|
||||
|
||||
function patch(ctxProto, isGL2) {
|
||||
const origShaderSource = ctxProto.shaderSource;
|
||||
ctxProto.shaderSource = function (shader, source) {
|
||||
shaderMap.set(shader, { source, type: this.getShaderParameter?.(shader, this.SHADER_TYPE) });
|
||||
return origShaderSource.call(this, shader, source);
|
||||
};
|
||||
|
||||
const origAttach = ctxProto.attachShader;
|
||||
ctxProto.attachShader = function (program, shader) {
|
||||
const info = shaderMap.get(shader);
|
||||
const t = this.getShaderParameter(shader, this.SHADER_TYPE);
|
||||
const entry = programMap.get(program) || {};
|
||||
if (t === this.VERTEX_SHADER) entry.vertex = info?.source || '';
|
||||
else if (t === this.FRAGMENT_SHADER) entry.fragment = info?.source || '';
|
||||
programMap.set(program, entry);
|
||||
return origAttach.call(this, program, shader);
|
||||
};
|
||||
|
||||
const origLink = ctxProto.linkProgram;
|
||||
ctxProto.linkProgram = function (program) {
|
||||
const res = origLink.call(this, program);
|
||||
const entry = programMap.get(program) || {};
|
||||
if (!entry.id) entry.id = nextProgramId++;
|
||||
const canvas = contextCanvas.get(this);
|
||||
const sel = canvas ? cssPath(canvas) : null;
|
||||
const attributes = [];
|
||||
const uniforms = [];
|
||||
try {
|
||||
const aCount = this.getProgramParameter(program, this.ACTIVE_ATTRIBUTES);
|
||||
for (let i = 0; i < aCount; i++) {
|
||||
const info = this.getActiveAttrib(program, i);
|
||||
if (info) attributes.push({ name: info.name, type: info.type, size: info.size });
|
||||
}
|
||||
const uCount = this.getProgramParameter(program, this.ACTIVE_UNIFORMS);
|
||||
for (let i = 0; i < uCount; i++) {
|
||||
const info = this.getActiveUniform(program, i);
|
||||
if (info) uniforms.push({ name: info.name, type: info.type, size: info.size });
|
||||
}
|
||||
} catch {}
|
||||
CAPTURE().shaders.push({
|
||||
programId: entry.id,
|
||||
vertex: entry.vertex || '',
|
||||
fragment: entry.fragment || '',
|
||||
attributes,
|
||||
uniforms,
|
||||
canvasSelector: sel,
|
||||
isWebGL2: isGL2,
|
||||
});
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
// Intercept getContext to remember which canvas owns which WebGL context.
|
||||
const origGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function (type, ...rest) {
|
||||
const ctx = origGetContext.call(this, type, ...rest);
|
||||
if (ctx && (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl')) {
|
||||
contextCanvas.set(ctx, this);
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
if (typeof WebGLRenderingContext !== 'undefined') patch(WebGLRenderingContext.prototype, false);
|
||||
if (typeof WebGL2RenderingContext !== 'undefined') patch(WebGL2RenderingContext.prototype, true);
|
||||
|
||||
function cssPath(el) {
|
||||
if (!el) return null;
|
||||
const parts = [];
|
||||
while (el && el.nodeType === 1 && parts.length < 10) {
|
||||
let s = el.tagName.toLowerCase();
|
||||
if (el.id) {
|
||||
s += '#' + el.id;
|
||||
parts.unshift(s);
|
||||
break;
|
||||
}
|
||||
const parent = el.parentNode;
|
||||
if (parent) {
|
||||
const sibs = Array.from(parent.children).filter((c) => c.tagName === el.tagName);
|
||||
if (sibs.length > 1) s += `:nth-of-type(${sibs.indexOf(el) + 1})`;
|
||||
}
|
||||
parts.unshift(s);
|
||||
el = el.parentNode;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,130 @@
|
||||
// Patches THREE.WebGLRenderer to track its canvas + scene root, then serializes the scene graph
|
||||
// at __CLONE_FINALIZE__. Records geometry types, material types + uniforms, lights, cameras,
|
||||
// and per-object position/rotation/scale.
|
||||
(() => {
|
||||
const renderers = new Set();
|
||||
const rendererToScene = new WeakMap();
|
||||
|
||||
function cssPath(el) {
|
||||
if (!el || el.nodeType !== 1) return null;
|
||||
if (el.id) return `#${el.id}`;
|
||||
const parts = [];
|
||||
while (el && el.nodeType === 1 && parts.length < 10) {
|
||||
let s = el.tagName.toLowerCase();
|
||||
const p = el.parentNode;
|
||||
if (p) {
|
||||
const sibs = Array.from(p.children).filter((c) => c.tagName === el.tagName);
|
||||
if (sibs.length > 1) s += `:nth-of-type(${sibs.indexOf(el) + 1})`;
|
||||
}
|
||||
parts.unshift(s);
|
||||
el = el.parentNode;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
}
|
||||
|
||||
function patchWhenReady() {
|
||||
if (!window.THREE || !window.THREE.WebGLRenderer) return false;
|
||||
const WR = window.THREE.WebGLRenderer;
|
||||
const origRender = WR.prototype.render;
|
||||
WR.prototype.render = function (scene, camera) {
|
||||
renderers.add(this);
|
||||
if (scene) rendererToScene.set(this, { scene, camera });
|
||||
return origRender.call(this, scene, camera);
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!patchWhenReady()) {
|
||||
const timer = setInterval(() => {
|
||||
if (patchWhenReady()) clearInterval(timer);
|
||||
}, 200);
|
||||
setTimeout(() => clearInterval(timer), 10_000);
|
||||
}
|
||||
|
||||
function serializeObject(obj) {
|
||||
const out = {
|
||||
type: obj.type || obj.constructor?.name || 'Object3D',
|
||||
name: obj.name || undefined,
|
||||
position: obj.position ? [obj.position.x, obj.position.y, obj.position.z] : undefined,
|
||||
rotation: obj.rotation ? [obj.rotation.x, obj.rotation.y, obj.rotation.z] : undefined,
|
||||
scale: obj.scale ? [obj.scale.x, obj.scale.y, obj.scale.z] : undefined,
|
||||
visible: obj.visible,
|
||||
children: [],
|
||||
};
|
||||
if (obj.geometry) {
|
||||
out.geometry = {
|
||||
type: obj.geometry.type,
|
||||
parameters: obj.geometry.parameters ? { ...obj.geometry.parameters } : undefined,
|
||||
};
|
||||
}
|
||||
if (obj.material) {
|
||||
const m = Array.isArray(obj.material) ? obj.material[0] : obj.material;
|
||||
out.material = {
|
||||
type: m.type,
|
||||
color: m.color ? m.color.getHex?.().toString(16) : undefined,
|
||||
opacity: m.opacity,
|
||||
transparent: m.transparent,
|
||||
uniforms: m.uniforms ? summarizeUniforms(m.uniforms) : undefined,
|
||||
vertexShader: m.vertexShader?.slice(0, 4000),
|
||||
fragmentShader: m.fragmentShader?.slice(0, 4000),
|
||||
};
|
||||
}
|
||||
if (obj.isLight) {
|
||||
out.light = {
|
||||
type: obj.type,
|
||||
color: obj.color?.getHex?.()?.toString(16),
|
||||
intensity: obj.intensity,
|
||||
};
|
||||
}
|
||||
if (obj.isCamera) {
|
||||
out.camera = {
|
||||
type: obj.type,
|
||||
fov: obj.fov,
|
||||
aspect: obj.aspect,
|
||||
near: obj.near,
|
||||
far: obj.far,
|
||||
};
|
||||
}
|
||||
if (obj.children?.length) {
|
||||
out.children = obj.children.map(serializeObject);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function summarizeUniforms(u) {
|
||||
const out = {};
|
||||
for (const k of Object.keys(u)) {
|
||||
try {
|
||||
const v = u[k]?.value;
|
||||
if (v == null) continue;
|
||||
if (typeof v === 'number') out[k] = v;
|
||||
else if (Array.isArray(v)) out[k] = v.slice(0, 16);
|
||||
else if (v.x != null && v.y != null) out[k] = [v.x, v.y, v.z ?? 0, v.w ?? 0];
|
||||
else out[k] = String(v).slice(0, 80);
|
||||
} catch {}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const prevFinalize = window.__CLONE_FINALIZE__;
|
||||
window.__CLONE_FINALIZE__ = function () {
|
||||
if (prevFinalize)
|
||||
try {
|
||||
prevFinalize();
|
||||
} catch {}
|
||||
try {
|
||||
for (const r of renderers) {
|
||||
const { scene, camera } = rendererToScene.get(r) || {};
|
||||
const dom = r.domElement;
|
||||
window.__CLONE_CAPTURE__.threejs.push({
|
||||
canvasSelector: dom instanceof Element ? cssPath(dom) : null,
|
||||
size: dom ? [dom.width, dom.height] : null,
|
||||
scene: scene ? serializeObject(scene) : null,
|
||||
camera: camera ? serializeObject(camera) : null,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
window.__CLONE_CAPTURE__.threejs.push({ error: String(e) });
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,7 @@
|
||||
playwright>=1.40
|
||||
httpx>=0.25
|
||||
tinycss2>=1.2
|
||||
pixelmatch>=0.3
|
||||
pillow>=10
|
||||
numpy>=1.26
|
||||
scipy>=1.11
|
||||
@@ -0,0 +1,632 @@
|
||||
---
|
||||
name: agent-browser
|
||||
description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx agent-browser:*), Bash(agent-browser:*)
|
||||
---
|
||||
|
||||
# Browser Automation with agent-browser
|
||||
|
||||
## Core Workflow
|
||||
|
||||
Every browser automation follows this pattern:
|
||||
|
||||
1. **Navigate**: `agent-browser open <url>`
|
||||
2. **Snapshot**: `agent-browser snapshot -i` (get element refs like `@e1`, `@e2`)
|
||||
3. **Interact**: Use refs to click, fill, select
|
||||
4. **Re-snapshot**: After navigation or DOM changes, get fresh refs
|
||||
|
||||
```bash
|
||||
agent-browser open https://example.com/form
|
||||
agent-browser snapshot -i
|
||||
# Output: @e1 [input type="email"], @e2 [input type="password"], @e3 [button] "Submit"
|
||||
|
||||
agent-browser fill @e1 "user@example.com"
|
||||
agent-browser fill @e2 "password123"
|
||||
agent-browser click @e3
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser snapshot -i # Check result
|
||||
```
|
||||
|
||||
## Command Chaining
|
||||
|
||||
Commands can be chained with `&&` in a single shell invocation. The browser persists between commands via a background daemon, so chaining is safe and more efficient than separate calls.
|
||||
|
||||
```bash
|
||||
# Chain open + wait + snapshot in one call
|
||||
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
|
||||
|
||||
# Chain multiple interactions
|
||||
agent-browser fill @e1 "user@example.com" && agent-browser fill @e2 "password123" && agent-browser click @e3
|
||||
|
||||
# Navigate and capture
|
||||
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser screenshot page.png
|
||||
```
|
||||
|
||||
**When to chain:** Use `&&` when you don't need to read the output of an intermediate command before proceeding (e.g., open + wait + screenshot). Run commands separately when you need to parse the output first (e.g., snapshot to discover refs, then interact using those refs).
|
||||
|
||||
## Handling Authentication
|
||||
|
||||
When automating a site that requires login, choose the approach that fits:
|
||||
|
||||
**Option 1: Import auth from the user's browser (fastest for one-off tasks)**
|
||||
|
||||
```bash
|
||||
# Connect to the user's running Chrome (they're already logged in)
|
||||
agent-browser --auto-connect state save ./auth.json
|
||||
# Use that auth state
|
||||
agent-browser --state ./auth.json open https://app.example.com/dashboard
|
||||
```
|
||||
|
||||
State files contain session tokens in plaintext -- add to `.gitignore` and delete when no longer needed. Set `AGENT_BROWSER_ENCRYPTION_KEY` for encryption at rest.
|
||||
|
||||
**Option 2: Persistent profile (simplest for recurring tasks)**
|
||||
|
||||
```bash
|
||||
# First run: login manually or via automation
|
||||
agent-browser --profile ~/.myapp open https://app.example.com/login
|
||||
# ... fill credentials, submit ...
|
||||
|
||||
# All future runs: already authenticated
|
||||
agent-browser --profile ~/.myapp open https://app.example.com/dashboard
|
||||
```
|
||||
|
||||
**Option 3: Session name (auto-save/restore cookies + localStorage)**
|
||||
|
||||
```bash
|
||||
agent-browser --session-name myapp open https://app.example.com/login
|
||||
# ... login flow ...
|
||||
agent-browser close # State auto-saved
|
||||
|
||||
# Next time: state auto-restored
|
||||
agent-browser --session-name myapp open https://app.example.com/dashboard
|
||||
```
|
||||
|
||||
**Option 4: Auth vault (credentials stored encrypted, login by name)**
|
||||
|
||||
```bash
|
||||
echo "$PASSWORD" | agent-browser auth save myapp --url https://app.example.com/login --username user --password-stdin
|
||||
agent-browser auth login myapp
|
||||
```
|
||||
|
||||
**Option 5: State file (manual save/load)**
|
||||
|
||||
```bash
|
||||
# After logging in:
|
||||
agent-browser state save ./auth.json
|
||||
# In a future session:
|
||||
agent-browser state load ./auth.json
|
||||
agent-browser open https://app.example.com/dashboard
|
||||
```
|
||||
|
||||
See [references/authentication.md](references/authentication.md) for OAuth, 2FA, cookie-based auth, and token refresh patterns.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
# Navigation
|
||||
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
||||
agent-browser close # Close browser
|
||||
|
||||
# Snapshot
|
||||
agent-browser snapshot -i # Interactive elements with refs (recommended)
|
||||
agent-browser snapshot -i -C # Include cursor-interactive elements (divs with onclick, cursor:pointer)
|
||||
agent-browser snapshot -s "#selector" # Scope to CSS selector
|
||||
|
||||
# Interaction (use @refs from snapshot)
|
||||
agent-browser click @e1 # Click element
|
||||
agent-browser click @e1 --new-tab # Click and open in new tab
|
||||
agent-browser fill @e2 "text" # Clear and type text
|
||||
agent-browser type @e2 "text" # Type without clearing
|
||||
agent-browser select @e1 "option" # Select dropdown option
|
||||
agent-browser check @e1 # Check checkbox
|
||||
agent-browser press Enter # Press key
|
||||
agent-browser keyboard type "text" # Type at current focus (no selector)
|
||||
agent-browser keyboard inserttext "text" # Insert without key events
|
||||
agent-browser scroll down 500 # Scroll page
|
||||
agent-browser scroll down 500 --selector "div.content" # Scroll within a specific container
|
||||
|
||||
# Get information
|
||||
agent-browser get text @e1 # Get element text
|
||||
agent-browser get url # Get current URL
|
||||
agent-browser get title # Get page title
|
||||
agent-browser get cdp-url # Get CDP WebSocket URL
|
||||
|
||||
# Wait
|
||||
agent-browser wait @e1 # Wait for element
|
||||
agent-browser wait --load networkidle # Wait for network idle
|
||||
agent-browser wait --url "**/page" # Wait for URL pattern
|
||||
agent-browser wait 2000 # Wait milliseconds
|
||||
|
||||
# Downloads
|
||||
agent-browser download @e1 ./file.pdf # Click element to trigger download
|
||||
agent-browser wait --download ./output.zip # Wait for any download to complete
|
||||
agent-browser --download-path ./downloads open <url> # Set default download directory
|
||||
|
||||
# Viewport & Device Emulation
|
||||
agent-browser set viewport 1920 1080 # Set viewport size (default: 1280x720)
|
||||
agent-browser set viewport 1920 1080 2 # 2x retina (same CSS size, higher res screenshots)
|
||||
agent-browser set device "iPhone 14" # Emulate device (viewport + user agent)
|
||||
|
||||
# Capture
|
||||
agent-browser screenshot # Screenshot to temp dir
|
||||
agent-browser screenshot --full # Full page screenshot (AVOID: can exceed 8000px API image limit — use scroll + multiple viewport screenshots instead)
|
||||
agent-browser screenshot --annotate # Annotated screenshot with numbered element labels
|
||||
agent-browser pdf output.pdf # Save as PDF
|
||||
|
||||
# Diff (compare page states)
|
||||
agent-browser diff snapshot # Compare current vs last snapshot
|
||||
agent-browser diff snapshot --baseline before.txt # Compare current vs saved file
|
||||
agent-browser diff screenshot --baseline before.png # Visual pixel diff
|
||||
agent-browser diff url <url1> <url2> # Compare two pages
|
||||
agent-browser diff url <url1> <url2> --wait-until networkidle # Custom wait strategy
|
||||
agent-browser diff url <url1> <url2> --selector "#main" # Scope to element
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Form Submission
|
||||
|
||||
```bash
|
||||
agent-browser open https://example.com/signup
|
||||
agent-browser snapshot -i
|
||||
agent-browser fill @e1 "Jane Doe"
|
||||
agent-browser fill @e2 "jane@example.com"
|
||||
agent-browser select @e3 "California"
|
||||
agent-browser check @e4
|
||||
agent-browser click @e5
|
||||
agent-browser wait --load networkidle
|
||||
```
|
||||
|
||||
### Authentication with Auth Vault (Recommended)
|
||||
|
||||
```bash
|
||||
# Save credentials once (encrypted with AGENT_BROWSER_ENCRYPTION_KEY)
|
||||
# Recommended: pipe password via stdin to avoid shell history exposure
|
||||
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
|
||||
|
||||
# Login using saved profile (LLM never sees password)
|
||||
agent-browser auth login github
|
||||
|
||||
# List/show/delete profiles
|
||||
agent-browser auth list
|
||||
agent-browser auth show github
|
||||
agent-browser auth delete github
|
||||
```
|
||||
|
||||
### Authentication with State Persistence
|
||||
|
||||
```bash
|
||||
# Login once and save state
|
||||
agent-browser open https://app.example.com/login
|
||||
agent-browser snapshot -i
|
||||
agent-browser fill @e1 "$USERNAME"
|
||||
agent-browser fill @e2 "$PASSWORD"
|
||||
agent-browser click @e3
|
||||
agent-browser wait --url "**/dashboard"
|
||||
agent-browser state save auth.json
|
||||
|
||||
# Reuse in future sessions
|
||||
agent-browser state load auth.json
|
||||
agent-browser open https://app.example.com/dashboard
|
||||
```
|
||||
|
||||
### Session Persistence
|
||||
|
||||
```bash
|
||||
# Auto-save/restore cookies and localStorage across browser restarts
|
||||
agent-browser --session-name myapp open https://app.example.com/login
|
||||
# ... login flow ...
|
||||
agent-browser close # State auto-saved to ~/.agent-browser/sessions/
|
||||
|
||||
# Next time, state is auto-loaded
|
||||
agent-browser --session-name myapp open https://app.example.com/dashboard
|
||||
|
||||
# Encrypt state at rest
|
||||
export AGENT_BROWSER_ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
agent-browser --session-name secure open https://app.example.com
|
||||
|
||||
# Manage saved states
|
||||
agent-browser state list
|
||||
agent-browser state show myapp-default.json
|
||||
agent-browser state clear myapp
|
||||
agent-browser state clean --older-than 7
|
||||
```
|
||||
|
||||
### Data Extraction
|
||||
|
||||
```bash
|
||||
agent-browser open https://example.com/products
|
||||
agent-browser snapshot -i
|
||||
agent-browser get text @e5 # Get specific element text
|
||||
agent-browser get text body > page.txt # Get all page text
|
||||
|
||||
# JSON output for parsing
|
||||
agent-browser snapshot -i --json
|
||||
agent-browser get text @e1 --json
|
||||
```
|
||||
|
||||
### Parallel Sessions
|
||||
|
||||
```bash
|
||||
agent-browser --session site1 open https://site-a.com
|
||||
agent-browser --session site2 open https://site-b.com
|
||||
|
||||
agent-browser --session site1 snapshot -i
|
||||
agent-browser --session site2 snapshot -i
|
||||
|
||||
agent-browser session list
|
||||
```
|
||||
|
||||
### Connect to Existing Chrome
|
||||
|
||||
```bash
|
||||
# Auto-discover running Chrome with remote debugging enabled
|
||||
agent-browser --auto-connect open https://example.com
|
||||
agent-browser --auto-connect snapshot
|
||||
|
||||
# Or with explicit CDP port
|
||||
agent-browser --cdp 9222 snapshot
|
||||
```
|
||||
|
||||
### Color Scheme (Dark Mode)
|
||||
|
||||
```bash
|
||||
# Persistent dark mode via flag (applies to all pages and new tabs)
|
||||
agent-browser --color-scheme dark open https://example.com
|
||||
|
||||
# Or via environment variable
|
||||
AGENT_BROWSER_COLOR_SCHEME=dark agent-browser open https://example.com
|
||||
|
||||
# Or set during session (persists for subsequent commands)
|
||||
agent-browser set media dark
|
||||
```
|
||||
|
||||
### Viewport & Responsive Testing
|
||||
|
||||
```bash
|
||||
# Set a custom viewport size (default is 1280x720)
|
||||
agent-browser set viewport 1920 1080
|
||||
agent-browser screenshot desktop.png
|
||||
|
||||
# Test mobile-width layout
|
||||
agent-browser set viewport 375 812
|
||||
agent-browser screenshot mobile.png
|
||||
|
||||
# Retina/HiDPI: same CSS layout at 2x pixel density
|
||||
# Screenshots stay at logical viewport size, but content renders at higher DPI
|
||||
agent-browser set viewport 1920 1080 2
|
||||
agent-browser screenshot retina.png
|
||||
|
||||
# Device emulation (sets viewport + user agent in one step)
|
||||
agent-browser set device "iPhone 14"
|
||||
agent-browser screenshot device.png
|
||||
```
|
||||
|
||||
The `scale` parameter (3rd argument) sets `window.devicePixelRatio` without changing CSS layout. Use it when testing retina rendering or capturing higher-resolution screenshots.
|
||||
|
||||
### Visual Browser (Debugging)
|
||||
|
||||
```bash
|
||||
agent-browser --headed open https://example.com
|
||||
agent-browser highlight @e1 # Highlight element
|
||||
agent-browser inspect # Open Chrome DevTools for the active page
|
||||
agent-browser record start demo.webm # Record session
|
||||
agent-browser profiler start # Start Chrome DevTools profiling
|
||||
agent-browser profiler stop trace.json # Stop and save profile (path optional)
|
||||
```
|
||||
|
||||
Use `AGENT_BROWSER_HEADED=1` to enable headed mode via environment variable. Browser extensions work in both headed and headless mode.
|
||||
|
||||
### Local Files (PDFs, HTML)
|
||||
|
||||
```bash
|
||||
# Open local files with file:// URLs
|
||||
agent-browser --allow-file-access open file:///path/to/document.pdf
|
||||
agent-browser --allow-file-access open file:///path/to/page.html
|
||||
agent-browser screenshot output.png
|
||||
```
|
||||
|
||||
### iOS Simulator (Mobile Safari)
|
||||
|
||||
```bash
|
||||
# List available iOS simulators
|
||||
agent-browser device list
|
||||
|
||||
# Launch Safari on a specific device
|
||||
agent-browser -p ios --device "iPhone 16 Pro" open https://example.com
|
||||
|
||||
# Same workflow as desktop - snapshot, interact, re-snapshot
|
||||
agent-browser -p ios snapshot -i
|
||||
agent-browser -p ios tap @e1 # Tap (alias for click)
|
||||
agent-browser -p ios fill @e2 "text"
|
||||
agent-browser -p ios swipe up # Mobile-specific gesture
|
||||
|
||||
# Take screenshot
|
||||
agent-browser -p ios screenshot mobile.png
|
||||
|
||||
# Close session (shuts down simulator)
|
||||
agent-browser -p ios close
|
||||
```
|
||||
|
||||
**Requirements:** macOS with Xcode, Appium (`npm install -g appium && appium driver install xcuitest`)
|
||||
|
||||
**Real devices:** Works with physical iOS devices if pre-configured. Use `--device "<UDID>"` where UDID is from `xcrun xctrace list devices`.
|
||||
|
||||
## Security
|
||||
|
||||
All security features are opt-in. By default, agent-browser imposes no restrictions on navigation, actions, or output.
|
||||
|
||||
### Content Boundaries (Recommended for AI Agents)
|
||||
|
||||
Enable `--content-boundaries` to wrap page-sourced output in markers that help LLMs distinguish tool output from untrusted page content:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_CONTENT_BOUNDARIES=1
|
||||
agent-browser snapshot
|
||||
# Output:
|
||||
# --- AGENT_BROWSER_PAGE_CONTENT nonce=<hex> origin=https://example.com ---
|
||||
# [accessibility tree]
|
||||
# --- END_AGENT_BROWSER_PAGE_CONTENT nonce=<hex> ---
|
||||
```
|
||||
|
||||
### Domain Allowlist
|
||||
|
||||
Restrict navigation to trusted domains. Wildcards like `*.example.com` also match the bare domain `example.com`. Sub-resource requests, WebSocket, and EventSource connections to non-allowed domains are also blocked. Include CDN domains your target pages depend on:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com"
|
||||
agent-browser open https://example.com # OK
|
||||
agent-browser open https://malicious.com # Blocked
|
||||
```
|
||||
|
||||
### Action Policy
|
||||
|
||||
Use a policy file to gate destructive actions:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_ACTION_POLICY=./policy.json
|
||||
```
|
||||
|
||||
Example `policy.json`:
|
||||
|
||||
```json
|
||||
{ "default": "deny", "allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"] }
|
||||
```
|
||||
|
||||
Auth vault operations (`auth login`, etc.) bypass action policy but domain allowlist still applies.
|
||||
|
||||
### Output Limits
|
||||
|
||||
Prevent context flooding from large pages:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_MAX_OUTPUT=50000
|
||||
```
|
||||
|
||||
## Diffing (Verifying Changes)
|
||||
|
||||
Use `diff snapshot` after performing an action to verify it had the intended effect. This compares the current accessibility tree against the last snapshot taken in the session.
|
||||
|
||||
```bash
|
||||
# Typical workflow: snapshot -> action -> diff
|
||||
agent-browser snapshot -i # Take baseline snapshot
|
||||
agent-browser click @e2 # Perform action
|
||||
agent-browser diff snapshot # See what changed (auto-compares to last snapshot)
|
||||
```
|
||||
|
||||
For visual regression testing or monitoring:
|
||||
|
||||
```bash
|
||||
# Save a baseline screenshot, then compare later
|
||||
agent-browser screenshot baseline.png
|
||||
# ... time passes or changes are made ...
|
||||
agent-browser diff screenshot --baseline baseline.png
|
||||
|
||||
# Compare staging vs production
|
||||
agent-browser diff url https://staging.example.com https://prod.example.com --screenshot
|
||||
```
|
||||
|
||||
`diff snapshot` output uses `+` for additions and `-` for removals, similar to git diff. `diff screenshot` produces a diff image with changed pixels highlighted in red, plus a mismatch percentage.
|
||||
|
||||
## Timeouts and Slow Pages
|
||||
|
||||
The default Playwright timeout is 25 seconds for local browsers. This can be overridden with the `AGENT_BROWSER_DEFAULT_TIMEOUT` environment variable (value in milliseconds). For slow websites or large pages, use explicit waits instead of relying on the default timeout:
|
||||
|
||||
```bash
|
||||
# Wait for network activity to settle (best for slow pages)
|
||||
agent-browser wait --load networkidle
|
||||
|
||||
# Wait for a specific element to appear
|
||||
agent-browser wait "#content"
|
||||
agent-browser wait @e1
|
||||
|
||||
# Wait for a specific URL pattern (useful after redirects)
|
||||
agent-browser wait --url "**/dashboard"
|
||||
|
||||
# Wait for a JavaScript condition
|
||||
agent-browser wait --fn "document.readyState === 'complete'"
|
||||
|
||||
# Wait a fixed duration (milliseconds) as a last resort
|
||||
agent-browser wait 5000
|
||||
```
|
||||
|
||||
When dealing with consistently slow websites, use `wait --load networkidle` after `open` to ensure the page is fully loaded before taking a snapshot. If a specific element is slow to render, wait for it directly with `wait <selector>` or `wait @ref`.
|
||||
|
||||
## Session Management and Cleanup
|
||||
|
||||
When running multiple agents or automations concurrently, always use named sessions to avoid conflicts:
|
||||
|
||||
```bash
|
||||
# Each agent gets its own isolated session
|
||||
agent-browser --session agent1 open site-a.com
|
||||
agent-browser --session agent2 open site-b.com
|
||||
|
||||
# Check active sessions
|
||||
agent-browser session list
|
||||
```
|
||||
|
||||
Always close your browser session when done to avoid leaked processes:
|
||||
|
||||
```bash
|
||||
agent-browser close # Close default session
|
||||
agent-browser --session agent1 close # Close specific session
|
||||
```
|
||||
|
||||
If a previous session was not closed properly, the daemon may still be running. Use `agent-browser close` to clean it up before starting new work.
|
||||
|
||||
## Ref Lifecycle (Important)
|
||||
|
||||
Refs (`@e1`, `@e2`, etc.) are invalidated when the page changes. Always re-snapshot after:
|
||||
|
||||
- Clicking links or buttons that navigate
|
||||
- Form submissions
|
||||
- Dynamic content loading (dropdowns, modals)
|
||||
|
||||
```bash
|
||||
agent-browser click @e5 # Navigates to new page
|
||||
agent-browser snapshot -i # MUST re-snapshot
|
||||
agent-browser click @e1 # Use new refs
|
||||
```
|
||||
|
||||
## Annotated Screenshots (Vision Mode)
|
||||
|
||||
Use `--annotate` to take a screenshot with numbered labels overlaid on interactive elements. Each label `[N]` maps to ref `@eN`. This also caches refs, so you can interact with elements immediately without a separate snapshot.
|
||||
|
||||
In native mode, this currently works on the CDP-backed browser path (Chromium/Lightpanda). The Safari/WebDriver backend does not yet support `--annotate`.
|
||||
|
||||
```bash
|
||||
agent-browser screenshot --annotate
|
||||
# Output includes the image path and a legend:
|
||||
# [1] @e1 button "Submit"
|
||||
# [2] @e2 link "Home"
|
||||
# [3] @e3 textbox "Email"
|
||||
agent-browser click @e2 # Click using ref from annotated screenshot
|
||||
```
|
||||
|
||||
Use annotated screenshots when:
|
||||
|
||||
- The page has unlabeled icon buttons or visual-only elements
|
||||
- You need to verify visual layout or styling
|
||||
- Canvas or chart elements are present (invisible to text snapshots)
|
||||
- You need spatial reasoning about element positions
|
||||
|
||||
## Semantic Locators (Alternative to Refs)
|
||||
|
||||
When refs are unavailable or unreliable, use semantic locators:
|
||||
|
||||
```bash
|
||||
agent-browser find text "Sign In" click
|
||||
agent-browser find label "Email" fill "user@test.com"
|
||||
agent-browser find role button click --name "Submit"
|
||||
agent-browser find placeholder "Search" type "query"
|
||||
agent-browser find testid "submit-btn" click
|
||||
```
|
||||
|
||||
## JavaScript Evaluation (eval)
|
||||
|
||||
Use `eval` to run JavaScript in the browser context. **Shell quoting can corrupt complex expressions** -- use `--stdin` or `-b` to avoid issues.
|
||||
|
||||
```bash
|
||||
# Simple expressions work with regular quoting
|
||||
agent-browser eval 'document.title'
|
||||
agent-browser eval 'document.querySelectorAll("img").length'
|
||||
|
||||
# Complex JS: use --stdin with heredoc (RECOMMENDED)
|
||||
agent-browser eval --stdin <<'EVALEOF'
|
||||
JSON.stringify(
|
||||
Array.from(document.querySelectorAll("img"))
|
||||
.filter(i => !i.alt)
|
||||
.map(i => ({ src: i.src.split("/").pop(), width: i.width }))
|
||||
)
|
||||
EVALEOF
|
||||
|
||||
# Alternative: base64 encoding (avoids all shell escaping issues)
|
||||
agent-browser eval -b "$(echo -n 'Array.from(document.querySelectorAll("a")).map(a => a.href)' | base64)"
|
||||
```
|
||||
|
||||
**Why this matters:** When the shell processes your command, inner double quotes, `!` characters (history expansion), backticks, and `$()` can all corrupt the JavaScript before it reaches agent-browser. The `--stdin` and `-b` flags bypass shell interpretation entirely.
|
||||
|
||||
**Rules of thumb:**
|
||||
|
||||
- Single-line, no nested quotes -> regular `eval 'expression'` with single quotes is fine
|
||||
- Nested quotes, arrow functions, template literals, or multiline -> use `eval --stdin <<'EVALEOF'`
|
||||
- Programmatic/generated scripts -> use `eval -b` with base64
|
||||
|
||||
## Configuration File
|
||||
|
||||
Create `agent-browser.json` in the project root for persistent settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"headed": true,
|
||||
"proxy": "http://localhost:8080",
|
||||
"profile": "./browser-data"
|
||||
}
|
||||
```
|
||||
|
||||
Priority (lowest to highest): `~/.agent-browser/config.json` < `./agent-browser.json` < env vars < CLI flags. Use `--config <path>` or `AGENT_BROWSER_CONFIG` env var for a custom config file (exits with error if missing/invalid). All CLI options map to camelCase keys (e.g., `--executable-path` -> `"executablePath"`). Boolean flags accept `true`/`false` values (e.g., `--headed false` overrides config). Extensions from user and project configs are merged, not replaced.
|
||||
|
||||
## Deep-Dive Documentation
|
||||
|
||||
| Reference | When to Use |
|
||||
| -------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| [references/commands.md](references/commands.md) | Full command reference with all options |
|
||||
| [references/snapshot-refs.md](references/snapshot-refs.md) | Ref lifecycle, invalidation rules, troubleshooting |
|
||||
| [references/session-management.md](references/session-management.md) | Parallel sessions, state persistence, concurrent scraping |
|
||||
| [references/authentication.md](references/authentication.md) | Login flows, OAuth, 2FA handling, state reuse |
|
||||
| [references/video-recording.md](references/video-recording.md) | Recording workflows for debugging and documentation |
|
||||
| [references/profiling.md](references/profiling.md) | Chrome DevTools profiling for performance analysis |
|
||||
| [references/proxy-support.md](references/proxy-support.md) | Proxy configuration, geo-testing, rotating proxies |
|
||||
|
||||
## Experimental: Native Mode
|
||||
|
||||
agent-browser has an experimental native Rust daemon that communicates with Chrome directly via CDP, bypassing Node.js and Playwright entirely. It is opt-in and not recommended for production use yet.
|
||||
|
||||
```bash
|
||||
# Enable via flag
|
||||
agent-browser --native open example.com
|
||||
|
||||
# Enable via environment variable (avoids passing --native every time)
|
||||
export AGENT_BROWSER_NATIVE=1
|
||||
agent-browser open example.com
|
||||
```
|
||||
|
||||
The native daemon supports Chromium and Safari (via WebDriver). Firefox and WebKit are not yet supported. All core commands (navigate, snapshot, click, fill, screenshot, cookies, storage, tabs, eval, etc.) work identically in native mode. Use `agent-browser close` before switching between native and default mode within the same session.
|
||||
|
||||
## Browser Engine Selection
|
||||
|
||||
Use `--engine` to choose a local browser engine. The default is `chrome`.
|
||||
|
||||
```bash
|
||||
# Use Lightpanda (fast headless browser, requires separate install)
|
||||
agent-browser --engine lightpanda open example.com
|
||||
|
||||
# Via environment variable
|
||||
export AGENT_BROWSER_ENGINE=lightpanda
|
||||
agent-browser open example.com
|
||||
|
||||
# With custom binary path
|
||||
agent-browser --engine lightpanda --executable-path /path/to/lightpanda open example.com
|
||||
```
|
||||
|
||||
Supported engines:
|
||||
|
||||
- `chrome` (default) -- Chrome/Chromium via CDP
|
||||
- `lightpanda` -- Lightpanda headless browser via CDP (10x faster, 10x less memory than Chrome)
|
||||
|
||||
Lightpanda does not support `--extension`, `--profile`, `--state`, or `--allow-file-access`. Install Lightpanda from https://lightpanda.io/docs/open-source/installation.
|
||||
|
||||
## Ready-to-Use Templates
|
||||
|
||||
| Template | Description |
|
||||
| ------------------------------------------------------------------------ | ----------------------------------- |
|
||||
| [templates/form-automation.sh](templates/form-automation.sh) | Form filling with validation |
|
||||
| [templates/authenticated-session.sh](templates/authenticated-session.sh) | Login once, reuse state |
|
||||
| [templates/capture-workflow.sh](templates/capture-workflow.sh) | Content extraction with screenshots |
|
||||
|
||||
```bash
|
||||
./templates/form-automation.sh https://example.com/form
|
||||
./templates/authenticated-session.sh https://app.example.com/login
|
||||
./templates/capture-workflow.sh https://example.com ./output
|
||||
```
|
||||
@@ -0,0 +1,178 @@
|
||||
---
|
||||
name: animation-translation
|
||||
description: Library mapping from source-site animation primitives (CSS keyframes, CSS transitions, GSAP tweens/timelines/ScrollTrigger, Framer Motion, Lottie, Three.js, IntersectionObserver reveals) to target primitives in the cloned Next.js project. Rules on when to translate vs keep source library. Load this in Stage 2 (CSS portion) and Stage 3 (JS portion).
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Animation Translation
|
||||
|
||||
Rule of thumb: prefer declarative (Framer Motion, Tailwind transitions) over imperative (GSAP, manual `requestAnimationFrame`) when the translation is clean. Keep imperative when translation loses fidelity — e.g. a GSAP timeline with 15 chained steps.
|
||||
|
||||
## Mapping table
|
||||
|
||||
| Source | Target | Stage | Notes |
|
||||
| ----------------------------------------- | -------------------------------------------------- | ----- | ----------------------------------------------------------- |
|
||||
| CSS keyframes (`@keyframes`) | CSS keyframes kept in `globals.css` | 2 | No reason to translate |
|
||||
| CSS transition | Tailwind `transition-*` / arbitrary | 2 | `transition-colors duration-200 ease-in-out` |
|
||||
| GSAP tween (single property) | Framer Motion `animate` prop | 3 | `<motion.div animate={{ opacity: 1 }} transition={{...}}/>` |
|
||||
| GSAP timeline (complex, chained) | Keep GSAP + `@gsap/react` `useGSAP` | 3 | Do not force bad Framer translation |
|
||||
| GSAP ScrollTrigger (simple) | Framer `useScroll` + `useTransform` | 3 | Only when trigger + 1-2 output props |
|
||||
| GSAP ScrollTrigger (complex) | Keep GSAP + ScrollTrigger | 3 | Timeline > 3 steps, pinned, scrub |
|
||||
| Framer Motion (source already) | Framer Motion direct | 3 | Copy `variants`, `transition`, `whileInView` verbatim |
|
||||
| Lottie | `lottie-react` + captured `animationData` | 3 | Component is `"use client"` |
|
||||
| Three.js | `@react-three/fiber` + `drei` | 4 | Handled by `clone-advanced` |
|
||||
| Custom GLSL shaders | `<shaderMaterial>` with extracted GLSL | 4 | Handled by `clone-advanced` |
|
||||
| `IntersectionObserver` reveal | Framer `whileInView` + `viewport: { once: true }` | 3 | Rarely worth keeping imperative |
|
||||
| Raw `<canvas>` 2D loop | Keep the `<canvas>` + `"use client"` + `useEffect` | 4 | If reconstruction fails, video fallback |
|
||||
| WAAPI (`Element.animate()`) | Translate to Framer `animate` | 3 | Similar semantics |
|
||||
| Parallax (`background-attachment: fixed`) | CSS kept | 2 | Browsers support it directly |
|
||||
|
||||
## Stage 2 (CSS animation)
|
||||
|
||||
### Keyframes
|
||||
|
||||
Source:
|
||||
|
||||
```css
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.hero-title {
|
||||
animation: fadeUp 0.6s ease-out forwards;
|
||||
}
|
||||
```
|
||||
|
||||
Target — in `globals.css`:
|
||||
|
||||
```css
|
||||
@layer utilities {
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.animate-fade-up {
|
||||
animation: fade-up 0.6s ease-out forwards;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or, per `css-to-tailwind`, add to `tailwind.config.ts`:
|
||||
|
||||
```ts
|
||||
theme: { extend: {
|
||||
keyframes: { "fade-up": { from: { opacity: "0", transform: "translateY(12px)" }, to: { opacity: "1", transform: "none" } } },
|
||||
animation: { "fade-up": "fade-up 0.6s ease-out forwards" },
|
||||
}}
|
||||
```
|
||||
|
||||
Then the component just has `className="animate-fade-up"`.
|
||||
|
||||
### Transitions
|
||||
|
||||
Source: `transition: transform 400ms cubic-bezier(0.22,1,0.36,1);`
|
||||
|
||||
Target: `className="transition-transform duration-[400ms] ease-[cubic-bezier(0.22,1,0.36,1)]"`. If that tuple repeats, promote the easing to `theme.extend.transitionTimingFunction`.
|
||||
|
||||
## Stage 3 (JS animation)
|
||||
|
||||
### Framer Motion (common path)
|
||||
|
||||
The capture hook dump gives you the component's `variants`, `transition`, `animate`, `initial`, `whileInView`, `whileHover`, `whileTap` verbatim. Reproduce them:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const variants = { hidden: { opacity: 0, y: 12 }, visible: { opacity: 1, y: 0 } };
|
||||
export default function Hero() {
|
||||
return (
|
||||
<motion.section
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
variants={variants}
|
||||
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
...
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### GSAP ScrollTrigger kept
|
||||
|
||||
When the source has a pinned, scrubbed, multi-step timeline, translating to Framer would flatten it. Keep GSAP:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { useRef } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import { useGSAP } from '@gsap/react';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
export default function PinnedHero() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useGSAP(
|
||||
() => {
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: { trigger: ref.current, start: 'top top', end: '+=1200', pin: true, scrub: 1 },
|
||||
});
|
||||
tl.to('.layer-a', { yPercent: -30 }).to('.layer-b', { yPercent: -50 }, '<');
|
||||
},
|
||||
{ scope: ref }
|
||||
);
|
||||
return <div ref={ref}>...</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Lottie
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import Lottie from 'lottie-react';
|
||||
import animationData from '@/public/assets/cloned/lottie/<hash>.json';
|
||||
|
||||
export default function LottieIcon() {
|
||||
return <Lottie animationData={animationData} loop autoplay style={{ width: 64, height: 64 }} />;
|
||||
}
|
||||
```
|
||||
|
||||
Prefer imports over `fetch()` so the JSON ships in the bundle and there is no layout shift on first play.
|
||||
|
||||
## Respecting `prefers-reduced-motion`
|
||||
|
||||
The Stage 1 gate runs with reduced motion forced, so your Stage 1 output should render a static final-state snapshot when motion is reduced.
|
||||
|
||||
In Framer Motion, use the `MotionConfig` with `reducedMotion="user"` in `layout.tsx`, or gate `animate` / `initial` on a `useReducedMotion()` hook.
|
||||
|
||||
For CSS animations, wrap the rule:
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.animate-fade-up {
|
||||
animation: fade-up 0.6s ease-out forwards;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Do not
|
||||
|
||||
- Do not add animations that are not in the source. The clone target is fidelity, not flair.
|
||||
- Do not approximate a GSAP timeline with a Framer `animate` sequence if the timeline has overlaps, labels, or scrubbing — keep GSAP.
|
||||
- Do not use `requestAnimationFrame` by hand if Framer covers the case. You will burn React lifecycle bugs.
|
||||
- Do not import full `gsap` bundle if only `ScrollTrigger` is needed — it is fine to `import { gsap } from "gsap"` because tree-shaking handles it, but check the bundle size if multiple sections use GSAP.
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
name: asset-pipeline
|
||||
description: Rules for how the clone pipeline names, organizes, and references downloaded assets. Covers folder layout under public/assets/cloned, hash-based filename convention, font strategy (self-host via next/font/local, fall back to system stack if licensing unclear), SVG inline-vs-file threshold, video format, and import paths. Load this when downloading assets and when referencing them in JSX.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Asset Pipeline
|
||||
|
||||
All cloned assets live under the generated project's `public/assets/cloned/`. Never outside this tree. Never in the workspace `.clone-workspace` beyond the capture bundle.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
<project>/public/assets/cloned/
|
||||
images/ # jpg, png, webp, avif
|
||||
videos/ # mp4 (only)
|
||||
fonts/ # woff2 (preferred), woff, ttf
|
||||
svg/ # .svg files when not inlined
|
||||
lottie/ # .json animation data
|
||||
```
|
||||
|
||||
## Naming
|
||||
|
||||
Every file is renamed to `<sha1-8>-<sanitized-original-name>`. The sha1 is of the source URL (not content), truncated to 8 hex chars. Sanitization: lowercase, spaces → `-`, strip everything not in `[a-z0-9.-]`.
|
||||
|
||||
Examples:
|
||||
|
||||
- `https://cdn.site.com/hero-bg.jpg` → `a1b2c3d4-hero-bg.jpg`
|
||||
- `https://fonts.site.com/inter-display-500.woff2` → `e5f67890-inter-display-500.woff2`
|
||||
|
||||
Collisions are effectively impossible at 8 chars for any one site. If they happen, extend to 10.
|
||||
|
||||
The manifest records the full `local_path` for each asset — the generate agent reads that field, never reconstructs the path.
|
||||
|
||||
## Fonts
|
||||
|
||||
**Preferred: self-hosted via `next/font/local`.** In `src/app/layout.tsx`:
|
||||
|
||||
```ts
|
||||
import localFont from 'next/font/local';
|
||||
|
||||
const display = localFont({
|
||||
src: [
|
||||
{ path: '../../public/assets/cloned/fonts/e5f67890-inter-display-400.woff2', weight: '400', style: 'normal' },
|
||||
{ path: '../../public/assets/cloned/fonts/abcd1234-inter-display-500.woff2', weight: '500', style: 'normal' },
|
||||
],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={display.variable}>
|
||||
<body className="font-display">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Then in `tailwind.config.ts`:
|
||||
|
||||
```ts
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
display: ['var(--font-display)', 'sans-serif'];
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**If licensing is unclear** (manifest marks `license_hint: "licensed"` or `"unclear"`), do NOT ship the font file. Substitute with the closest free alternative from a system stack and flag in the final report:
|
||||
|
||||
| Source font | Fallback |
|
||||
| -------------------------------- | ------------------------------------------------------ |
|
||||
| Any licensed sans | Inter from Google Fonts (via `next/font/google`) |
|
||||
| Any licensed serif | `ui-serif, Georgia, Cambria, "Times New Roman", serif` |
|
||||
| Any licensed mono | `ui-monospace, SFMono-Regular, Menlo, monospace` |
|
||||
| Variable display font (licensed) | Inter or Manrope via `next/font/google` |
|
||||
|
||||
Record the substitution in the manifest's `skipped[]` with `reason: "licensed_font_unclear"` and log to `logs/skipped.md`.
|
||||
|
||||
## SVGs
|
||||
|
||||
**Inline as React component** when all hold:
|
||||
|
||||
- Source size < 5KB
|
||||
- SVG is used in-flow, not as a CSS `background-image`
|
||||
- SVG has no `<script>` tags (never inline external scripts)
|
||||
|
||||
Use `@svgr/webpack` or inline the markup by hand in a small component file `src/components/icons/<Name>.tsx`. Prefer hand-written components for simple icons — `@svgr` adds tooling complexity that rarely pays back.
|
||||
|
||||
**Keep as file** when:
|
||||
|
||||
- SVG ≥ 5KB
|
||||
- SVG is used as `background-image` (referenced from CSS)
|
||||
- SVG contains references to `<filter>` or `<pattern>` that make it complex to inline
|
||||
|
||||
For file-kept SVGs, reference as `<img src="/assets/cloned/svg/<hash>.svg" alt="" />`. `next/image` does not accept SVGs without the `dangerouslyAllowSVG` flag — prefer raw `<img>` over enabling that flag.
|
||||
|
||||
## Videos
|
||||
|
||||
Only MP4. Capture normalizes HLS/DASH to MP4 when possible; if the source is DRM-protected (Widevine / FairPlay / PlayReady signatures in HAR), skip with `reason: "drm"` in the manifest.
|
||||
|
||||
Embed as:
|
||||
|
||||
```tsx
|
||||
<video
|
||||
src="/assets/cloned/videos/<hash>.mp4"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
poster="/assets/cloned/images/<hash>-poster.jpg"
|
||||
/>
|
||||
```
|
||||
|
||||
## Images
|
||||
|
||||
`next/image` is preferred. Configure no remote domains — everything is local under `/assets/cloned/`, which Next.js serves as static assets automatically.
|
||||
|
||||
```tsx
|
||||
import Image from 'next/image';
|
||||
<Image
|
||||
src="/assets/cloned/images/abc1234-hero.jpg"
|
||||
alt=""
|
||||
width={1920}
|
||||
height={1080}
|
||||
priority={isAboveTheFold}
|
||||
sizes="(max-width: 768px) 100vw, 1200px"
|
||||
/>;
|
||||
```
|
||||
|
||||
- `alt=""` for decorative images. Preserve source `alt` when present.
|
||||
- `priority` on above-the-fold images (hero bg, logo).
|
||||
- `sizes` matches the responsive layout — omit if the image is fixed-width.
|
||||
|
||||
For background images (CSS `background-image`), keep as CSS with arbitrary Tailwind value:
|
||||
|
||||
```tsx
|
||||
<div className="bg-[url('/assets/cloned/images/abc1234-bg.jpg')] bg-cover bg-center" />
|
||||
```
|
||||
|
||||
## Lottie
|
||||
|
||||
Lottie JSON files go under `lottie/`. Import them as ES modules, not fetch at runtime — this eliminates flash and is under 100KB in nearly every case:
|
||||
|
||||
```tsx
|
||||
import animationData from '@/public/assets/cloned/lottie/<hash>.json';
|
||||
```
|
||||
|
||||
If a Lottie is > 1MB, consider dynamic import with a `<Suspense>` boundary to defer.
|
||||
|
||||
## Import paths
|
||||
|
||||
In Next.js, the public directory is served at the root. Use absolute paths starting with `/`:
|
||||
|
||||
- ✅ `<img src="/assets/cloned/svg/logo.svg" />`
|
||||
- ✅ `import data from "@/public/assets/cloned/lottie/x.json"` (for ES import of JSON)
|
||||
- ❌ Never `../../../public/...` from components
|
||||
- ❌ Never `https://source-site.com/...` — those were downloaded
|
||||
|
||||
## Flagging failures
|
||||
|
||||
If an asset download fails (404, timeout, CORS, auth), the orchestrator records it in `logs/skipped.md` with the URL and reason. Generation should render a placeholder of the captured dimensions rather than a broken `<img>` for any section that referenced the missing asset.
|
||||
|
||||
## Cleanup rule
|
||||
|
||||
Do not delete assets the capture downloaded "just in case they are unused". The analyze agent records `assets[].referenced_by[]`; if the array is empty, leave the file in place — it might be referenced in a stage that has not run yet. Orphan cleanup is a post-run task, not part of generation.
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
name: clone-staging
|
||||
description: The four-stage taxonomy for the clone pipeline (Foundation, CSS & Static Interactivity, JS Animation, WebGL/3D), with gate thresholds. In staged-mode runs, gates are hard — sections must pass each stage before progressing. In default-mode runs, the staging is descriptive: it tells the generate agent which features go in early vs late and what's deferrable. Load this before starting any cloning work — the orchestrator, generate, and validate agents all reference these definitions.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Clone Staging
|
||||
|
||||
The clone pipeline organizes work into four ordered stages by capability. The same taxonomy serves two roles depending on orchestrator mode:
|
||||
|
||||
- **Staged-mode (`--staged`):** gates are hard. A section never enters stage N until stage N-1 has passed for that section. Prevents burning tokens on WebGL translation when the layout is still broken. Used for sites where surgical iteration matters.
|
||||
- **Default-mode:** stages are descriptive guidance for the generate agent — what goes in foundation, what's deferred to animation, what's webgl-only. The orchestrator does NOT enforce gates between stages; it generates everything in parallel batches per section, then iterates on visible failures. Used for marketing/landing pages where the manifest signals are usually enough to carry fidelity through one generation pass.
|
||||
|
||||
Either way, the stage classifications below are the source of truth for what work belongs where.
|
||||
|
||||
## Stage 0 — Capture (always, once)
|
||||
|
||||
Not a generation stage. The capture agent runs once and produces the bundle under `<workspace>/capture/`. Generation only begins after capture is complete and the manifest is written.
|
||||
|
||||
## Stage 1 — Foundation
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Design tokens in `tailwind.config.ts` + `globals.css` (colors, typography, spacing, shadows, radii, breakpoints)
|
||||
- Asset pipeline: all images, videos, fonts, SVGs, lottie JSONs downloaded to `/public/assets/cloned/`
|
||||
- Font loading via `next/font/local` (self-hosted `@font-face`)
|
||||
- Layout primitives: `Container`, `Section`, base typography in `globals.css`
|
||||
- Static page structure — DOM skeleton per section, no interactivity
|
||||
|
||||
**Gate:**
|
||||
|
||||
- Per-section diff at 1280px viewport with `prefers-reduced-motion` forced: `diff_pct < 5%`
|
||||
- All viewports (375 / 768 / 1280 / 1920) render without cumulative layout shift
|
||||
- No 404s in the network log for local assets
|
||||
|
||||
## Stage 2 — CSS & Static Interactivity
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Gradients, filters, `backdrop-filter`, masks, clip-paths
|
||||
- SVGs inlined or imported as components
|
||||
- CSS animations: `@keyframes`, `transition`
|
||||
- Hover, focus, focus-visible states
|
||||
- `prefers-color-scheme: dark` styles if detected in capture
|
||||
|
||||
**Gate:**
|
||||
|
||||
- Per-section diff at rest: `diff_pct < 3%`
|
||||
- Hover/focus state screenshots match the captured variants (delta `< 4%`)
|
||||
|
||||
## Stage 3 — JS Animation (only if Stage 2 passes)
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Scroll-linked animations — Framer Motion `useScroll` / `useTransform` if simple, keep GSAP ScrollTrigger if timeline is complex
|
||||
- Framer Motion variants for entrance / exit / `whileInView`
|
||||
- Lottie animations via `lottie-react`
|
||||
- IntersectionObserver-driven reveals → Framer `whileInView`
|
||||
|
||||
**Gate:**
|
||||
|
||||
- Per-section diff averaged across 5 sampled scroll positions (0%, 25%, 50%, 75%, 100%): `diff_pct < 8%`
|
||||
|
||||
## Stage 4 — WebGL / 3D (only if Stage 3 passes)
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Three.js scenes reconstructed from the captured scene graph via `@react-three/fiber`
|
||||
- Custom shaders reinstated from extracted GLSL
|
||||
- Custom `<canvas>` 2D animation if detected
|
||||
|
||||
**Gate:**
|
||||
|
||||
- Best-effort. `diff_pct < 15%` on sampled frames is a pass.
|
||||
- Two reconstruction attempts; if both fail, fall back to the captured MP4 embedded as `<video autoplay loop muted playsinline>` with matching dimensions. Fallback is a valid pass — flag in report.
|
||||
|
||||
## Tie-breakers
|
||||
|
||||
**A section straddles stages.** Classify by the _highest_ stage required for any element in it.
|
||||
|
||||
Example: a hero section is mostly static CSS but has one Lottie icon → `max_stage_required: 3`. The generate agent still builds the static part in Stage 1, adds CSS flourishes in Stage 2, and only wires up the Lottie in Stage 3. The Stage 1 gate is applied with the Lottie slot rendered as a placeholder of matching dimensions.
|
||||
|
||||
**Placeholder rule during earlier stages.** For elements whose implementation is deferred to a later stage, render a positioned placeholder `<div style={{ width, height }}>` so layout diffs at the earlier-stage gate are not polluted. Replace in the later stage.
|
||||
|
||||
**Conservative staging.** When in doubt about whether a section needs Stage 3 (e.g. subtle fade-in), mark it Stage 2. If Stage 2 validation fails because the animation is material to layout, promote at that point. Cheap to promote upward; expensive to demote.
|
||||
|
||||
## Non-goals (skip, do not attempt)
|
||||
|
||||
When any of these is detected, do not try to clone. Replace with a sized placeholder and comment:
|
||||
|
||||
- DRM'd video/audio (Widevine, FairPlay, PlayReady)
|
||||
- Third-party widgets: Intercom, Drift, Typeform, Calendly, HubSpot forms, chat widgets, cookie consent banners
|
||||
- Authenticated or personalized content behind login
|
||||
- Obfuscated WASM modules
|
||||
- Real-time data feeds (stock tickers, live chat)
|
||||
- Analytics pixels / tag managers (no value to clone; actively harmful)
|
||||
|
||||
Pattern:
|
||||
|
||||
```tsx
|
||||
<div style={{ width: W, height: H }}>{/* CLONE: skipped third-party widget — <vendor> */}</div>
|
||||
```
|
||||
|
||||
The captured bounding box supplies W/H so layout does not shift.
|
||||
|
||||
## Iteration caps
|
||||
|
||||
- **Staged mode:** 3 iterations per section per stage. After three failed attempts at the same section at the same stage, log to `logs/manual-review.md` with the current component code, diff images, and most recent issues list; move on. A partial clone is better than a stuck clone.
|
||||
- **Default mode:** 2 targeted-fix iterations per section AFTER the initial parallel-batch generation. Same logging rule on exhaustion.
|
||||
|
||||
## Default-mode "advisory" gates
|
||||
|
||||
When the orchestrator does the visual-review pass in default mode, the gate thresholds above are useful guidance but not enforced. A section showing 12% pixel diff dominated by font-rendering or video-frame timing is fine. A section showing 12% pixel diff with a visibly missing element is not. The orchestrator's judgment call — informed by reading the screenshots — is what triggers a targeted-fix call.
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
name: css-to-tailwind
|
||||
description: Rules for converting resolved computed CSS from the capture into Tailwind v3 classes on Next.js components. Decides when to use arbitrary values vs extending tailwind.config.ts, how to handle gradients / shadows / filters / clamp / variable fonts / container queries, and when to bail to globals.css. Load this in every Stage 1 or Stage 2 generation run.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# CSS → Tailwind
|
||||
|
||||
The generate agent receives a DOM fragment with computed styles already resolved per node. Your job is to turn those styles into Tailwind classes on the JSX output — no cascade reasoning required.
|
||||
|
||||
## Core decision: utility vs arbitrary vs config vs CSS file
|
||||
|
||||
| Situation | Approach |
|
||||
| ---------------------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| Value matches a default Tailwind scale step (e.g. `p-4`, `gap-8`) | Use the utility |
|
||||
| Value is used 1-2 times site-wide and does not match scale | Arbitrary: `w-[347px]` |
|
||||
| Value is used 3+ times site-wide | Extend `tailwind.config.ts`, then use the token |
|
||||
| Cannot be expressed in Tailwind (complex `filter`, `@property`, `@supports`, dynamic `attr()`) | Write to `globals.css` |
|
||||
|
||||
The manifest's `design_tokens` object is already deduplicated by frequency — use it as the source of truth for what goes into `tailwind.config.ts`.
|
||||
|
||||
## Specific rules
|
||||
|
||||
### Heights and widths — use intent over resolved px
|
||||
|
||||
The capture's computed styles resolve `vh`/`vw`/`%`/`calc()` to literal pixel values at the capture viewport (1280×720). **Do not blindly transcribe these.** The manifest gives you better signals:
|
||||
|
||||
1. **`section.vh_relative === true`**: the section's height is viewport-height-derived. Map `section.vh_value` to Tailwind:
|
||||
- `vh_value` ≈ 100 → `min-h-screen` (or `h-screen` if the source had `height` not `min-height`)
|
||||
- `vh_value` between 50 and 99 → `min-h-[<vh>vh]` arbitrary, e.g. `min-h-[88vh]`
|
||||
- `vh_value` < 50 → still `min-h-[<vh>vh]` but consider whether content alone determines the height (if so, omit the height entirely and let content size it)
|
||||
2. **`section.full_width === true`**: use `w-full` (or `w-screen` for true full-bleed elements that escape parent padding). Never write `w-[1280px]`.
|
||||
3. **`capture/css-rules/<vp>.json`** has the source intent verbatim. When in doubt for a specific element, read the rule that matched its selector. Look for `100vh`, `100%`, `calc(...)`, `clamp(...)`, `aspect-ratio` — these are signals to NOT use the literal px value.
|
||||
|
||||
Heuristic for elements not in `vh_relative_elements` but suspected vh-relative: if `bounding_box.height` is close to `viewport_height * N` for `N ∈ {0.5, 0.75, 1.0}` and the element is a top-level section with no other content driving height, prefer the vh form. Cheaper to over-flag here than to leave a brittle px height.
|
||||
|
||||
### Colors
|
||||
|
||||
- Every color in `manifest.design_tokens.colors` gets promoted into `theme.extend.colors` under its semantic name.
|
||||
- Reference as `bg-primary`, `text-primary`, `border-accent-1`, etc. in class output.
|
||||
- Unseen one-off colors: arbitrary, e.g. `text-[#3a3a3a]`.
|
||||
|
||||
### Typography
|
||||
|
||||
- Font families: promote to `theme.extend.fontFamily`. Use `next/font/local` in `layout.tsx` with the downloaded woff2 files and expose via CSS variable, then reference as `['var(--font-display)']` etc.
|
||||
- Font sizes: promote clusters to `theme.extend.fontSize` as named keys (`display`, `h1`, `body`, `small`). Value is `[fontSize, { lineHeight, letterSpacing, fontWeight }]`.
|
||||
- One-off sizes: `text-[32px]/[1.2]` arbitrary.
|
||||
|
||||
### Spacing
|
||||
|
||||
- Infer the scale from `manifest.design_tokens.spacing[]`. Often 4px / 8px / 16px / 24px ... but match what the site actually uses.
|
||||
- Extend Tailwind's spacing scale only for values not already close to a default step.
|
||||
- Use `gap-*`, `p-*`, `m-*`, `space-y-*` utilities.
|
||||
|
||||
### Gradients
|
||||
|
||||
- Linear: `bg-gradient-to-r from-[#...] via-[#...] to-[#...]` for simple 2-3 stops. For more stops or angled variants, use arbitrary `bg-[linear-gradient(135deg,#a_0%,#b_50%,#c_100%)]`.
|
||||
- Radial: `bg-[radial-gradient(...)]` arbitrary.
|
||||
- Conic: `bg-[conic-gradient(...)]` arbitrary.
|
||||
- Mesh / multi-layer: wrap in `bg-[image:...]` syntax with escaped commas.
|
||||
|
||||
### Shadows
|
||||
|
||||
- Single-layer shadows that match scale: `shadow-md`, `shadow-xl`.
|
||||
- Multi-layer shadows: arbitrary with escaped commas: `shadow-[0_1px_2px_rgba(0,0,0,0.05),_0_4px_8px_rgba(0,0,0,0.08)]`.
|
||||
- Promote 3+ occurrences of the same shadow to `theme.extend.boxShadow`.
|
||||
|
||||
### Filters and backdrop-filter
|
||||
|
||||
- Built-in: `blur-*`, `brightness-*`, `contrast-*`, `grayscale-*`, `backdrop-blur-*`, `backdrop-saturate-*`.
|
||||
- Arbitrary: `filter-[blur(16px)_saturate(140%)]`, `backdrop-filter-[blur(20px)_saturate(140%)]`.
|
||||
- Complex filter chains (4+ functions): promote to a CSS utility class in `globals.css` under `@layer utilities`.
|
||||
|
||||
### clamp() / min() / max()
|
||||
|
||||
- Preserve verbatim in arbitrary: `text-[clamp(24px,4vw,48px)]`, `w-[min(100%,1200px)]`.
|
||||
- Do not try to approximate with `md:` breakpoints — the whole point of `clamp()` is continuous scaling.
|
||||
|
||||
### Variable fonts
|
||||
|
||||
- `font-variation-settings: "wght" 420, "ital" 0` → arbitrary `[font-variation-settings:'wght'_420,'ital'_0]`.
|
||||
- If the site uses variable weights fluidly (e.g. on hover), pair with `transition-[font-variation-settings]`.
|
||||
|
||||
### Container queries
|
||||
|
||||
- Use the `@tailwindcss/container-queries` plugin. Add to `tailwind.config.ts` plugins.
|
||||
- Mark the container: `@container`.
|
||||
- Query: `@md:grid-cols-2`, `@lg:text-lg`, etc.
|
||||
|
||||
### clip-path and mask
|
||||
|
||||
- Built-in utilities cover basic cases (`rounded-full`). Anything custom: arbitrary.
|
||||
- `clip-path: polygon(...)` → `[clip-path:polygon(0_0,100%_0,100%_80%,0_100%)]`.
|
||||
- `mask-image` → `[mask-image:linear-gradient(...)]` + matching `[-webkit-mask-image:...]` if Safari support needed.
|
||||
|
||||
### Transforms
|
||||
|
||||
- Scale / rotate / translate cover common cases. Chained transforms: Tailwind's `transform` + individual utilities, or arbitrary `[transform:perspective(800px)_rotateY(12deg)]`.
|
||||
|
||||
### Transitions
|
||||
|
||||
- Simple: `transition-colors`, `duration-200`, `ease-in-out`.
|
||||
- Multi-property transitions: arbitrary `[transition:transform_400ms_cubic-bezier(0.22,1,0.36,1),_opacity_300ms]`.
|
||||
|
||||
### Keyframes / `@keyframes`
|
||||
|
||||
- Always bail to `globals.css` under `@layer utilities` or a named keyframe in `tailwind.config.ts` `theme.extend.keyframes` + `animation`.
|
||||
- Then reference in JSX: `animate-fade-up`.
|
||||
|
||||
## When to bail to `globals.css`
|
||||
|
||||
Write rules to `src/app/globals.css` (not to the component) when:
|
||||
|
||||
- The rule is `@property` — Tailwind cannot express it.
|
||||
- The rule is `@supports` — conditional CSS outside Tailwind's reach.
|
||||
- The rule is `@font-face` — prefer `next/font/local`, but if the font is served from a remote URL that cannot be downloaded, `@font-face` in `globals.css` is acceptable.
|
||||
- A selector you cannot express as a class (e.g. `:has()`, `:nth-child(odd) > :first-of-type`).
|
||||
- Complex multi-rule animations (keyframes + state variants + JS hooks).
|
||||
|
||||
Keep `globals.css` under 200 lines. If it grows beyond, group related rules into separate files imported from `globals.css`.
|
||||
|
||||
## Do not
|
||||
|
||||
- Do not re-derive cascade from the source stylesheets. The computed styles on each node already resolve cascade. Copy those.
|
||||
- Do not use `!important`. If a style is not applying, you have a selector specificity problem — fix it by structuring the JSX, not by nuking the cascade.
|
||||
- Do not add classes that are not represented in the captured computed styles. If the source does not have `box-shadow`, do not add one "for polish".
|
||||
- Do not add `@apply` in component files. `@apply` only belongs in `globals.css` when consolidating a repeated utility cluster.
|
||||
- Do not leave TODO comments in component files — the cloner is meant to be done work, not a scaffold.
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: dom-to-jsx
|
||||
description: Rules for converting captured DOM fragments into Next.js App Router JSX components. Covers component naming, when to split, Server vs Client classification, hydration-safe patterns, image handling (next/image vs raw img), and prop extraction for repeated patterns. Load this in every generation run from Stage 1 onward.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# DOM → JSX
|
||||
|
||||
The generate agent receives a DOM fragment per section. Convert it into idiomatic Next.js 14+ App Router JSX.
|
||||
|
||||
## Component naming
|
||||
|
||||
- PascalCase. Section-scoped: `HeroSection`, `FeatureGrid`, `TestimonialCard`, `PricingTable`, `CTAStrip`.
|
||||
- File path: `src/components/sections/<Name>.tsx` for top-level sections, `src/components/cards/<Name>.tsx` for repeated card components, `src/components/ui/<Name>.tsx` for primitives (button, badge).
|
||||
- Default export matches the file name. Named exports are fine for sub-components.
|
||||
|
||||
## When to split
|
||||
|
||||
Split a component when any of these is true:
|
||||
|
||||
- File exceeds 150 lines of JSX
|
||||
- JSX nesting exceeds 3 levels
|
||||
- A repeated pattern appears 3+ times within the section
|
||||
|
||||
When splitting for a repeat, extract a component with a `data` prop (array of items) or a single-item prop shape matching the manifest's `candidate_component` proposal.
|
||||
|
||||
Example — manifest says a `FeatureCard` appears 6 times with `{title, description, icon}`:
|
||||
|
||||
```tsx
|
||||
// src/components/cards/FeatureCard.tsx
|
||||
type Props = { title: string; description: string; icon: string };
|
||||
export default function FeatureCard({ title, description, icon }: Props) { ... }
|
||||
|
||||
// src/components/sections/Features.tsx
|
||||
import FeatureCard from "@/components/cards/FeatureCard";
|
||||
const ITEMS = [{ title: "...", description: "...", icon: "..." }, ...];
|
||||
export default function Features() {
|
||||
return <div className="grid grid-cols-3 gap-8">{ITEMS.map(i => <FeatureCard key={i.title} {...i} />)}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Extract `ITEMS` as a local `const` in the section file for Stage 1. If the data is clearly content-managed (12+ items, or visually heterogeneous), move to `src/content/<section>.ts` as a typed export.
|
||||
|
||||
## Server vs Client
|
||||
|
||||
Default: **Server Component**. Add `"use client"` at the top of a file only if any of these is true:
|
||||
|
||||
- Uses `useState`, `useEffect`, `useRef`, or any other hook
|
||||
- Uses event handlers (`onClick`, `onChange`, etc.) — interactive buttons that do more than `<a href>`-style navigation
|
||||
- Uses `framer-motion`'s `motion.*` components
|
||||
- Uses `lottie-react` or any other client-only lib
|
||||
- Uses browser APIs (`window`, `document`, `localStorage`)
|
||||
|
||||
Do not mark parents client unnecessarily. Keep `"use client"` at the leaf — a client leaf can live inside a server parent, not vice versa.
|
||||
|
||||
## Hydration safety
|
||||
|
||||
- **No** `Date.now()` / `Math.random()` / `new Date()` at render. If needed, generate in `useEffect` and store in state. At render time the first pass must be deterministic.
|
||||
- **No** reading `window` / `document` at module scope. Wrap in `useEffect` or guard with `typeof window !== "undefined"`.
|
||||
- `suppressHydrationWarning` is a last resort — only on a single element where the server/client divergence is intentional and minimal (e.g. a `<time>` element).
|
||||
- Do not pass `new Date()` or live content into a Server Component from within a client wrapper at first paint — you will get a mismatch.
|
||||
|
||||
## Image handling
|
||||
|
||||
**Default: `next/image`** when all of these hold:
|
||||
|
||||
- The image has known dimensions (the manifest captured them)
|
||||
- The image is a raster (jpg, png, webp, avif)
|
||||
- The image is content, not purely decorative
|
||||
|
||||
Example:
|
||||
|
||||
```tsx
|
||||
import Image from 'next/image';
|
||||
<Image src="/assets/cloned/images/abc1234-hero.jpg" alt="" width={1920} height={1080} />;
|
||||
```
|
||||
|
||||
**Raw `<img>`** for:
|
||||
|
||||
- SVGs (use native `<img src="/assets/cloned/svg/xyz.svg" alt="" />` or inline the SVG as a component if <5KB and decorative)
|
||||
- Images with unknown dimensions at clone time
|
||||
- Decorative blur-ups where LCP is not a concern
|
||||
|
||||
**Inline SVG** when:
|
||||
|
||||
- The SVG is <5KB in the source
|
||||
- The SVG is used in-flow (not as a background-image)
|
||||
- Animated parts need to be controlled from JS
|
||||
|
||||
Configure `next.config.js` to allow loading from `/assets/cloned` paths — no external loader needed since everything is already local.
|
||||
|
||||
## Video
|
||||
|
||||
```tsx
|
||||
<video src="/assets/cloned/videos/<hash>.mp4" autoPlay loop muted playsInline className="..." />
|
||||
```
|
||||
|
||||
Always `playsInline` (iOS won't inline autoplay without it). Always `muted` for autoplay (browsers require it).
|
||||
|
||||
## Links
|
||||
|
||||
- Internal: `<Link href="/...">` from `next/link`.
|
||||
- External: `<a href="..." target="_blank" rel="noopener noreferrer">`.
|
||||
- Anchor in page: plain `<a href="#id">` is fine — `<Link>` with a hash works too.
|
||||
|
||||
## Accessibility
|
||||
|
||||
Keep what the source had — do not invent. If the source has `aria-label` or `alt=""`, preserve it. If the source is inaccessible, the clone is inaccessible. This is a fidelity tool, not a correction tool.
|
||||
|
||||
That said — do not _remove_ accessibility attributes the source had.
|
||||
|
||||
## Third-party widgets (skip per `clone-staging`)
|
||||
|
||||
When the manifest flags a subtree as a third-party widget, replace with:
|
||||
|
||||
```tsx
|
||||
<div style={{ width: W, height: H }}>{/* CLONE: skipped third-party widget — <vendor> */}</div>
|
||||
```
|
||||
|
||||
Pull `W` and `H` from the manifest's bounding box for the widget's container. This preserves layout so later sections land at the right Y offset.
|
||||
|
||||
## Forms
|
||||
|
||||
The clone is a static marketing-page mirror. Forms should render visually but the `onSubmit` handler is `(e) => e.preventDefault()` — no backend wiring.
|
||||
|
||||
If the source had client-side validation that is material to the layout (error states, spinners), keep it as a visual-only `useState` example. Do not call real APIs.
|
||||
|
||||
## Attribute translation
|
||||
|
||||
- `class` → `className`
|
||||
- `for` → `htmlFor`
|
||||
- `tabindex` → `tabIndex`
|
||||
- Hyphenated attrs on SVG: most become camelCase (`stroke-width` → `strokeWidth`), but `data-*` and `aria-*` stay hyphenated.
|
||||
- `style="..."` → `style={{ ... }}` with camelCase keys. Only inline style that is computed / dynamic; static style always goes to Tailwind per `css-to-tailwind`.
|
||||
|
||||
## Content
|
||||
|
||||
- Copy text verbatim from the DOM fragment. Do not paraphrase. Do not translate. Do not "improve" grammar.
|
||||
- Preserve whitespace-sensitive content (`<pre>`, `<code>`) exactly.
|
||||
- Keep HTML entities as-is (`—`, ` `, `…`) but JSX renders them when placed inside string literals — use `{"—"}` or `{"\u00a0"}` for non-breaking spaces when adjacency matters.
|
||||
@@ -0,0 +1,177 @@
|
||||
---
|
||||
name: site-manifest
|
||||
description: Schema and rules for manifest.json — the single blueprint artifact the analyze agent produces and the generate + validate agents consume. Defines field shapes, stable id conventions, token naming conventions, and what goes where. Load this in the analyze agent before writing manifest.json and in any agent that reads it.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# site-manifest
|
||||
|
||||
Everything the generate + validate agents need lives in `manifest.json` at `<workspace>/manifest.json`. The capture bundle is the raw source; the manifest is the refined, structured index.
|
||||
|
||||
## Top-level schema
|
||||
|
||||
```ts
|
||||
type Manifest = {
|
||||
source_url: string; // absolute URL
|
||||
captured_at: string; // ISO 8601
|
||||
viewports: number[]; // e.g. [375, 768, 1280, 1920]
|
||||
|
||||
design_tokens: {
|
||||
colors: Record<string, string>; // semantic_name -> hex (e.g. "primary" -> "#1A1A1A")
|
||||
typography: {
|
||||
families: Record<string, string>; // semantic -> family (e.g. "display" -> "'Inter Display', sans-serif")
|
||||
scales: Array<{
|
||||
name: string; // "display" | "h1" | "body" | "small" | ...
|
||||
size: string; // "48px"
|
||||
lineHeight: string; // "1.1"
|
||||
weight: number; // 500
|
||||
letterSpacing: string; // "-0.02em"
|
||||
}>;
|
||||
};
|
||||
spacing: number[]; // the scale in px — e.g. [4, 8, 12, 16, 24, 32, 48, 64]
|
||||
radii: Record<string, string>; // "sm" -> "4px"
|
||||
shadows: Record<string, string>; // "md" -> "0 1px 2px ..." (full CSS value)
|
||||
breakpoints: Record<string, number>; // "md" -> 768
|
||||
};
|
||||
|
||||
fonts: Array<{
|
||||
family: string; // "Inter Display"
|
||||
weights: number[]; // [400, 500, 700]
|
||||
styles: Array<'normal' | 'italic'>;
|
||||
source_urls: string[]; // as seen in @font-face
|
||||
local_paths: string[]; // where they were downloaded
|
||||
license_hint?: 'licensed' | 'unclear' | 'open';
|
||||
}>;
|
||||
|
||||
sections: Array<{
|
||||
id: string; // slug — stable across reruns
|
||||
name: string; // "Hero" | "Feature Grid" | ...
|
||||
dom_path: string; // relative to capture_dir — JSON file with DOM + computed styles
|
||||
screenshot_paths: Record<number, string>; // viewport -> relative path in capture_dir
|
||||
section_anchor?: string; // CSS selector for this section's root element (e.g. "#hero", ".elementor-element-fe377c0"). Used by validate's dom-diff `root_selector`. REQUIRED when present in capture.
|
||||
bounding_box: { x: number; y: number; width: number; height: number }; // at canonical 1280×720 capture
|
||||
vh_relative?: boolean; // true if the height is viewport-height-derived (see vh-flags.json)
|
||||
vh_value?: number; // implied vh percentage (e.g. 88 for ~min-height: 88vh) when vh_relative is true
|
||||
full_width?: boolean; // true if width matched viewport width at capture time AND nominal source CSS targets full-bleed (use w-full / w-screen, not the literal px value)
|
||||
max_stage_required: 1 | 2 | 3 | 4;
|
||||
detected_libs: string[]; // ["gsap", "framer-motion", "lottie", "three"]
|
||||
third_party_widgets: Array<{ vendor: string; selector: string; bbox: BBox }>;
|
||||
repeated_patterns: Array<{
|
||||
selector: string; // pattern selector in the captured DOM
|
||||
count: number; // 3+
|
||||
candidate_component: {
|
||||
name: string; // PascalCase
|
||||
props: string[]; // field names extracted from pattern variance
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
|
||||
assets: Array<{
|
||||
type: 'image' | 'video' | 'font' | 'svg' | 'lottie';
|
||||
source_url: string; // absolute
|
||||
local_path: string; // "public/assets/cloned/images/<hash>-<name>"
|
||||
dimensions?: { width: number; height: number };
|
||||
from_css?: boolean; // true if discovered from a CSS url() rather than a HAR response
|
||||
referenced_by: string[]; // section ids where this asset appears
|
||||
}>;
|
||||
|
||||
// Optional — populated when capture.py finds vh-relative elements (compares
|
||||
// canonical 1280×720 capture against a 1280×1080 alt-height capture).
|
||||
vh_relative_elements?: Array<{
|
||||
path: string; // structural path produced by detect_vh_flags()
|
||||
tag: string;
|
||||
id?: string | null;
|
||||
class?: string | null;
|
||||
primary_h: number; // height at 1280×720
|
||||
alt_h: number; // height at 1280×1080
|
||||
ratio: number; // alt_h / primary_h — should ~= 1.5 for vh-relative
|
||||
vh: number; // implied vh % (= primary_h / 720 * 100)
|
||||
}>;
|
||||
|
||||
skipped: Array<{
|
||||
reason: 'drm' | 'third_party_widget' | 'auth_required' | 'wasm_obfuscated' | 'licensed_font_unclear';
|
||||
element?: string; // selector
|
||||
original_url?: string;
|
||||
vendor?: string;
|
||||
}>;
|
||||
};
|
||||
```
|
||||
|
||||
## Id conventions
|
||||
|
||||
- `section.id` is a slug from `section.name`, suffixed with a stable counter when the same semantic name appears twice: `features-0`, `features-1`. Order is DOM order top-to-bottom.
|
||||
- Asset `local_path` uses an 8-char sha1 hash of the source URL + the original filename: `abc12345-hero-background.jpg`. This guarantees no collisions and is stable across reruns.
|
||||
- `repeated_patterns[].candidate_component.name` is PascalCase, singular: `FeatureCard` not `FeatureCards`.
|
||||
|
||||
## Determinism
|
||||
|
||||
The analyze agent MUST produce the same manifest on the same capture input. This means:
|
||||
|
||||
- No timestamps inside section ids (`captured_at` at the top is fine — it is read-once)
|
||||
- No random numbers, no iterator-order-dependent ids
|
||||
- Sort arrays by semantic key (sections by DOM order, assets by local_path, skipped by reason then element)
|
||||
|
||||
## Promotion rule for tokens
|
||||
|
||||
A value goes into `design_tokens` only if it is used 3+ times in the capture. One-off values stay inline in the DOM fragment and become arbitrary Tailwind values in generation.
|
||||
|
||||
Exception: the page-level background color, primary text color, and primary font family always go into `design_tokens` regardless of count — downstream agents need them to set globals.
|
||||
|
||||
## Capture artifacts the analyze agent should read
|
||||
|
||||
In addition to per-section `dom_path` files, several capture-level artifacts are now available:
|
||||
|
||||
- `<capture_dir>/sections/<viewport>.json` — `__CLONE_LIST_SECTIONS__` candidate sections per viewport, with bbox and selector. Use these as scaffolding when decomposing into manifest sections — the smarter scroll loop already used these to time the capture, so each candidate has a clean DOM dump.
|
||||
- `<capture_dir>/section-shots/<viewport>/section-NN.png` — pre-cropped per-section screenshots. Map these to manifest sections by index; the validate agent uses them as the diff reference.
|
||||
- `<capture_dir>/css-rules/<viewport>.json` — every same-origin CSS rule, plus extracted `url()` asset references. Use this to recover source intent (e.g. `min-height: 100vh`, `width: 100%`, `aspect-ratio: 16/9`) that resolved computed styles erase. Also lets you recover backgrounds/masks/cursors that were not GET-requested at capture time.
|
||||
- `<capture_dir>/dom-alt/1280-1080/step-00.json` — DOM at canonical width with alt height. Used internally to derive `vh-flags.json`; you don't need to read this directly.
|
||||
- `<workspace>/vh-flags.json` (next to manifest.json) — pre-computed list of vh-relative element candidates. Cross-reference against your section bbox.y values to populate `section.vh_relative` + `section.vh_value`.
|
||||
|
||||
## How to populate vh_relative on a section
|
||||
|
||||
1. Load `vh-flags.json`.
|
||||
2. For each entry, check whether its `path` resolves to an element inside or equal to one of your section roots.
|
||||
3. If a flagged entry IS the section root, set `section.vh_relative = true` and `section.vh_value = entry.vh`.
|
||||
4. If a flagged entry is a child but the child's height equals the section's height, the section itself is vh-relative — same flag.
|
||||
5. The generate agent uses this to emit `min-h-[<vh>vh]` (Tailwind arbitrary) or `min-h-screen` when vh ≈ 100. See `css-to-tailwind` for the rule.
|
||||
|
||||
## Reading the DOM fragment
|
||||
|
||||
Each `section.dom_path` points to a JSON file with this shape:
|
||||
|
||||
```ts
|
||||
type DomNode = {
|
||||
tag: string;
|
||||
attrs: Record<string, string>; // already translated: class, for, tabindex names
|
||||
text?: string; // text content if leaf or pre-text
|
||||
computed: {
|
||||
// resolved computed styles — only non-default values
|
||||
color?: string;
|
||||
backgroundColor?: string;
|
||||
fontFamily?: string;
|
||||
fontSize?: string;
|
||||
// ... any property the node actually uses
|
||||
pseudo?: {
|
||||
'::before'?: Record<string, string>;
|
||||
'::after'?: Record<string, string>;
|
||||
':hover'?: Record<string, string>;
|
||||
':focus-visible'?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
children: DomNode[];
|
||||
};
|
||||
```
|
||||
|
||||
The generate agent walks this tree and emits JSX with Tailwind classes derived from `computed`. Because the styles are already resolved, no cascade reasoning is required.
|
||||
|
||||
## Validation
|
||||
|
||||
After writing `manifest.json`, self-check:
|
||||
|
||||
- `viewports.length >= 1`
|
||||
- Every `section.screenshot_paths` has a key for each viewport
|
||||
- Every `asset.local_path` starts with `public/assets/cloned/`
|
||||
- No section has `max_stage_required > 4` or `< 1`
|
||||
- `design_tokens.colors.primary` exists (fall back to `#000` if none inferred — noisy fallback is better than missing key)
|
||||
|
||||
If a check fails, fix the manifest — do not write a broken one.
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
name: validation-loop
|
||||
description: How the validate agent runs the two diff channels — screenshot pixel-diff AND structural DOM diff — against captured originals, ranks issues, and feeds them back to the generate agent. Defines thresholds, tool usage, diff report format, and the 3-iteration hard cap per section per stage. Load this in the validate agent every run.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Validation Loop
|
||||
|
||||
The validate agent closes the loop: render → screenshot + DOM dump → diff against captured originals → decide pass/fail → return a report the generate agent can act on.
|
||||
|
||||
## Two diff channels
|
||||
|
||||
Both run on every fail. Combine the signals.
|
||||
|
||||
### 1. Pixel diff — `screenshot-diff`
|
||||
|
||||
Wraps `scripts/diff.py` (pixelmatch + scipy connected components).
|
||||
|
||||
Inputs: `before` (captured PNG), `after` (rendered PNG), `threshold` (default 0.1).
|
||||
Outputs: `diff_pct`, `diff_image_path`, `worst_regions[]`.
|
||||
|
||||
Prefer per-section pre-cropped captures when available:
|
||||
|
||||
- `capture/section-shots/<viewport>/section-<idx>.png` — if the analyze agent matched this section to a candidate index.
|
||||
- Otherwise the closest scroll-step PNG from `capture/screenshots/<viewport>/step-NN.png`.
|
||||
|
||||
### 2. Structural diff — `dom-diff`
|
||||
|
||||
Wraps `scripts/dom-diff.py`. Compares two DOM JSON files produced by `__CLONE_DUMP_COMPUTED__` (the rich walker the capture script and `dump-rendered` both use). Returns concrete property-level mismatches the generate agent can fix without eyeballing pixels.
|
||||
|
||||
Inputs:
|
||||
|
||||
- `captured` — `manifest.sections[id].dom_path` from the source capture.
|
||||
- `rendered` — JSON from `dump-rendered` against `localhost:3000`.
|
||||
- `root_selector` — `#id` or `.class` to scope to the section. Use the manifest's `section_anchor` field; fall back to `#<section_id>`.
|
||||
|
||||
Outputs:
|
||||
|
||||
```json
|
||||
{
|
||||
"matched": 76,
|
||||
"counts": { "matched": 76, "missing": 2, "extra": 1, "tag_mismatch": 0, "style": 5, "size": 3 },
|
||||
"issues": [
|
||||
"section.hero > div[1]: missing in rendered (expected <img> #data-overlay, ~620x340)",
|
||||
"section.hero > h1[0]: fontSize is 48px, should be 56px",
|
||||
"section.hero: size 1280x634, should be 1280x792 (Δ22.4%)"
|
||||
],
|
||||
"structured_issues": [...]
|
||||
}
|
||||
```
|
||||
|
||||
The `issues` array is already ranked and human-readable. Use it verbatim in the validate report.
|
||||
|
||||
## Capturing the rendered clone
|
||||
|
||||
For pixel: see `agent-browser-shot`. For structural:
|
||||
|
||||
```
|
||||
dumpRendered({
|
||||
url: "http://localhost:3000/",
|
||||
output: "<workspace>/rendered-dom/<section_id>-<vp>.json",
|
||||
viewport: <width>,
|
||||
scroll_y: <bbox.y - 80>,
|
||||
reduce_motion: <stage===1>
|
||||
})
|
||||
```
|
||||
|
||||
The walker captures the same property set as the source, so the comparison is apples-to-apples.
|
||||
|
||||
## Gate thresholds
|
||||
|
||||
From `clone-staging`:
|
||||
|
||||
| Stage | Pixel | Structural extra check |
|
||||
| ----- | ---------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| 1 | `diff_pct < 5%` at 1280px, reduced motion. Layout shift = 0. | counts.missing == 0 AND counts.tag_mismatch == 0 |
|
||||
| 2 | `diff_pct < 3%` at rest. Hover/focus delta < 4%. | counts.style + counts.size < 5 |
|
||||
| 3 | mean `diff_pct < 8%` across 5 scroll samples. | (animations dominate; structural less reliable) |
|
||||
| 4 | `diff_pct < 15%` on sampled frames. Fallback to MP4 also passes. | n/a |
|
||||
|
||||
A pass requires BOTH pixel and structural to pass at Stages 1 and 2. A pixel pass with `counts.missing > 0` is a fail because the diff distributed across pixels; an early-stage section with a missing 600x400 element will sometimes squeak under a 5% pixel threshold and that is the regression we are guarding against.
|
||||
|
||||
## Diff report format
|
||||
|
||||
Pass:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "pass",
|
||||
"section_id": "hero",
|
||||
"stage": 2,
|
||||
"viewports": {
|
||||
"375": { "diff_pct": 1.8, "structural": { "matched": 60, "missing": 0 } },
|
||||
"1280": { "diff_pct": 1.5, "structural": { "matched": 87, "missing": 0 } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fail:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "fail",
|
||||
"section_id": "hero",
|
||||
"stage": 2,
|
||||
"viewport": 1280,
|
||||
"diff_pct": 7.4,
|
||||
"structural": { "matched": 76, "missing": 2, "tag_mismatch": 0, "style": 5, "size": 3 },
|
||||
"worst_regions": [{ "x": 120, "y": 88, "width": 340, "height": 60 }],
|
||||
"issues": [
|
||||
"section.hero > div[1]: missing in rendered (expected <img> #data-overlay, ~620x340)",
|
||||
"section.hero > h1[0]: fontSize is 48px, should be 56px",
|
||||
"section.hero: size 1280x634, should be 1280x792 (Δ22.4%) — vh-relative; use min-h-screen"
|
||||
],
|
||||
"structured_issues": [...],
|
||||
"diff_image_path": "<workspace>/diffs/hero-1280.png"
|
||||
}
|
||||
```
|
||||
|
||||
The `issues` array is the key payload. 3-6 items, most-impactful first. Each item is a single concrete change.
|
||||
|
||||
## Issue ranking
|
||||
|
||||
`dom-diff` already ranks: missing > tag_mismatch > size_mismatch > style_mismatch > extra. Style mismatches further weight `backgroundImage`, `fontSize`, `color`, `backgroundColor`, `fontFamily` higher than other properties.
|
||||
|
||||
Only override the order if pixel-diff caught something structural missed (color gradients, image content shift, blur/filter changes that don't show in computed styles).
|
||||
|
||||
## Special cases
|
||||
|
||||
### Section flagged as vh_relative
|
||||
|
||||
If `manifest.sections[id].vh_relative === true`, the captured `bounding_box.height` reflects a viewport-height-derived value at capture time. When the rendered clone is wider/taller than the capture viewport, structural size mismatches on the section itself are expected unless the clone uses `min-h-screen`/`h-screen`. **Report the size mismatch with the hint `"vh-relative; use min-h-screen"` so generate knows the fix shape.**
|
||||
|
||||
### Cumulative layout shift
|
||||
|
||||
A Stage 1 failure even if `diff_pct` is below threshold. Detect by comparing the rendered viewport at multiple scroll positions — if elements jump > 4px between initial paint and post-network-idle paint, fail with `issues: ["Layout shift detected: hero image reflows 120px after load"]`.
|
||||
|
||||
### Font swap flicker
|
||||
|
||||
If captured shows the web font and the clone's first paint shows fallback, `next/font/local` is mis-wired. Report — usually fix is `display: "swap"` + correct `preload`.
|
||||
|
||||
### Dark mode
|
||||
|
||||
If the manifest records dark-mode styles, diff both modes. Run with `document.documentElement.classList.add("dark")` (or the captured toggle mechanism) and diff both screenshots and DOMs.
|
||||
|
||||
### Hover / focus states
|
||||
|
||||
At Stage 2, hover interactive elements flagged in capture, wait 200ms, screenshot, diff. Structural diff isn't useful here — pixel only.
|
||||
|
||||
## Iteration cap
|
||||
|
||||
**3 iterations per section per stage.** Orchestrator-enforced. Each call is one-shot: snapshot + diff + report.
|
||||
|
||||
After third failure, orchestrator writes a `manual-review.md` entry with: section id, stage, final component file, final diff image, last issues list. Continue to next section.
|
||||
|
||||
## Do not
|
||||
|
||||
- Do not edit component files. Report, do not fix.
|
||||
- Do not restart the dev server mid-validation — flushes state.
|
||||
- Do not widen `threshold` to make a diff pass.
|
||||
- Do not report "no issues found" with a failing `diff_pct` — if the gate fails, list issues. If structural matched cleanly but pixel still failed: that's a non-DOM divergence (canvas content, video frame, font rendering); say so explicitly: `["Pixel diff fails despite clean structural match — likely canvas/video/font-rendering divergence; consider re-rendering at higher DPR"]`.
|
||||
@@ -0,0 +1,8 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export function resolvePython(worktree: string): string {
|
||||
const venvPython = resolve(worktree, '.opencode/scripts/.venv/bin/python');
|
||||
if (existsSync(venvPython)) return venvPython;
|
||||
return 'python3';
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { tool } from '@opencode-ai/plugin';
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
'Thin wrapper over the agent-browser CLI. Opens the target URL at the requested viewport, optionally scrolls to a section, waits for networkidle plus a settle delay, then saves a cropped PNG screenshot. Used by clone-validate to capture rendered sections for diff against the captured originals.',
|
||||
args: {
|
||||
url: tool.schema.string().describe('URL to open (typically http://localhost:3000/)'),
|
||||
viewport: tool.schema.number().describe('Viewport width in pixels (height is derived 16:9)'),
|
||||
output_path: tool.schema.string().describe('Absolute path to write the PNG to'),
|
||||
scroll_y: tool.schema
|
||||
.number()
|
||||
.nullable()
|
||||
.describe('Absolute Y pixel position to scroll to before screenshot. Omit for no scroll.'),
|
||||
crop: tool.schema
|
||||
.object({
|
||||
x: tool.schema.number(),
|
||||
y: tool.schema.number(),
|
||||
width: tool.schema.number(),
|
||||
height: tool.schema.number(),
|
||||
})
|
||||
.nullable()
|
||||
.describe('Crop region to extract from the viewport screenshot. Omit to keep full viewport.'),
|
||||
reduce_motion: tool.schema
|
||||
.boolean()
|
||||
.nullable()
|
||||
.describe('If true, adds the `reduce-motion` class to <html> before screenshot. Default false.'),
|
||||
settle_ms: tool.schema
|
||||
.number()
|
||||
.nullable()
|
||||
.describe('Extra ms to wait after networkidle before screenshot. Default 500.'),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const { spawn } = await import('node:child_process');
|
||||
const { resolve, dirname } = await import('node:path');
|
||||
const { mkdirSync, existsSync } = await import('node:fs');
|
||||
const { resolvePython } = await import('./_python.js');
|
||||
|
||||
const python = resolvePython(context.worktree);
|
||||
const outPath = resolve(context.worktree, args.output_path);
|
||||
const outDir = dirname(outPath);
|
||||
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const height = Math.round((args.viewport * 9) / 16);
|
||||
const session = `clone-validate-${args.viewport}`;
|
||||
|
||||
const steps: string[][] = [
|
||||
['--session', session, 'set', 'viewport', String(args.viewport), String(height)],
|
||||
['--session', session, 'open', args.url],
|
||||
['--session', session, 'wait', '--load', 'networkidle'],
|
||||
];
|
||||
|
||||
if (args.reduce_motion ?? false) {
|
||||
steps.push(['--session', session, 'eval', 'document.documentElement.classList.add("reduce-motion")']);
|
||||
}
|
||||
|
||||
if (args.scroll_y != null) {
|
||||
steps.push(['--session', session, 'eval', `window.scrollTo({ top: ${args.scroll_y}, behavior: "instant" })`]);
|
||||
}
|
||||
|
||||
steps.push(['--session', session, 'wait', String(args.settle_ms ?? 500)]);
|
||||
steps.push(['--session', session, 'screenshot', outPath]);
|
||||
|
||||
for (const argv of steps) {
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn('agent-browser', argv, { stdio: 'inherit' });
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) resolvePromise();
|
||||
else reject(new Error(`agent-browser ${argv.join(' ')} exited ${code}`));
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
if (args.crop != null) {
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn(
|
||||
python,
|
||||
[
|
||||
'-c',
|
||||
`from PIL import Image; import sys; im=Image.open(sys.argv[1]); im.crop((${args.crop!.x}, ${
|
||||
args.crop!.y
|
||||
}, ${args.crop!.x + args.crop!.width}, ${args.crop!.y + args.crop!.height})).save(sys.argv[1])`,
|
||||
outPath,
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
child.on('exit', (code) => (code === 0 ? resolvePromise() : reject(new Error(`crop failed ${code}`))));
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({ output_path: outPath, viewport: args.viewport });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { tool } from '@opencode-ai/plugin';
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
'Run the Playwright-based site capture pipeline against a target URL. Produces a full capture bundle (DOM + computed styles per scroll position, screenshots per viewport, HAR, animation library dumps, shaders, CSS rules + url() asset refs, fonts, per-section cropped screenshots, alt-height pass for vh detection) at output_dir. Wraps scripts/capture.py. Set replay=true to skip capture entirely and only re-run the post-process (vh-flags + meta.json) against an existing capture bundle — use this when iterating on prompts/agents/skills.',
|
||||
args: {
|
||||
url: tool.schema
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('Absolute target URL, e.g. https://example.com. Required unless replay=true.'),
|
||||
viewports: tool.schema
|
||||
.array(tool.schema.number())
|
||||
.nullable()
|
||||
.describe('Viewport widths in pixels to capture, e.g. [375, 768, 1280, 1920]. Default [375,768,1280,1920].'),
|
||||
output_dir: tool.schema
|
||||
.string()
|
||||
.describe('Directory where the capture bundle is written (absolute path or relative to cwd)'),
|
||||
wait_strategy: tool.schema
|
||||
.enum(['networkidle', 'load', 'domcontentloaded'])
|
||||
.nullable()
|
||||
.describe('Playwright wait strategy for navigation. Default networkidle.'),
|
||||
skip_third_party: tool.schema
|
||||
.boolean()
|
||||
.nullable()
|
||||
.describe('If true, blocks requests to known third-party widget domains. Default true.'),
|
||||
replay: tool.schema
|
||||
.boolean()
|
||||
.nullable()
|
||||
.describe(
|
||||
'If true, skip browser capture entirely and only re-run post-process (vh-flags + meta.json) against existing capture data in output_dir. ~100x faster than a real capture.'
|
||||
),
|
||||
skip_alt_height: tool.schema
|
||||
.boolean()
|
||||
.nullable()
|
||||
.describe('If true, skip the vh-detection alt-height pass at canonical width. Saves ~10s.'),
|
||||
skip_section_shots: tool.schema
|
||||
.boolean()
|
||||
.nullable()
|
||||
.describe('If true, skip the per-section cropped screenshot pass. Saves ~30s.'),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const { spawn } = await import('node:child_process');
|
||||
const { resolve } = await import('node:path');
|
||||
const { readFileSync, existsSync } = await import('node:fs');
|
||||
const { resolvePython } = await import('./_python.js');
|
||||
|
||||
const scriptPath = resolve(context.worktree, '.opencode/scripts/capture.py');
|
||||
const outputDir = resolve(context.worktree, args.output_dir);
|
||||
const python = resolvePython(context.worktree);
|
||||
const replay = args.replay ?? false;
|
||||
|
||||
if (!replay && !args.url) {
|
||||
throw new Error('capture: url is required unless replay=true');
|
||||
}
|
||||
|
||||
const viewports = args.viewports ?? [375, 768, 1280, 1920];
|
||||
|
||||
const cmdArgs = [scriptPath, '--output', outputDir];
|
||||
if (args.url) cmdArgs.push('--url', args.url);
|
||||
if (viewports.length) cmdArgs.push('--viewports', viewports.join(','));
|
||||
cmdArgs.push('--wait-strategy', args.wait_strategy ?? 'networkidle');
|
||||
if (args.skip_third_party ?? true) cmdArgs.push('--skip-third-party');
|
||||
if (replay) cmdArgs.push('--replay');
|
||||
if (args.skip_alt_height ?? false) cmdArgs.push('--skip-alt-height');
|
||||
if (args.skip_section_shots ?? false) cmdArgs.push('--skip-section-shots');
|
||||
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn(python, cmdArgs, { cwd: context.worktree, stdio: 'inherit' });
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) resolvePromise();
|
||||
else reject(new Error(`capture.py exited with code ${code}`));
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
|
||||
const metaPath = resolve(outputDir, 'meta.json');
|
||||
if (!existsSync(metaPath)) {
|
||||
throw new Error(`capture.py did not produce ${metaPath}`);
|
||||
}
|
||||
const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
|
||||
|
||||
return JSON.stringify({
|
||||
status: 'success',
|
||||
mode: replay ? 'replay' : 'capture',
|
||||
capture_dir: outputDir,
|
||||
viewports: meta.viewports ?? viewports,
|
||||
dom_snapshots: meta.dom_snapshots ?? 0,
|
||||
screenshots: meta.screenshots ?? 0,
|
||||
section_shots: meta.section_shots ?? 0,
|
||||
assets_discovered: Array.isArray(meta.assets) ? meta.assets.length : 0,
|
||||
asset_sources: meta.asset_sources ?? null,
|
||||
libs_detected: meta.libs_detected ?? [],
|
||||
vh_relative_count: meta.vh_relative_count ?? 0,
|
||||
canvas_regions: meta.canvas_regions ?? 0,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { tool } from '@opencode-ai/plugin';
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
"Start, stop, or health-check the Next.js dev server for the generated clone project. Use action='health' to check if a server is already responding at url; action='start' to spawn `bun run dev` in project_dir; action='stop' to kill the pid from a prior start.",
|
||||
args: {
|
||||
action: tool.schema.enum(['health', 'start', 'stop']).describe('Which lifecycle action to perform'),
|
||||
project_dir: tool.schema.string().nullable().describe('Path to the generated Next.js project (required for start)'),
|
||||
url: tool.schema.string().nullable().describe('Base URL to health-check. Default http://localhost:3000'),
|
||||
port: tool.schema.number().nullable().describe('Port for `bun run dev` when starting. Default 3000.'),
|
||||
pid: tool.schema.number().nullable().describe('Pid to kill when action=stop'),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const { spawn } = await import('node:child_process');
|
||||
const { resolve } = await import('node:path');
|
||||
const url = args.url ?? 'http://localhost:3000';
|
||||
|
||||
if (args.action === 'health') {
|
||||
try {
|
||||
const res = await fetch(url, { method: 'HEAD' });
|
||||
return JSON.stringify({ healthy: res.ok || res.status === 404, status: res.status, url });
|
||||
} catch {
|
||||
return JSON.stringify({ healthy: false, status: 0, url });
|
||||
}
|
||||
}
|
||||
|
||||
if (args.action === 'start') {
|
||||
if (!args.project_dir) throw new Error('project_dir is required for action=start');
|
||||
const cwd = resolve(context.worktree, args.project_dir);
|
||||
const port = args.port ?? 3000;
|
||||
const child = spawn('bun', ['run', 'dev', '--port', String(port)], {
|
||||
cwd,
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(url, { method: 'HEAD' });
|
||||
if (res.ok || res.status === 404) {
|
||||
return JSON.stringify({ status: 'started', url, pid: child.pid });
|
||||
}
|
||||
} catch {}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
return JSON.stringify({ status: 'timeout', url, pid: child.pid });
|
||||
}
|
||||
|
||||
if (args.action === 'stop') {
|
||||
if (args.pid == null) throw new Error('pid is required for action=stop');
|
||||
try {
|
||||
process.kill(args.pid, 'SIGTERM');
|
||||
return JSON.stringify({ status: 'stopped', pid: args.pid });
|
||||
} catch (err) {
|
||||
return JSON.stringify({ status: 'error', pid: args.pid, error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`unknown action ${args.action}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { tool } from '@opencode-ai/plugin';
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
'Structural diff between two DOM JSON snapshots produced by __CLONE_DUMP_COMPUTED__ — one from the source capture, one from the rendered clone (via dump-rendered). Returns a ranked list of concrete issues (missing elements, wrong dimensions, style mismatches) the generate agent can act on directly. Wraps scripts/dom-diff.py.',
|
||||
args: {
|
||||
captured: tool.schema.string().describe('Path to the captured DOM JSON (e.g. capture/dom/1280/step-00.json)'),
|
||||
rendered: tool.schema.string().describe('Path to the rendered DOM JSON produced by dump-rendered'),
|
||||
root_selector: tool.schema
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
'Optional — scope the diff to a subtree, e.g. "#hero" or ".ecosystem". Omit to diff the whole document.'
|
||||
),
|
||||
max_depth: tool.schema.number().nullable().describe('Recursion depth cap. Default 8.'),
|
||||
max_issues: tool.schema
|
||||
.number()
|
||||
.nullable()
|
||||
.describe('Cap on the number of structured issues returned. Default 200.'),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const { spawn } = await import('node:child_process');
|
||||
const { resolve } = await import('node:path');
|
||||
const { resolvePython } = await import('./_python.js');
|
||||
|
||||
const scriptPath = resolve(context.worktree, '.opencode/scripts/dom-diff.py');
|
||||
const capturedPath = resolve(context.worktree, args.captured);
|
||||
const renderedPath = resolve(context.worktree, args.rendered);
|
||||
const python = resolvePython(context.worktree);
|
||||
|
||||
const cmdArgs = [scriptPath, '--captured', capturedPath, '--rendered', renderedPath];
|
||||
if (args.root_selector) cmdArgs.push('--root-selector', args.root_selector);
|
||||
if (args.max_depth != null) cmdArgs.push('--max-depth', String(args.max_depth));
|
||||
if (args.max_issues != null) cmdArgs.push('--max-issues', String(args.max_issues));
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn(python, cmdArgs, { cwd: context.worktree });
|
||||
child.stdout.on('data', (chunk) => (stdout += chunk.toString()));
|
||||
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0 || code === 2 || code === 3) resolvePromise();
|
||||
else reject(new Error(`dom-diff.py exited ${code}: ${stderr}`));
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
|
||||
const result = JSON.parse(stdout.trim().split('\n').pop() ?? '{}');
|
||||
return JSON.stringify({
|
||||
matched: result.matched ?? 0,
|
||||
counts: result.counts ?? {},
|
||||
issues: result.issues ?? [],
|
||||
structured_issues: result.structured_issues ?? [],
|
||||
error: result.error ?? null,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { tool } from '@opencode-ai/plugin';
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
'Download every asset referenced in the capture bundle into the target Next.js project public directory using hash-based filenames. Wraps scripts/download-assets.py. Returns lists of downloaded, failed, and skipped URLs.',
|
||||
args: {
|
||||
manifest_path: tool.schema
|
||||
.string()
|
||||
.describe('Path to meta.json from capture (or manifest.json after analyze) — whichever has assets[]'),
|
||||
project_public_dir: tool.schema
|
||||
.string()
|
||||
.describe('Path to <project>/public/assets/cloned where assets are written'),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const { spawn } = await import('node:child_process');
|
||||
const { resolve } = await import('node:path');
|
||||
const { resolvePython } = await import('./_python.js');
|
||||
|
||||
const scriptPath = resolve(context.worktree, '.opencode/scripts/download-assets.py');
|
||||
const manifestPath = resolve(context.worktree, args.manifest_path);
|
||||
const publicDir = resolve(context.worktree, args.project_public_dir);
|
||||
const python = resolvePython(context.worktree);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn(python, [scriptPath, '--manifest', manifestPath, '--public-dir', publicDir, '--json'], {
|
||||
cwd: context.worktree,
|
||||
});
|
||||
child.stdout.on('data', (chunk) => (stdout += chunk.toString()));
|
||||
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) resolvePromise();
|
||||
else reject(new Error(`download-assets.py exited with code ${code}: ${stderr}`));
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
|
||||
const result = JSON.parse(stdout.trim().split('\n').pop() ?? '{}');
|
||||
return JSON.stringify({
|
||||
downloaded: result.downloaded ?? [],
|
||||
failed: result.failed ?? [],
|
||||
skipped: result.skipped ?? [],
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { tool } from '@opencode-ai/plugin';
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
'Open a URL (typically the local dev server at http://localhost:3000) and dump its DOM + computed styles using the same __CLONE_DUMP_COMPUTED__ walker the source capture pipeline uses. Produces a directly-comparable structural snapshot the validate agent can pass to dom-diff. Wraps scripts/dump-rendered.py.',
|
||||
args: {
|
||||
url: tool.schema.string().describe('URL to dump, typically http://localhost:3000/'),
|
||||
output: tool.schema.string().describe('Path to write the rendered DOM JSON (the section snapshot file)'),
|
||||
viewport: tool.schema.number().nullable().describe('Viewport width in px. Default 1280.'),
|
||||
viewport_height: tool.schema
|
||||
.number()
|
||||
.nullable()
|
||||
.describe('Viewport height in px. Default = 16:9 of viewport width.'),
|
||||
scroll_y: tool.schema.number().nullable().describe('Y position to scroll to before dumping. Default 0.'),
|
||||
reduce_motion: tool.schema
|
||||
.boolean()
|
||||
.nullable()
|
||||
.describe('Add reduce-motion class to <html> before dumping (matches the Stage 1 validate gate). Default false.'),
|
||||
settle_ms: tool.schema.number().nullable().describe('Extra ms to wait after scroll before dumping. Default 800.'),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const { spawn } = await import('node:child_process');
|
||||
const { resolve } = await import('node:path');
|
||||
const { resolvePython } = await import('./_python.js');
|
||||
|
||||
const scriptPath = resolve(context.worktree, '.opencode/scripts/dump-rendered.py');
|
||||
const outPath = resolve(context.worktree, args.output);
|
||||
const python = resolvePython(context.worktree);
|
||||
|
||||
const cmdArgs = [scriptPath, '--url', args.url, '--output', outPath];
|
||||
if (args.viewport != null) cmdArgs.push('--viewport', String(args.viewport));
|
||||
if (args.viewport_height != null) cmdArgs.push('--viewport-height', String(args.viewport_height));
|
||||
if (args.scroll_y != null) cmdArgs.push('--scroll-y', String(args.scroll_y));
|
||||
if (args.reduce_motion ?? false) cmdArgs.push('--reduce-motion');
|
||||
if (args.settle_ms != null) cmdArgs.push('--settle-ms', String(args.settle_ms));
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn(python, cmdArgs, { cwd: context.worktree });
|
||||
child.stdout.on('data', (chunk) => (stdout += chunk.toString()));
|
||||
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) resolvePromise();
|
||||
else reject(new Error(`dump-rendered.py exited ${code}: ${stderr}`));
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
|
||||
const result = JSON.parse(stdout.trim().split('\n').pop() ?? '{}');
|
||||
return JSON.stringify({
|
||||
status: result.status ?? 'success',
|
||||
output: result.output ?? outPath,
|
||||
sections_path: result.sections_path,
|
||||
section_count: result.section_count ?? 0,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { tool } from '@opencode-ai/plugin';
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
'Pixel-diff two PNG screenshots using pixelmatch. Returns diff percentage, path to a diff image with changed pixels highlighted, and bounding boxes of the worst regions (top connected components of changed pixels).',
|
||||
args: {
|
||||
before: tool.schema.string().describe('Path to the captured original PNG'),
|
||||
after: tool.schema.string().describe('Path to the rendered clone PNG'),
|
||||
threshold: tool.schema.number().nullable().describe('pixelmatch per-pixel color tolerance 0-1. Default 0.1.'),
|
||||
diff_out: tool.schema
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('Path to write the diff PNG to. Default: same dir as after with -diff suffix.'),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const { spawn } = await import('node:child_process');
|
||||
const { resolve, dirname, basename, extname, join } = await import('node:path');
|
||||
const { resolvePython } = await import('./_python.js');
|
||||
|
||||
const scriptPath = resolve(context.worktree, '.opencode/scripts/diff.py');
|
||||
const beforePath = resolve(context.worktree, args.before);
|
||||
const afterPath = resolve(context.worktree, args.after);
|
||||
const python = resolvePython(context.worktree);
|
||||
const diffOut =
|
||||
args.diff_out != null
|
||||
? resolve(context.worktree, args.diff_out)
|
||||
: join(dirname(afterPath), `${basename(afterPath, extname(afterPath))}-diff.png`);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn(
|
||||
python,
|
||||
[
|
||||
scriptPath,
|
||||
'--before',
|
||||
beforePath,
|
||||
'--after',
|
||||
afterPath,
|
||||
'--diff-out',
|
||||
diffOut,
|
||||
'--threshold',
|
||||
String(args.threshold ?? 0.1),
|
||||
'--json',
|
||||
],
|
||||
{ cwd: context.worktree }
|
||||
);
|
||||
child.stdout.on('data', (chunk) => (stdout += chunk.toString()));
|
||||
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) resolvePromise();
|
||||
else reject(new Error(`diff.py exited with code ${code}: ${stderr}`));
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
|
||||
const result = JSON.parse(stdout.trim().split('\n').pop() ?? '{}');
|
||||
return JSON.stringify({
|
||||
diff_pct: result.diff_pct ?? 100,
|
||||
diff_image_path: diffOut,
|
||||
worst_regions: result.worst_regions ?? [],
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here. The format is based on
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project is pre-1.0,
|
||||
so minor/patch semantics are not yet guaranteed.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added — Service layer (REST + MCP API)
|
||||
|
||||
A hosted service around the deterministic compiler, as an npm-workspaces monorepo
|
||||
(`packages/*`). The compiler's clone behavior is unchanged — only a library boundary
|
||||
was added.
|
||||
|
||||
- **`packages/core`** — the sole compiler adapter: `runCloneJob` (temp-dir lifecycle,
|
||||
optional verify), `collectFileMap`, `cacheKey`.
|
||||
- **`packages/db`** — Drizzle schema + migrations (jobs/clones/cache/apiKeys), repo,
|
||||
and a pg-boss queue.
|
||||
- **`packages/storage`** — `ArtifactStore` (local disk or S3/R2 via presigned URLs),
|
||||
deterministic `tgz`/`zip` bundles.
|
||||
- **`packages/api`** — Hono app: REST routes + MCP (Streamable-HTTP), API-key auth,
|
||||
rate limiting, and SSRF protection.
|
||||
- **`packages/worker`** — queue consumer with isolated per-worker `verify` build
|
||||
harness; supports multi-page (`clone_site`) jobs.
|
||||
- Freshness-bounded caching (`CACHE_STALE_AFTER`, `noCache`), Docker images +
|
||||
`docker-compose` (Postgres + MinIO), CI, and Railway/Neon/R2 deploy docs.
|
||||
- Root CLI passthrough scripts so the compiler CLI runs from the repo root too.
|
||||
|
||||
See [`docs/SERVICE.md`](docs/SERVICE.md) and [`README.md`](README.md).
|
||||
|
||||
### Changed — Component extraction keeps styling out of the content model
|
||||
|
||||
- Extracted components no longer pull per-instance `className` strings into the editable
|
||||
content model (`content.ts`). When a node's class varies across instances, the tokens
|
||||
common to all instances are baked into the component skeleton and only the per-instance
|
||||
**diff** is emitted — to a separate `_styles.ts` plumbing module (alongside `_cids.ts`),
|
||||
merged back at render time with a generated `cn()` helper. `content.ts` now holds only
|
||||
semantic fields (text, href, src, alt, …). Render-identical (token order doesn't affect
|
||||
computed style); gates 0–6 and site/determinism unaffected. Swap `cn` for `tailwind-merge`
|
||||
if you want conflict-aware merging when hand-editing the overrides.
|
||||
|
||||
### Notes
|
||||
|
||||
- The deterministic compiler predates this changelog; the current architecture is
|
||||
summarized in [`README.md`](README.md), with operational service details in
|
||||
[`docs/SERVICE.md`](docs/SERVICE.md).
|
||||
@@ -0,0 +1,132 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
**samraaj@ion.design**.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
@@ -0,0 +1,60 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for your interest! This repo is a deterministic website **compiler**
|
||||
(`compiler/`) plus a hosted **service layer** (`packages/`). The benchmarks and
|
||||
fixtures double as the regression suite.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install # installs all workspaces
|
||||
npx playwright install chromium # for capture / browser-gated tests
|
||||
```
|
||||
|
||||
## Develop
|
||||
|
||||
- **Service layer** lives in `packages/*` (TypeScript, run via `tsx`, no build step).
|
||||
- `npm run typecheck` — type-checks every workspace.
|
||||
- `npm test` — runs every workspace's tests (node:test). Tests gate themselves:
|
||||
browser tests skip without Chromium; Postgres tests use `TEST_DATABASE_URL` or a
|
||||
throwaway local Postgres (root only).
|
||||
- Start locally: `docker compose up -d` then `npm run dev:api` / `npm run dev:worker`
|
||||
(see [`docs/SERVICE.md`](docs/SERVICE.md)).
|
||||
- **Compiler** lives in `compiler/`. See the root [`README.md`](README.md) for the
|
||||
architecture overview.
|
||||
The service layer depends on it **only** through `compiler/src/index.ts` (the
|
||||
library barrel) — do not import compiler internals from `packages/*`.
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Don't change the compiler's clone semantics** from the service layer. The
|
||||
service is a wrapper; clone output must stay byte-deterministic (rubric Gate 6).
|
||||
Golden-file tests rely on this.
|
||||
- Every change should keep `npm run typecheck` and `npm test` green.
|
||||
- Database schema changes: edit `packages/db/src/schema.ts`, then
|
||||
`npm run db:generate` to produce a migration, and commit it.
|
||||
- Keep new code in the style of the surrounding code (naming, comments, idiom).
|
||||
|
||||
## Database migrations
|
||||
|
||||
```bash
|
||||
npm run db:generate # after editing the Drizzle schema → writes packages/db/migrations/*
|
||||
npm run db:migrate # applies to $DATABASE_URL
|
||||
```
|
||||
|
||||
## Pull requests
|
||||
|
||||
1. Fork / branch off `main`.
|
||||
2. Make your change with tests; keep `npm run typecheck` and `npm test` green.
|
||||
3. Open a PR and fill in the [PR template](.github/pull_request_template.md). CI
|
||||
(typecheck + the full suite, with Postgres + Chromium) must pass.
|
||||
4. If your change touches the deterministic compiler's clone output, say so and
|
||||
include benchmark results (`npm run bench`).
|
||||
|
||||
By participating you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## Reporting bugs & security issues
|
||||
|
||||
- **Bugs / features:** open an issue using the templates.
|
||||
- **Security vulnerabilities:** do **not** open a public issue — follow
|
||||
[`SECURITY.md`](SECURITY.md) (private GitHub advisory).
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ion-design and ditto.site contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,333 @@
|
||||
# ditto.site
|
||||
|
||||
[](https://github.com/ion-design/ditto.site/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
[](.nvmrc)
|
||||
|
||||
ditto.site is a deterministic website compiler. Given a public URL, it compiles the
|
||||
observed rendered site into a modern TypeScript app, defaulting to Next.js App
|
||||
Router with an option for Vite React, using
|
||||
browser-captured evidence: DOM, computed styles, layout boxes, CSS rules, fonts,
|
||||
assets, source metadata, screenshots, interaction states, and motion specs where
|
||||
they can be reproduced safely.
|
||||
|
||||
The compiler is not an LLM page author. The normal clone path is a pure
|
||||
generation pipeline over a frozen capture, so the same capture produces
|
||||
byte-stable output. The generated app uses `ditto` naming for clone-specific
|
||||
runtime helpers and documentation.
|
||||
|
||||
## What It Produces
|
||||
|
||||
A generated app is a self-contained project under `generated/app/` during
|
||||
validation and under `<out>/<site>/app` for delivery. The default framework is
|
||||
Next.js App Router:
|
||||
|
||||
- `src/app/layout.tsx`: root layout, captured language, Next metadata, viewport,
|
||||
JSON-LD, and shared multi-route shell.
|
||||
- `src/app/page.tsx` and route pages: JSX reconstructed from the captured render
|
||||
tree.
|
||||
- `src/app/globals.css`: reset, font faces, design tokens, Tailwind setup when
|
||||
enabled.
|
||||
- `src/app/ditto.css`: clone-specific CSS that still needs stylesheet emission,
|
||||
including pseudo-elements, keyframes, raw CSS fallbacks, and non-Tailwind
|
||||
fidelity rules.
|
||||
- `src/app/content.ts` or `content.tsx`: semantic editable data when repeated
|
||||
regions are promoted into components or sections.
|
||||
- `src/app/components/`, `src/app/sections/`, `src/app/svgs/`: generated modules
|
||||
split from repeated components, page sections, and inline SVGs.
|
||||
- `src/app/ditto/`: small generated runtime helpers for recognized interactions,
|
||||
accordions, dropdown menus, and reproducible motion.
|
||||
- `src/app/robots.ts`, `src/app/sitemap.ts`, `src/app/llms.txt/route.ts`, and
|
||||
sometimes `src/app/llms-full.txt/route.ts`.
|
||||
- App Router file-based icons such as `src/app/favicon.ico`, `src/app/icon.png`,
|
||||
and `src/app/apple-icon.png` when the source exposes them.
|
||||
- `public/assets/cloned/`: materialized images, fonts, manifest files, manifest
|
||||
icons/screenshots, and other source assets needed by the clone.
|
||||
- `AGENTS.md` and `ARCHITECTURE.md`: generated documentation for the delivered
|
||||
app, derived from clone metadata.
|
||||
|
||||
With `--framework=vite` or API option `framework: "vite"`, the generated app is
|
||||
a Vite React project. Single-page output uses `index.html`, `src/main.tsx`,
|
||||
`src/page.tsx`, `src/globals.css`, and `src/ditto.css`. Multi-route Vite output
|
||||
is a Vite multi-page app with one HTML entry per cloned route and route modules
|
||||
under `src/routes/<routeKey>/`.
|
||||
|
||||
Validation builds keep internal `data-cid` attributes so gates can align source
|
||||
and clone nodes exactly. The exported app strips validation-only ids and only
|
||||
keeps deterministic `data-ditto-id` anchors where generated CSS or runtime
|
||||
recipes still need a stable target.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
|
||||
# Single-page clone, Tailwind output by default.
|
||||
npm run clone -- https://example.com/
|
||||
|
||||
# Explicit product choices.
|
||||
npm run clone -- https://example.com/ --mode=single --styling=tailwind
|
||||
npm run clone -- https://example.com/ --mode=single --styling=css
|
||||
npm run clone -- https://example.com/ --mode=multi --styling=tailwind
|
||||
npm run clone -- https://example.com/ --mode=multi --styling=css
|
||||
npm run clone -- https://example.com/ --mode=single --framework=vite
|
||||
npm run clone -- https://example.com/ --mode=multi --framework=vite
|
||||
|
||||
# Deliver into a clean app directory.
|
||||
npm run clone -- https://example.com/ --out=./output
|
||||
|
||||
# Benchmark and validation helpers.
|
||||
npm run bench -- --tier=easy
|
||||
npm run bench -- --tier=stage2 --reuse
|
||||
npm run bench-site
|
||||
npm run validate-site -- runs/site-example.com/<timestamp>
|
||||
|
||||
# Faster multi-page runs on larger sites. Validation is opt-in.
|
||||
npm run clone -- https://example.com/ --mode=multi --concurrency=5
|
||||
npm run clone -- https://example.com/ --mode=multi --validate --validate-concurrency=3 --viewport-concurrency=2
|
||||
npm run validate-site -- runs/site-example.com/<timestamp> --validate-concurrency=3 --viewport-concurrency=2
|
||||
```
|
||||
|
||||
The root scripts forward into the `compiler` workspace. Running the same commands
|
||||
from `compiler/` also works.
|
||||
|
||||
Multi-page generation defaults to the fast no-validation path. For production
|
||||
delivery, keep first response and QA as separate phases: run single-page first,
|
||||
expand to multi-page with default CLI behavior or service `verify:false`, then
|
||||
run strict validation separately. Use service `asyncVerify:true` when a DB worker
|
||||
should persist the clone first and attach the verify report afterward. Capture
|
||||
route parallelism is controlled by `--concurrency`; validation route and viewport
|
||||
parallelism are controlled by `--validate-concurrency` and
|
||||
`--viewport-concurrency`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
URL
|
||||
-> capture
|
||||
-> normalize render IR
|
||||
-> infer assets, fonts, sections, recipes, SEO, metadata
|
||||
-> generate app (Next by default, Vite optional)
|
||||
-> materialize assets
|
||||
-> build and validate
|
||||
-> export delivery app
|
||||
```
|
||||
|
||||
### 1. Capture
|
||||
|
||||
Capture is Playwright-based. A page is loaded once and resized through the target
|
||||
viewports, normally `375`, `768`, `1280`, and `1920`, so responsive snapshots are
|
||||
aligned to the same page state instead of four unrelated reloads.
|
||||
|
||||
The in-page walker records:
|
||||
|
||||
- DOM tree shape and text.
|
||||
- Curated computed styles.
|
||||
- Document-coordinate bounding boxes.
|
||||
- Per-viewport visibility, scroll dimensions, and page backgrounds.
|
||||
- Pseudo-element content and styles.
|
||||
- Inline SVG markup.
|
||||
- Source attributes that are safe and meaningful, including accessibility
|
||||
attributes.
|
||||
- Head metadata, link tags, JSON-LD, icons, manifest links, alternates, and SEO
|
||||
discovery resources.
|
||||
- CSS, fonts, images, SVGs, videos, manifests, and other linked assets.
|
||||
|
||||
Capture also handles common state problems before the snapshot:
|
||||
|
||||
- Cookie, consent, newsletter, and modal overlays are dismissed when a safe
|
||||
deterministic rule recognizes them.
|
||||
- Scroll-locked and iframe-backed overlays are removed only when they match the
|
||||
blocker patterns.
|
||||
- Poster-less videos get a representative still when possible.
|
||||
- Lazy background images and responsive assets are backfilled from CSS, DOM, and
|
||||
manifest evidence.
|
||||
- Interaction capture stamps temporary `data-cid-cap` ids in the browser,
|
||||
explores recognized controls, and records state deltas.
|
||||
- Motion capture extracts declarative CSS keyframes, WAAPI effects, rotating text,
|
||||
scroll reveals, and marquee-like tracks when they can be replayed deterministically.
|
||||
|
||||
### 2. Normalize
|
||||
|
||||
The normalizer merges viewport snapshots into one render IR. Each node receives a
|
||||
stable pre-order id and carries per-viewport style, box, and visibility data.
|
||||
Temporary capture ids are kept only as needed for interaction and motion mapping.
|
||||
|
||||
The IR is intentionally close to rendered browser facts. Higher-level intent is
|
||||
inferred later so fidelity gates can always fall back to measured evidence when a
|
||||
semantic guess is uncertain.
|
||||
|
||||
### 3. Infer
|
||||
|
||||
Inference is deterministic and local to the capture:
|
||||
|
||||
- Sections are detected from rendered structure and visible hierarchy.
|
||||
- Design tokens are extracted from repeated color, spacing, and type values.
|
||||
- Semantic color roles are assigned where source evidence and usage make them
|
||||
stable enough.
|
||||
- Primitive roles identify headings, links, buttons, images, icons, nav, badges,
|
||||
forms, and related UI pieces.
|
||||
- Asset and font graphs classify linked files, download them content-addressably,
|
||||
rewrite references, and preserve fallbacks.
|
||||
- Recipe inference recognizes repeated card grids, feature grids, product grids,
|
||||
logo clouds, galleries, navs, and other common section patterns.
|
||||
- Interaction recipes map captured deltas to small runtime helpers rather than
|
||||
replaying arbitrary site JavaScript.
|
||||
- Motion recipes emit reproducible templates for the safe declarative families
|
||||
and freeze unsupported motion honestly.
|
||||
- SEO inventory records source title, description, keywords, canonical URL,
|
||||
robots/referrer/theme-color/color-scheme, Open Graph, Twitter cards, icons,
|
||||
manifest links and assets, alternates/hreflang, JSON-LD, robots/sitemap links,
|
||||
and `llms.txt` or `llms-full.txt` when discoverable.
|
||||
- Code quality inventory records module organization, generated component split,
|
||||
content-model extraction, metadata, and markup hygiene.
|
||||
|
||||
### 4. Generate
|
||||
|
||||
The generator emits a Next.js App Router app by default, or a Vite React app when
|
||||
`framework` is `vite`. Tailwind v4 is the default styling mode; plain CSS remains
|
||||
available.
|
||||
|
||||
In Tailwind mode, most exact geometry, typography, display, and spacing becomes
|
||||
utility classes. Values that do not belong in class names, pseudo-elements,
|
||||
keyframes, and recipe/runtime selectors stay in `ditto.css`. In CSS mode, shared
|
||||
semantic classes and per-rule stylesheet emission preserve the same computed
|
||||
style contract.
|
||||
|
||||
The generator also:
|
||||
|
||||
- Splits clean page regions into `sections/`.
|
||||
- Extracts repeated DOM skeletons into `components/`.
|
||||
- Keeps editable semantic data in `content.ts`, while validation ids and
|
||||
per-instance class overrides stay in generated plumbing modules.
|
||||
- Hoists inline SVGs into `svgs/` where that improves readability.
|
||||
- Emits `ditto` runtime utilities only for recognized recipes that need them.
|
||||
- Rewrites same-origin internal links for generated routes.
|
||||
- Emits framework-appropriate metadata, JSON-LD, robots, sitemap, `llms.txt`,
|
||||
icons, web manifests, and manifest assets from the SEO inventory.
|
||||
- Emits generated `AGENTS.md` and `ARCHITECTURE.md` for the delivered app.
|
||||
|
||||
### 5. Multi-Route Cloning
|
||||
|
||||
Multi-route mode starts at one entry URL, crawls same-origin links, groups routes
|
||||
by deterministic URL templates, and applies a CMS/template boundary:
|
||||
|
||||
- Singletons are reproduced.
|
||||
- Pairs are reproduced because there is not enough evidence to collapse them.
|
||||
- Larger collections are represented by the listing and one representative route.
|
||||
- The full instance list is recorded as a handoff boundary rather than cloning
|
||||
every CMS item.
|
||||
|
||||
Each selected route is captured separately. The site generator then emits one app
|
||||
with shared assets, shared tokens, link rewriting, optional shared header/footer
|
||||
chrome, and route-level pages. A multi-route job can reuse a prior single-page
|
||||
entry capture to return the first page quickly and expand later.
|
||||
|
||||
### 6. Validate
|
||||
|
||||
Validation builds and serves the generated app, captures it with the same walker,
|
||||
and grades deterministic gates:
|
||||
|
||||
- Build and static export success.
|
||||
- Capture sanity: the source was not an empty shell, bot wall, or polluted overlay
|
||||
state.
|
||||
- Asset and font materialization.
|
||||
- DOM shape and valid retags.
|
||||
- Computed-style fidelity.
|
||||
- Layout boxes, section positions, page dimensions, and responsive behavior.
|
||||
- Byte determinism from regenerating the same frozen capture.
|
||||
- Perceptual screenshot similarity.
|
||||
- Interaction recipe behavior for menus, tabs, accordions, carousels, modals,
|
||||
hover, and focus states that were captured.
|
||||
- Motion reproduction for supported declarative families.
|
||||
- Site-level link integrity and site determinism for multi-route output.
|
||||
- Output quality, including componentization, naming, content extraction,
|
||||
styling organization, and metadata hygiene.
|
||||
|
||||
The gates are intended to be deterministic grading functions. Failures should
|
||||
produce a reproducible artifact and a narrow compiler improvement, not a manual
|
||||
one-off patch to an output app.
|
||||
|
||||
## SEO And Documentation Layer
|
||||
|
||||
ditto.site treats source metadata as part of the clone contract. The current
|
||||
generator preserves source-provided metadata in the generated framework shell
|
||||
where possible, materializes linked icons and manifest assets, preserves JSON-LD
|
||||
with safe script emission, and generates or preserves `llms.txt`.
|
||||
|
||||
If a source exposes `llms.txt` or `llms-full.txt`, those files are preserved as
|
||||
static routes. If not, the generator creates a concise `llms.txt` from captured
|
||||
route titles, descriptions, and visible content summaries. The generated app also
|
||||
receives root `AGENTS.md` and `ARCHITECTURE.md` files that explain the app
|
||||
structure, safe edit zones, `src/app/ditto`, `content.ts`, components, sections,
|
||||
SVG modules, `ditto.css`, and `ditto-meta.ts`.
|
||||
|
||||
SEO inventory metrics are written beside generated artifacts as `seo.json` and
|
||||
`seo.md` so coverage can be inspected without changing output behavior.
|
||||
|
||||
## Service Layer
|
||||
|
||||
The hosted service wraps the compiler without changing clone semantics:
|
||||
|
||||
```
|
||||
compiler/ # capture, IR, inference, generation, validation
|
||||
packages/core/ # compiler adapter and file-map collection
|
||||
packages/db/ # Drizzle schema, migrations, repository, queue wrapper
|
||||
packages/storage/ # local and S3/R2 artifact storage
|
||||
packages/api/ # Hono REST API and MCP server
|
||||
packages/worker/ # queued clone runner and optional verify harness
|
||||
packages/test-utils/ # fixture server and integration-test helpers
|
||||
```
|
||||
|
||||
REST accepts a URL and clone options, then returns either an inline result or an
|
||||
async job id depending on whether a database queue is configured. The MCP server
|
||||
uses a list-then-read model: agents get job metadata and file manifests first,
|
||||
then request only the files they need.
|
||||
|
||||
See [docs/SERVICE.md](docs/SERVICE.md) and [docs/DEPLOY.md](docs/DEPLOY.md) for
|
||||
the operational API and deployment details.
|
||||
|
||||
## Repository Map
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `compiler/` | deterministic clone compiler |
|
||||
| `compiler/src/capture/` | Playwright capture, walker, assets, SEO resources, interactions, motion |
|
||||
| `compiler/src/normalize/` | render IR construction |
|
||||
| `compiler/src/infer/` | sections, tokens, assets, fonts, recipes, primitives |
|
||||
| `compiler/src/generate/` | app generation, SEO/docs layer, code quality reports |
|
||||
| `compiler/src/site/` | multi-route crawl/generate/validate flow |
|
||||
| `compiler/src/validate/` | fidelity, perceptual, interaction, motion, and determinism gates |
|
||||
| `compiler/benchmarks/` | benchmark URL lists |
|
||||
| `packages/` | REST, MCP, queue, storage, and service adapters |
|
||||
| `docs/SERVICE.md` | service architecture and API reference |
|
||||
| `docs/DEPLOY.md` | Railway, Neon, and R2 deployment guide |
|
||||
| `examples/` | benchmark result summaries, composites, motion evidence, and small runnable outputs |
|
||||
|
||||
## Deferred Work
|
||||
|
||||
ditto.site intentionally does not attempt arbitrary JavaScript replay. It does not
|
||||
recreate full third-party applications, live personalization, auth, payments,
|
||||
analytics behavior, or remote iframe internals. It can preserve static scaffolding
|
||||
around those regions and may emit placeholders when that is the more faithful
|
||||
self-contained representation.
|
||||
|
||||
Video-like animation replay, scroll-scrubbed canvases, WebGL, and finished
|
||||
entrance animations remain outside the deterministic contract unless a safe,
|
||||
observable recipe exists. Unsupported motion is frozen rather than shipped as a
|
||||
broken imitation.
|
||||
|
||||
## Contributing
|
||||
|
||||
Use `npm run typecheck` and `npm test` before opening a PR. Browser tests require
|
||||
Chromium; Postgres-backed tests use `TEST_DATABASE_URL` or the local compose
|
||||
stack. Changes that alter compiler output should include focused fixture or
|
||||
benchmark evidence.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), and
|
||||
[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) © ion-design and contributors.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please do not open a public issue for security vulnerabilities.**
|
||||
|
||||
Report them privately via GitHub's [private vulnerability reporting](https://github.com/ion-design/ditto.site/security/advisories/new)
|
||||
("Security" tab → "Report a vulnerability"), or email **samraaj@ion.design**. We'll
|
||||
acknowledge within a few business days and keep you updated on the fix.
|
||||
|
||||
## Supported versions
|
||||
|
||||
This project is pre-1.0; security fixes land on `main`. Pin a commit if you need
|
||||
stability.
|
||||
|
||||
## Operating a public clone endpoint (read this before deploying)
|
||||
|
||||
The service is a **"fetch any URL"** system, so a misconfigured deployment can be
|
||||
abused. The codebase ships defenses — keep them on:
|
||||
|
||||
- **SSRF protection** (`packages/api/src/ssrf.ts`): every submitted URL is
|
||||
validated and its resolved IPs are checked against private / loopback /
|
||||
link-local / cloud-metadata (`169.254.169.254`) / reserved ranges **after DNS
|
||||
resolution** (covers DNS-rebinding). It's enabled by default — do **not** set
|
||||
`SSRF_DISABLE=true` or `SSRF_ALLOW_LOOPBACK=true` in production.
|
||||
- **API-key auth + rate limits**: set `API_KEYS` and `RATE_LIMIT_PER_MINUTE`.
|
||||
- **Per-job caps**: the compiler bounds every capture wait; the queue enforces
|
||||
retries/timeouts. Size worker memory for headless Chromium (~0.5–1 GB/clone).
|
||||
- **Capture sanity**: degenerate/bot-walled captures are flagged
|
||||
(`capture.pollution` / `capture.blocked`) rather than served as success.
|
||||
|
||||
See [`docs/DEPLOY.md`](docs/DEPLOY.md) and [`docs/SERVICE.md`](docs/SERVICE.md).
|
||||
|
||||
## Dependency advisories
|
||||
|
||||
Dependencies are monitored by [Dependabot](.github/dependabot.yml). One known item:
|
||||
the compiler's build harness and the **generated** app pin `next@14.2.21`, which has
|
||||
a published advisory. Bumping it should be paired with a benchmark re-run (it affects
|
||||
`next build` and the emitted `package.json`), so it's tracked rather than auto-applied.
|
||||
@@ -0,0 +1,99 @@
|
||||
// Local capture bridge: Chromium → (this HTTP proxy, TLS-terminated locally) → Node
|
||||
// CONNECT-tunnel through the agent proxy → origin. Exists because Chromium 141's TLS
|
||||
// ClientHello is closed by the egress proxy during the handshake, while Node/curl/openssl
|
||||
// ClientHellos are accepted. Chromium talks plaintext-after-TLS to us (we present a static
|
||||
// self-signed cert; capture runs with ignoreHTTPSErrors so host/CA mismatch is fine), and
|
||||
// WE re-originate every request over a Node TLS socket the proxy is happy with.
|
||||
import net from "node:net";
|
||||
import http from "node:http";
|
||||
import tls from "node:tls";
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const KEY = fs.readFileSync(join(DIR, "key.pem"));
|
||||
const CERT = fs.readFileSync(join(DIR, "cert.pem"));
|
||||
const CA = fs.readFileSync("/root/.ccr/ca-bundle.crt");
|
||||
const LISTEN_PORT = Number(process.env.BRIDGE_PORT || 8899);
|
||||
|
||||
// The real egress proxy we tunnel through (the one Chromium can't TLS-handshake with).
|
||||
const upstream = new URL(process.env.UPSTREAM_PROXY || "http://127.0.0.1:37671");
|
||||
const UP_HOST = upstream.hostname;
|
||||
const UP_PORT = Number(upstream.port || 80);
|
||||
|
||||
/** Open a socket to `host:port` by CONNECT-tunnelling through the agent proxy, then (for
|
||||
* 443) upgrading to TLS that the proxy accepts. Calls back with the ready socket. */
|
||||
function tunnel(host, port, cb) {
|
||||
const raw = net.connect(UP_PORT, UP_HOST);
|
||||
let settled = false;
|
||||
const fail = (e) => { if (!settled) { settled = true; cb(e); } raw.destroy(); };
|
||||
raw.once("error", fail);
|
||||
raw.on("connect", () => {
|
||||
raw.write(`CONNECT ${host}:${port} HTTP/1.1\r\nHost: ${host}:${port}\r\n\r\n`);
|
||||
});
|
||||
let buf = Buffer.alloc(0);
|
||||
const onData = (d) => {
|
||||
buf = Buffer.concat([buf, d]);
|
||||
const i = buf.indexOf("\r\n\r\n");
|
||||
if (i < 0) return;
|
||||
raw.removeListener("data", onData);
|
||||
const status = buf.slice(0, buf.indexOf("\r\n")).toString();
|
||||
if (!/ 200 /.test(status)) return fail(new Error(`upstream CONNECT ${host}:${port} → ${status}`));
|
||||
raw.removeListener("error", fail);
|
||||
if (port === 443) {
|
||||
const t = tls.connect({ socket: raw, servername: host, ca: CA, ALPNProtocols: ["http/1.1"] }, () => {
|
||||
if (!settled) { settled = true; cb(null, t); }
|
||||
});
|
||||
t.once("error", (e) => { if (!settled) { settled = true; cb(e); } });
|
||||
} else {
|
||||
if (!settled) { settled = true; cb(null, raw); }
|
||||
}
|
||||
};
|
||||
raw.on("data", onData);
|
||||
}
|
||||
|
||||
// An internal HTTP server that PARSES the plaintext requests Chromium sends us (after we
|
||||
// TLS-terminate its tunnel) and re-issues them to the origin over a fresh upstream tunnel.
|
||||
const origin = http.createServer((creq, cres) => {
|
||||
const host = creq.socket.__host;
|
||||
const opts = {
|
||||
method: creq.method,
|
||||
path: creq.url,
|
||||
headers: { ...creq.headers, host: creq.headers.host || host, connection: "close" },
|
||||
createConnection: (_o, cb) => tunnel(host, 443, cb),
|
||||
};
|
||||
const preq = http.request(opts, (pres) => {
|
||||
cres.writeHead(pres.statusCode || 502, pres.headers);
|
||||
pres.pipe(cres);
|
||||
});
|
||||
preq.once("error", (e) => { if (!cres.headersSent) cres.writeHead(502); cres.end("bridge upstream error: " + e.message); });
|
||||
creq.pipe(preq);
|
||||
});
|
||||
origin.on("clientError", (_e, sock) => sock.destroy());
|
||||
|
||||
const proxy = http.createServer((req, res) => {
|
||||
// Plain-HTTP proxied request (rare for these sites): forward host:80 through the tunnel.
|
||||
const u = new URL(req.url);
|
||||
const opts = { method: req.method, path: u.pathname + u.search, headers: { ...req.headers, connection: "close" },
|
||||
createConnection: (_o, cb) => tunnel(u.hostname, Number(u.port || 80), cb) };
|
||||
const preq = http.request(opts, (pres) => { res.writeHead(pres.statusCode || 502, pres.headers); pres.pipe(res); });
|
||||
preq.once("error", (e) => { if (!res.headersSent) res.writeHead(502); res.end("bridge error: " + e.message); });
|
||||
req.pipe(preq);
|
||||
});
|
||||
|
||||
proxy.on("connect", (req, clientSocket) => {
|
||||
const [host] = req.url.split(":");
|
||||
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
// We are the TLS *server* for Chromium's tunnel; present our static cert.
|
||||
const tlsSock = new tls.TLSSocket(clientSocket, { isServer: true, key: KEY, cert: CERT });
|
||||
tlsSock.__host = host;
|
||||
tlsSock.on("error", () => tlsSock.destroy());
|
||||
// Hand the decrypted byte stream to the internal HTTP parser.
|
||||
origin.emit("connection", tlsSock);
|
||||
});
|
||||
proxy.on("clientError", (_e, sock) => sock.destroy());
|
||||
|
||||
proxy.listen(LISTEN_PORT, "127.0.0.1", () => {
|
||||
console.log(`bridge listening on http://127.0.0.1:${LISTEN_PORT} → upstream ${UP_HOST}:${UP_PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "clone-build-harness",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.21",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@types/node": "22.10.5",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "5.7.3",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default { plugins: { "@tailwindcss/postcss": {} } };
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "clone-build-harness",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": { "build": "next build" },
|
||||
"dependencies": {
|
||||
"next": "14.2.21",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.7.3",
|
||||
"@types/node": "22.10.5",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# ditto.site Compiler
|
||||
|
||||
This workspace contains the deterministic compiler used by ditto.site. It captures
|
||||
a source URL, builds a render IR, infers assets/fonts/tokens/sections/recipes/SEO,
|
||||
generates a Next.js App Router app by default or a Vite React app on request, and
|
||||
validates the result with deterministic gates.
|
||||
|
||||
The full architecture overview lives in [../README.md](../README.md). This file
|
||||
keeps only compiler-local commands and notes.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
cd compiler
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
|
||||
npm run clone -- https://example.com/
|
||||
npm run clone -- https://example.com/ --mode=multi --styling=tailwind
|
||||
npm run clone -- https://example.com/ --mode=single --framework=vite
|
||||
npm run clone-site -- https://example.com/
|
||||
npm run validate-site -- ../runs/site-example.com/<timestamp>
|
||||
npm run clone -- https://example.com/ --mode=multi --concurrency=5
|
||||
npm run clone -- https://example.com/ --mode=multi --validate --validate-concurrency=3 --viewport-concurrency=2
|
||||
npm run validate-site -- ../runs/site-example.com/<timestamp> --validate-concurrency=3 --viewport-concurrency=2
|
||||
npm run bench -- --tier=easy
|
||||
npm run bench-site
|
||||
npm run quality -- ../runs/example.com/<timestamp>
|
||||
npm run audit -- ../runs/example.com/<timestamp>
|
||||
npm test
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
Root-level scripts forward to these commands, so `npm run clone -- <url>` works
|
||||
from the repository root too.
|
||||
|
||||
Multi-page generation defaults to the fast no-validation path. Use `--validate`
|
||||
when the clone command itself should run the full build/render/gates QA pass, or
|
||||
run `validate-site` separately. `--concurrency` controls source route capture;
|
||||
`--validate-concurrency` controls how many routes validation grades at once; and
|
||||
`--viewport-concurrency` controls how many clone viewports each route renders at
|
||||
once.
|
||||
|
||||
## Generated App Shape
|
||||
|
||||
Default Next generated apps use `src/app/ditto.css` and optional helpers under
|
||||
`src/app/ditto/`. Vite generated apps use `src/ditto.css` and optional helpers
|
||||
under `src/ditto/`, with multi-route pages under `src/routes/`. Validation builds
|
||||
keep `data-cid` attributes for source/clone alignment; delivered apps strip those
|
||||
validation ids and keep only required `data-ditto-id` anchors.
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{ "id": "easy-001", "url": "https://www.michaelcole.me/", "tier": "easy", "notes": "portfolio" },
|
||||
{ "id": "easy-002", "url": "https://f.inc/", "tier": "easy", "notes": "company/landing" },
|
||||
{ "id": "easy-003", "url": "https://www.arkli.io/", "tier": "easy", "notes": "SaaS/landing" },
|
||||
{ "id": "easy-004", "url": "https://gist-quiz.com/", "tier": "easy", "notes": "app landing" },
|
||||
{ "id": "easy-005", "url": "https://brittanychiang.com/", "tier": "easy", "notes": "portfolio" },
|
||||
{ "id": "easy-006", "url": "https://leerob.io/", "tier": "easy", "notes": "personal" },
|
||||
{ "id": "easy-007", "url": "https://paco.me/", "tier": "easy", "notes": "personal" },
|
||||
{ "id": "easy-008", "url": "https://rauno.me/", "tier": "easy", "notes": "portfolio/personal" },
|
||||
{ "id": "easy-009", "url": "https://sive.rs/", "tier": "easy", "notes": "personal/content" },
|
||||
{ "id": "easy-010", "url": "https://daringfireball.net/", "tier": "easy", "notes": "content-heavy" },
|
||||
{ "id": "easy-011", "url": "https://www.julian.com/", "tier": "easy", "notes": "personal" },
|
||||
{ "id": "easy-012", "url": "https://ianlunn.co.uk/", "tier": "easy", "notes": "portfolio" },
|
||||
{ "id": "easy-013", "url": "https://adamwathan.me/", "tier": "easy", "notes": "personal/portfolio" },
|
||||
{ "id": "easy-014", "url": "https://www.joshwcomeau.com/", "tier": "easy", "notes": "personal/content" },
|
||||
{ "id": "easy-015", "url": "https://emilkowal.ski/", "tier": "easy", "notes": "portfolio/personal" },
|
||||
{ "id": "easy-016", "url": "https://bradfrost.com/", "tier": "easy", "notes": "content/personal" },
|
||||
{ "id": "easy-017", "url": "https://maggieappleton.com/", "tier": "easy", "notes": "portfolio/content" },
|
||||
{ "id": "easy-018", "url": "https://www.robinwieruch.de/", "tier": "easy", "notes": "content/personal" },
|
||||
{ "id": "easy-019", "url": "https://www.karlsutt.com/", "tier": "easy", "notes": "portfolio" },
|
||||
{ "id": "easy-020", "url": "https://www.maxbo.me/", "tier": "easy", "notes": "portfolio/personal" },
|
||||
{ "id": "easy-021", "url": "https://www.olaolu.dev/", "tier": "easy", "notes": "portfolio" },
|
||||
{ "id": "easy-022", "url": "https://www.taniarascia.com/", "tier": "easy", "notes": "content/personal" },
|
||||
{ "id": "easy-023", "url": "https://kentcdodds.com/", "tier": "easy", "notes": "content/personal" },
|
||||
{ "id": "easy-024", "url": "https://www.swyx.io/", "tier": "easy", "notes": "content/personal" }
|
||||
]
|
||||
@@ -0,0 +1,194 @@
|
||||
[
|
||||
{
|
||||
"id": "hard-001",
|
||||
"url": "https://www.paulgraham.com/",
|
||||
"tier": "hard",
|
||||
"notes": "1996 nested-table/font typography"
|
||||
},
|
||||
{
|
||||
"id": "hard-002",
|
||||
"url": "https://news.ycombinator.com/",
|
||||
"tier": "hard",
|
||||
"notes": "dense table layout"
|
||||
},
|
||||
{
|
||||
"id": "hard-003",
|
||||
"url": "https://en.wikipedia.org/wiki/Cascading_Style_Sheets",
|
||||
"tier": "hard",
|
||||
"notes": "heavy nested DOM article"
|
||||
},
|
||||
{
|
||||
"id": "hard-004",
|
||||
"url": "https://tailwindcss.com/",
|
||||
"tier": "hard",
|
||||
"notes": "dense component showcase"
|
||||
},
|
||||
{
|
||||
"id": "hard-005",
|
||||
"url": "https://developer.mozilla.org/en-US/",
|
||||
"tier": "hard",
|
||||
"notes": "dense docs"
|
||||
},
|
||||
{
|
||||
"id": "hard-006",
|
||||
"url": "https://stripe.com/",
|
||||
"tier": "hard",
|
||||
"notes": "dense, gradients, complex"
|
||||
},
|
||||
{
|
||||
"id": "hard-007",
|
||||
"url": "https://github.com/",
|
||||
"tier": "hard",
|
||||
"notes": "dense landing"
|
||||
},
|
||||
{
|
||||
"id": "hard-008",
|
||||
"url": "https://www.apple.com/",
|
||||
"tier": "hard",
|
||||
"notes": "complex typography, scroll anim"
|
||||
},
|
||||
{
|
||||
"id": "hard-009",
|
||||
"url": "https://www.engadget.com/",
|
||||
"tier": "hard",
|
||||
"notes": "tech news, dense"
|
||||
},
|
||||
{
|
||||
"id": "hard-010",
|
||||
"url": "https://www.theregister.com/",
|
||||
"tier": "hard",
|
||||
"notes": "tech news, dense"
|
||||
},
|
||||
{
|
||||
"id": "hard-011",
|
||||
"url": "https://react.dev/",
|
||||
"tier": "hard",
|
||||
"notes": "docs, dense"
|
||||
},
|
||||
{
|
||||
"id": "hard-012",
|
||||
"url": "https://www.netlify.com/",
|
||||
"tier": "hard",
|
||||
"notes": "dense marketing"
|
||||
},
|
||||
{
|
||||
"id": "hard-013",
|
||||
"url": "https://www.smashingmagazine.com/",
|
||||
"tier": "hard",
|
||||
"notes": "dense editorial"
|
||||
},
|
||||
{
|
||||
"id": "hard-014",
|
||||
"url": "https://css-tricks.com/",
|
||||
"tier": "hard",
|
||||
"notes": "dense content"
|
||||
},
|
||||
{
|
||||
"id": "hard-015",
|
||||
"url": "https://www.sitepoint.com/",
|
||||
"tier": "hard",
|
||||
"notes": "editorial, dense"
|
||||
},
|
||||
{
|
||||
"id": "hard-016",
|
||||
"url": "https://dev.to/",
|
||||
"tier": "hard",
|
||||
"notes": "dense content feed"
|
||||
},
|
||||
{
|
||||
"id": "hard-017",
|
||||
"url": "https://www.producthunt.com/",
|
||||
"tier": "hard",
|
||||
"notes": "dense product feed"
|
||||
},
|
||||
{
|
||||
"id": "hard-018",
|
||||
"url": "https://www.awwwards.com/",
|
||||
"tier": "hard",
|
||||
"notes": "design gallery"
|
||||
},
|
||||
{
|
||||
"id": "hard-019",
|
||||
"url": "https://medium.com/",
|
||||
"tier": "hard",
|
||||
"notes": "editorial typography"
|
||||
},
|
||||
{
|
||||
"id": "hard-020",
|
||||
"url": "https://hackernoon.com/",
|
||||
"tier": "hard",
|
||||
"notes": "editorial feed, dense"
|
||||
},
|
||||
{
|
||||
"id": "hard-021",
|
||||
"url": "https://www.gov.uk/",
|
||||
"tier": "hard",
|
||||
"notes": "dense govt, static"
|
||||
},
|
||||
{
|
||||
"id": "hard-022",
|
||||
"url": "https://blog.cloudflare.com/",
|
||||
"tier": "hard",
|
||||
"notes": "dense technical blog"
|
||||
},
|
||||
{
|
||||
"id": "hard-023",
|
||||
"url": "https://www.nasa.gov/",
|
||||
"tier": "hard",
|
||||
"notes": "dense, many media assets"
|
||||
},
|
||||
{
|
||||
"id": "hard-024",
|
||||
"url": "https://www.mozilla.org/en-US/",
|
||||
"tier": "hard",
|
||||
"notes": "dense marketing"
|
||||
},
|
||||
{
|
||||
"id": "hard-025",
|
||||
"url": "https://web.dev/",
|
||||
"tier": "hard",
|
||||
"notes": "dense docs/articles"
|
||||
},
|
||||
{
|
||||
"id": "hard-026",
|
||||
"url": "https://www.nike.com/",
|
||||
"tier": "hard",
|
||||
"notes": "ecommerce; dense, popups"
|
||||
},
|
||||
{
|
||||
"id": "hard-027",
|
||||
"url": "https://www.zara.com/",
|
||||
"tier": "hard",
|
||||
"notes": "ecommerce; minimalist complex grid"
|
||||
},
|
||||
{
|
||||
"id": "hard-028",
|
||||
"url": "https://www.ssense.com/",
|
||||
"tier": "hard",
|
||||
"notes": "ecommerce; editorial luxury, dense"
|
||||
},
|
||||
{
|
||||
"id": "hard-029",
|
||||
"url": "https://www.gymshark.com/",
|
||||
"tier": "hard",
|
||||
"notes": "ecommerce; Shopify DTC, popups"
|
||||
},
|
||||
{
|
||||
"id": "hard-030",
|
||||
"url": "https://www.sephora.com/",
|
||||
"tier": "hard",
|
||||
"notes": "ecommerce; very dense, popups"
|
||||
},
|
||||
{
|
||||
"id": "hard-031",
|
||||
"url": "https://www.etsy.com/",
|
||||
"tier": "hard",
|
||||
"notes": "ecommerce; dense product grid"
|
||||
},
|
||||
{
|
||||
"id": "hard-032",
|
||||
"url": "https://www.uniqlo.com/us/en/",
|
||||
"tier": "hard",
|
||||
"notes": "ecommerce; dense, complex"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
[
|
||||
{ "id": "medium-002", "url": "https://origami.chat/", "tier": "medium", "notes": "SaaS" },
|
||||
{ "id": "medium-004", "url": "https://www.tryskylink.com/", "tier": "medium", "notes": "SaaS" },
|
||||
{ "id": "medium-005", "url": "https://linear.app/", "tier": "medium", "notes": "SaaS marketing" },
|
||||
{ "id": "medium-006", "url": "https://www.raycast.com/", "tier": "medium", "notes": "SaaS/product" },
|
||||
{ "id": "medium-007", "url": "https://supabase.com/", "tier": "medium", "notes": "SaaS/product" },
|
||||
{ "id": "medium-008", "url": "https://retool.com/", "tier": "medium", "notes": "SaaS/product" },
|
||||
{ "id": "medium-009", "url": "https://www.warp.dev/", "tier": "medium", "notes": "SaaS/product" },
|
||||
{ "id": "medium-010", "url": "https://plaid.com/", "tier": "medium", "notes": "SaaS/product" },
|
||||
{ "id": "medium-011", "url": "https://www.loom.com/", "tier": "medium", "notes": "SaaS/product" },
|
||||
{ "id": "medium-012", "url": "https://webflow.com/", "tier": "medium", "notes": "SaaS/product" },
|
||||
{ "id": "medium-013", "url": "https://www.notion.com/product", "tier": "medium", "notes": "SaaS/product" },
|
||||
{ "id": "medium-014", "url": "https://slack.com/", "tier": "medium", "notes": "SaaS/product" },
|
||||
{ "id": "medium-015", "url": "https://www.figma.com/", "tier": "medium", "notes": "SaaS/product" },
|
||||
{ "id": "medium-016", "url": "https://www.attio.com/", "tier": "medium", "notes": "SaaS/product" },
|
||||
{ "id": "medium-017", "url": "https://www.airtable.com/", "tier": "medium", "notes": "SaaS/product" },
|
||||
{ "id": "medium-018", "url": "https://www.deel.com/", "tier": "medium", "notes": "SaaS/product" },
|
||||
{ "id": "medium-019", "url": "https://mercury.com/", "tier": "medium", "notes": "SaaS/fintech" },
|
||||
{ "id": "medium-020", "url": "https://asana.com/", "tier": "medium", "notes": "SaaS/product" },
|
||||
{ "id": "medium-021", "url": "https://replit.com/", "tier": "medium", "notes": "SaaS/product" },
|
||||
{ "id": "medium-022", "url": "https://vercel.com/", "tier": "medium", "notes": "SaaS/product" },
|
||||
{ "id": "medium-023", "url": "https://www.shopify.com/", "tier": "medium", "notes": "SaaS/ecommerce" },
|
||||
{ "id": "medium-024", "url": "https://www.allbirds.com/", "tier": "medium", "notes": "ecommerce" },
|
||||
{ "id": "medium-025", "url": "https://www.glossier.com/", "tier": "medium", "notes": "ecommerce" }
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{ "id": "motion-001", "url": "https://rauno.me/", "tier": "motion", "notes": "personal site, ~7 CSS @keyframes animations + many transitions" },
|
||||
{ "id": "motion-002", "url": "https://www.sive.rs/", "tier": "motion", "notes": "minimal content site, light motion (regression baseline)" },
|
||||
{ "id": "motion-003", "url": "https://emilkowal.ski/", "tier": "motion", "notes": "animation-specialist site; entrance motion (some WAAPI finishes before capture)" },
|
||||
{ "id": "motion-004", "url": "https://leerob.io/", "tier": "motion", "notes": "personal site, subtle CSS transitions/animations" },
|
||||
{ "id": "motion-005", "url": "https://www.joshwcomeau.com/", "tier": "motion", "notes": "playful CSS animations (hero canvas is out of scope, frozen)" },
|
||||
{ "id": "motion-006", "url": "https://framer.com/", "tier": "motion", "notes": "Framer Motion (WAAPI) + 16 infinite CSS keyframes — declarative-motion showcase" }
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"id": "site-overreacted",
|
||||
"url": "https://overreacted.io/",
|
||||
"notes": "flat root-level blog collection (/:slug); home acts as the listing. Tests root-collection collapse + structural confirmation (home vs post)."
|
||||
},
|
||||
{
|
||||
"id": "site-brew",
|
||||
"url": "https://brew.sh/",
|
||||
"notes": "tiny site: home + blog listing + one post. Pure multi-page singletons + shared chrome baseline."
|
||||
},
|
||||
{
|
||||
"id": "site-jamstack",
|
||||
"url": "https://jamstack.org/",
|
||||
"maxRoutes": 12,
|
||||
"notes": "directory site: large /generators, /headless-cms, /glossary collections collapsed to listing + representative."
|
||||
},
|
||||
{
|
||||
"id": "site-gatsby",
|
||||
"url": "https://www.gatsbyjs.com/",
|
||||
"notes": "marketing + /docs collection + a /contributing pair kept whole; route-cap exercise."
|
||||
},
|
||||
{
|
||||
"id": "site-11ty",
|
||||
"url": "https://www.11ty.dev/",
|
||||
"maxRoutes": 12,
|
||||
"maxCollectionInstances": 500,
|
||||
"notes": "docs+blog multi-collection incl. nested /docs/:id/:id and an 882-instance authors directory (listing capped via maxCollectionInstances)."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{ "id": "stage2-001", "url": "https://www.figma.com/", "tier": "stage2", "notes": "cookie consent (OneTrust), product hero, animation" },
|
||||
{ "id": "stage2-002", "url": "https://www.duolingo.com/", "tier": "stage2", "notes": "cookie banner, heavy mascot animation" },
|
||||
{ "id": "stage2-003", "url": "https://monday.com/", "tier": "stage2", "notes": "cookie consent, colorful marketing, video" },
|
||||
{ "id": "stage2-004", "url": "https://www.intercom.com/", "tier": "stage2", "notes": "cookie banner, marketing, scroll reveals" },
|
||||
{ "id": "stage2-005", "url": "https://www.grammarly.com/", "tier": "stage2", "notes": "cookie consent, marketing animation" },
|
||||
{ "id": "stage2-006", "url": "https://www.allbirds.com/", "tier": "stage2", "notes": "DTC: newsletter modal, cookie banner" },
|
||||
{ "id": "stage2-007", "url": "https://www.glossier.com/", "tier": "stage2", "notes": "DTC: email-capture popup" },
|
||||
{ "id": "stage2-008", "url": "https://www.warbyparker.com/", "tier": "stage2", "notes": "DTC: popups, hero media" },
|
||||
{ "id": "stage2-009", "url": "https://casper.com/", "tier": "stage2", "notes": "DTC: newsletter modal, sticky offers" },
|
||||
{ "id": "stage2-010", "url": "https://www.brooklinen.com/", "tier": "stage2", "notes": "DTC: email popup, promo banner" },
|
||||
{ "id": "stage2-011", "url": "https://ruggable.com/", "tier": "stage2", "notes": "DTC: spin-to-win / email popup" },
|
||||
{ "id": "stage2-012", "url": "https://bombas.com/", "tier": "stage2", "notes": "DTC: email-capture modal" },
|
||||
{ "id": "stage2-013", "url": "https://webflow.com/", "tier": "stage2", "notes": "hero video, scroll animation" },
|
||||
{ "id": "stage2-014", "url": "https://www.squarespace.com/", "tier": "stage2", "notes": "autoplay hero video" },
|
||||
{ "id": "stage2-015", "url": "https://www.wix.com/", "tier": "stage2", "notes": "popups, hero video, heavy JS" },
|
||||
{ "id": "stage2-016", "url": "https://www.descript.com/", "tier": "stage2", "notes": "hero/product video, animation" },
|
||||
{ "id": "stage2-017", "url": "https://www.clay.com/", "tier": "stage2", "notes": "hero video, scroll reveals" },
|
||||
{ "id": "stage2-018", "url": "https://runwayml.com/", "tier": "stage2", "notes": "video-heavy, dynamic media" },
|
||||
{ "id": "stage2-019", "url": "https://www.framer.com/", "tier": "stage2", "notes": "heavy entrance/scroll animation, video" },
|
||||
{ "id": "stage2-020", "url": "https://vercel.com/", "tier": "stage2", "notes": "scroll-reveal animation, gradients" },
|
||||
{ "id": "stage2-021", "url": "https://mailchimp.com/", "tier": "stage2", "notes": "illustrative animation, consent" },
|
||||
{ "id": "stage2-022", "url": "https://resend.com/", "tier": "stage2", "notes": "animation, dark gradients" },
|
||||
{ "id": "stage2-023", "url": "https://linear.app/", "tier": "stage2", "notes": "entrance animation, hero video" },
|
||||
{ "id": "stage2-024", "url": "https://www.everlane.com/", "tier": "stage2", "notes": "DTC: newsletter popup, stable product grid" },
|
||||
{ "id": "stage2-025", "url": "https://posthog.com/", "tier": "stage2", "notes": "cookie banner, quirky marketing animation, stable" }
|
||||
]
|
||||
|
After Width: | Height: | Size: 68 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="12" fill="#123456"/>
|
||||
<path d="M18 39h28v6H18zM18 19h28v6H18zM18 29h20v6H18z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 196 B |
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Carousel fixture</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; color: #1a1a1a; background: #fff; }
|
||||
main { max-width: 760px; margin: 0 auto; padding: 48px 24px; }
|
||||
h1 { font-size: 30px; }
|
||||
|
||||
/* A Swiper-like single-slide carousel: a clipped viewport, a flex track moved
|
||||
by translateX, prev/next buttons, and pagination bullets. */
|
||||
.swiper { position: relative; overflow: hidden; border-radius: 12px; margin-top: 20px; }
|
||||
.swiper-wrapper { display: flex; transition: transform .35s ease; will-change: transform; }
|
||||
.swiper-slide { flex: 0 0 100%; min-width: 100%; height: 280px; display: flex; align-items: center; justify-content: center; font-size: 40px; color: #fff; }
|
||||
.s0 { background: #4f46e5; } .s1 { background: #0891b2; } .s2 { background: #16a34a; } .s3 { background: #db2777; } .s4 { background: #ea580c; }
|
||||
.swiper-button-prev, .swiper-button-next {
|
||||
position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
|
||||
width: 44px; height: 44px; border-radius: 50%; border: 0; cursor: pointer;
|
||||
background: rgba(255,255,255,.85); color: #1a1a1a; font-size: 20px;
|
||||
}
|
||||
.swiper-button-prev { left: 12px; } .swiper-button-next { right: 12px; }
|
||||
.swiper-pagination { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
|
||||
.swiper-pagination-bullet { width: 10px; height: 10px; border-radius: 50%; border: 0; padding: 0; cursor: pointer; background: #d1d5db; }
|
||||
.swiper-pagination-bullet-active { background: #4f46e5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Carousel</h1>
|
||||
<p>A single-slide, transform-positioned carousel with prev/next and pagination.</p>
|
||||
|
||||
<div class="swiper" aria-roledescription="carousel" aria-label="Featured">
|
||||
<div class="swiper-wrapper">
|
||||
<div class="swiper-slide s0" aria-roledescription="slide" aria-label="1 of 5">Slide 1</div>
|
||||
<div class="swiper-slide s1" aria-roledescription="slide" aria-label="2 of 5">Slide 2</div>
|
||||
<div class="swiper-slide s2" aria-roledescription="slide" aria-label="3 of 5">Slide 3</div>
|
||||
<div class="swiper-slide s3" aria-roledescription="slide" aria-label="4 of 5">Slide 4</div>
|
||||
<div class="swiper-slide s4" aria-roledescription="slide" aria-label="5 of 5">Slide 5</div>
|
||||
</div>
|
||||
<button class="swiper-button-prev" aria-label="Previous slide">‹</button>
|
||||
<button class="swiper-button-next" aria-label="Next slide">›</button>
|
||||
<div class="swiper-pagination">
|
||||
<button class="swiper-pagination-bullet swiper-pagination-bullet-active" aria-label="Go to slide 1"></button>
|
||||
<button class="swiper-pagination-bullet" aria-label="Go to slide 2"></button>
|
||||
<button class="swiper-pagination-bullet" aria-label="Go to slide 3"></button>
|
||||
<button class="swiper-pagination-bullet" aria-label="Go to slide 4"></button>
|
||||
<button class="swiper-pagination-bullet" aria-label="Go to slide 5"></button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll(".swiper").forEach((root) => {
|
||||
const wrap = root.querySelector(".swiper-wrapper");
|
||||
const slides = [...root.querySelectorAll(".swiper-slide")];
|
||||
const bullets = [...root.querySelectorAll(".swiper-pagination-bullet")];
|
||||
let i = 0;
|
||||
const go = (n) => {
|
||||
i = Math.max(0, Math.min(slides.length - 1, n));
|
||||
wrap.style.transform = "translateX(-" + (i * 100) + "%)";
|
||||
bullets.forEach((b, k) => b.classList.toggle("swiper-pagination-bullet-active", k === i));
|
||||
};
|
||||
root.querySelector(".swiper-button-next").addEventListener("click", () => go(i + 1));
|
||||
root.querySelector(".swiper-button-prev").addEventListener("click", () => go(i - 1));
|
||||
bullets.forEach((b, k) => b.addEventListener("click", () => go(k)));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Component extraction fixture</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; color: #1a1a1a; background: #fff; }
|
||||
header { border-bottom: 1px solid #e5e5e5; }
|
||||
nav { max-width: 960px; margin: 0 auto; padding: 16px 24px; display: flex; gap: 24px; }
|
||||
nav a { color: #374151; text-decoration: none; font-size: 15px; font-weight: 500; }
|
||||
main { max-width: 960px; margin: 0 auto; padding: 48px 24px; }
|
||||
h1 { font-size: 34px; margin: 0 0 8px; }
|
||||
.intro { color: #6b7280; font-size: 17px; margin: 0 0 40px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; }
|
||||
.card { border: 1px solid #e5e7eb; border-radius: 12px; overflow: hidden; }
|
||||
.card-link { display: block; padding: 20px; color: inherit; text-decoration: none; }
|
||||
.badge { display: inline-block; background: #eef2ff; color: #4f46e5; font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 999px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.card-title { font-size: 19px; margin: 14px 0 6px; line-height: 1.3; }
|
||||
.card-body { font-size: 14px; color: #6b7280; line-height: 1.6; margin: 0 0 14px; }
|
||||
.card-cta { font-size: 14px; font-weight: 600; color: #4f46e5; }
|
||||
footer { max-width: 960px; margin: 0 auto; padding: 32px 24px; color: #9ca3af; font-size: 13px; border-top: 1px solid #e5e5e5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav aria-label="Primary">
|
||||
<a href="https://example.com/">Home</a>
|
||||
<a href="https://example.com/features">Features</a>
|
||||
<a href="https://example.com/pricing">Pricing</a>
|
||||
<a href="https://example.com/docs">Docs</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h1>From the blog</h1>
|
||||
<p class="intro">Notes, guides, and release updates from the team.</p>
|
||||
<div class="grid">
|
||||
<article class="card">
|
||||
<a class="card-link" href="https://example.com/post/alpha">
|
||||
<span class="badge">News</span>
|
||||
<h3 class="card-title">Announcing the alpha release</h3>
|
||||
<p class="card-body">A first look at everything that shipped in our earliest public build.</p>
|
||||
<span class="card-cta">Read more →</span>
|
||||
</a>
|
||||
</article>
|
||||
<article class="card">
|
||||
<a class="card-link" href="https://example.com/post/grid-guide">
|
||||
<span class="badge">Guide</span>
|
||||
<h3 class="card-title">A practical guide to CSS grid</h3>
|
||||
<p class="card-body">Lay out responsive card collections without fighting the box model.</p>
|
||||
<span class="card-cta">Read more →</span>
|
||||
</a>
|
||||
</article>
|
||||
<article class="card">
|
||||
<a class="card-link" href="https://example.com/post/perf">
|
||||
<span class="badge">Engineering</span>
|
||||
<h3 class="card-title">Shipping faster pages in 2026</h3>
|
||||
<p class="card-body">The performance budget we hold every release to, and why it matters.</p>
|
||||
<span class="card-cta">Read more →</span>
|
||||
</a>
|
||||
</article>
|
||||
<article class="card">
|
||||
<a class="card-link" href="https://example.com/post/changelog">
|
||||
<span class="badge">News</span>
|
||||
<h3 class="card-title">What changed this month</h3>
|
||||
<p class="card-body">A roundup of the fixes and features that landed across the platform.</p>
|
||||
<span class="card-cta">Read more →</span>
|
||||
</a>
|
||||
</article>
|
||||
<article class="card">
|
||||
<a class="card-link" href="https://example.com/post/design">
|
||||
<span class="badge">Design</span>
|
||||
<h3 class="card-title">Rethinking our color system</h3>
|
||||
<p class="card-body">How we moved to semantic tokens and what we learned along the way.</p>
|
||||
<span class="card-cta">Read more →</span>
|
||||
</a>
|
||||
</article>
|
||||
<article class="card">
|
||||
<a class="card-link" href="https://example.com/post/community">
|
||||
<span class="badge">Community</span>
|
||||
<h3 class="card-title">Highlights from the meetup</h3>
|
||||
<p class="card-body">Talks, demos, and conversations from our first in-person gathering.</p>
|
||||
<span class="card-cta">Read more →</span>
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
<footer>© 2026 Example, Inc. All rights reserved.</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Disclosure fixture — dropdown, mega-menu, modal</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; color: #1a1a1a; background: #fff; }
|
||||
main { max-width: 820px; margin: 0 auto; padding: 48px 24px; }
|
||||
h1 { font-size: 30px; } h2 { font-size: 20px; margin-top: 40px; }
|
||||
nav { display: flex; gap: 8px; }
|
||||
.menu-root { position: relative; }
|
||||
.menu-trigger, .modal-trigger {
|
||||
appearance: none; border: 1px solid #d1d5db; background: #fff; border-radius: 8px;
|
||||
padding: 10px 16px; font-size: 15px; cursor: pointer; color: #1a1a1a;
|
||||
}
|
||||
.menu-trigger[aria-expanded="true"] { background: #eef2ff; border-color: #4f46e5; color: #4f46e5; }
|
||||
.menu-panel {
|
||||
position: absolute; top: calc(100% + 6px); left: 0; z-index: 10;
|
||||
min-width: 200px; background: #fff; border: 1px solid #e5e7eb; border-radius: 10px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,.12); padding: 8px; list-style: none; margin: 0;
|
||||
}
|
||||
.menu-panel[hidden] { display: none; }
|
||||
.menu-panel li > a { display: block; padding: 8px 12px; border-radius: 6px; color: #1a1a1a; text-decoration: none; }
|
||||
.mega { min-width: 520px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 50;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-backdrop[hidden] { display: none; }
|
||||
.modal-dialog { background: #fff; border-radius: 14px; padding: 28px; max-width: 440px; width: 90%; }
|
||||
.modal-dialog h3 { margin: 0 0 8px; font-size: 22px; }
|
||||
.modal-close { float: right; border: 0; background: transparent; font-size: 22px; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Disclosure patterns</h1>
|
||||
|
||||
<h2>Dropdown menu</h2>
|
||||
<nav>
|
||||
<div class="menu-root">
|
||||
<button class="menu-trigger" id="mt1" aria-haspopup="menu" aria-expanded="false" aria-controls="m1">Products ▾</button>
|
||||
<ul class="menu-panel" id="m1" role="menu" aria-labelledby="mt1" hidden>
|
||||
<li role="none"><a role="menuitem" href="#search">Search</a></li>
|
||||
<li role="none"><a role="menuitem" href="#recommend">Recommend</a></li>
|
||||
<li role="none"><a role="menuitem" href="#analytics">Analytics</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="menu-root">
|
||||
<button class="menu-trigger" id="mt2" aria-haspopup="menu" aria-expanded="false" aria-controls="m2">Solutions ▾</button>
|
||||
<ul class="menu-panel mega" id="m2" role="menu" aria-labelledby="mt2" hidden>
|
||||
<li role="none"><a role="menuitem" href="#ecommerce">Ecommerce — fast product discovery</a></li>
|
||||
<li role="none"><a role="menuitem" href="#media">Media — content recommendations</a></li>
|
||||
<li role="none"><a role="menuitem" href="#saas">SaaS — in-app search</a></li>
|
||||
<li role="none"><a role="menuitem" href="#marketplaces">Marketplaces — ranking</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<h2>Modal dialog</h2>
|
||||
<button class="modal-trigger" id="open-dlg" aria-haspopup="dialog" aria-controls="dlg">Open dialog</button>
|
||||
<div class="modal-backdrop" id="dlg-backdrop" hidden>
|
||||
<div class="modal-dialog" id="dlg" role="dialog" aria-modal="true" aria-labelledby="dlg-title">
|
||||
<button class="modal-close" id="dlg-close" aria-label="Close dialog">×</button>
|
||||
<h3 id="dlg-title">Subscribe</h3>
|
||||
<p>Get product updates in your inbox. We send at most one email a month.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Dropdown menus: toggle on click, open on hover, close on outside click / Escape.
|
||||
document.querySelectorAll(".menu-root").forEach((root) => {
|
||||
const trigger = root.querySelector(".menu-trigger");
|
||||
const panel = document.getElementById(trigger.getAttribute("aria-controls"));
|
||||
const set = (open) => { trigger.setAttribute("aria-expanded", open ? "true" : "false"); panel.hidden = !open; };
|
||||
trigger.addEventListener("click", () => set(trigger.getAttribute("aria-expanded") !== "true"));
|
||||
root.addEventListener("mouseenter", () => set(true));
|
||||
root.addEventListener("mouseleave", () => set(false));
|
||||
});
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") document.querySelectorAll(".menu-trigger").forEach((t) => { t.setAttribute("aria-expanded", "false"); document.getElementById(t.getAttribute("aria-controls")).hidden = true; }); });
|
||||
|
||||
// Modal: open from trigger, close from close button / backdrop / Escape.
|
||||
const backdrop = document.getElementById("dlg-backdrop");
|
||||
const openModal = (open) => { backdrop.hidden = !open; };
|
||||
document.getElementById("open-dlg").addEventListener("click", () => openModal(true));
|
||||
document.getElementById("dlg-close").addEventListener("click", () => openModal(false));
|
||||
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) openModal(false); });
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") openModal(false); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 48 48" version="1.2" baseProfile="tiny" xmlns="http://www.w3.org/2000/svg" id="Calque_1">
|
||||
|
||||
<rect fill="#82a3f7" height="48" width="48"></rect>
|
||||
<path d="M36,25.7c-4.8,0-5-11-5-11h-3.1s-.4,11-3.4,11-3.4-11-3.4-11h-3.1s-.1,11-5,11h-.7v7h.7c6.7,0,6-11.3,6.1-12.1h.6c.1.9-.4,12.1,4.5,12.1s4.4-11.3,4.5-12.1h.6c.1.9-.6,12.1,6.1,12.1h.7v-7h-.4Z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Hover transition fixture</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, sans-serif; padding: 60px; }
|
||||
/* a button whose hover EASES (transition), not snaps */
|
||||
.btn {
|
||||
display: inline-block; padding: 12px 24px; border-radius: 8px;
|
||||
background: rgb(238, 238, 238); color: rgb(51, 51, 51);
|
||||
transition: background-color 0.25s ease, color 0.25s ease, transform 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover { background: rgb(43, 80, 214); color: rgb(255, 255, 255); transform: translateY(-2px); }
|
||||
/* a link with no transition: its hover should NOT gain a transition rule */
|
||||
.plain { display: inline-block; margin-left: 24px; color: rgb(43, 80, 214); }
|
||||
.plain:hover { color: rgb(20, 30, 90); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="btn" href="/go">Hover me (eased)</a>
|
||||
<a class="plain" href="/plain">Plain hover (snaps)</a>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Interactive patterns fixture</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; color: #1a1a1a; background: #fff; }
|
||||
main { max-width: 760px; margin: 0 auto; padding: 48px 24px; }
|
||||
h1 { font-size: 32px; }
|
||||
h2 { font-size: 22px; margin-top: 48px; }
|
||||
|
||||
/* Tabs */
|
||||
.tabs { margin-top: 16px; }
|
||||
[role="tablist"] { display: flex; gap: 4px; border-bottom: 2px solid #e5e5e5; }
|
||||
[role="tab"] {
|
||||
appearance: none; border: 0; background: transparent; cursor: pointer;
|
||||
padding: 10px 18px; font-size: 15px; color: #6b7280;
|
||||
border-bottom: 3px solid transparent; margin-bottom: -2px;
|
||||
}
|
||||
[role="tab"][aria-selected="true"] { color: #4f46e5; border-bottom-color: #4f46e5; font-weight: 600; }
|
||||
[role="tabpanel"] { padding: 20px 4px; font-size: 15px; line-height: 1.6; }
|
||||
[role="tabpanel"][hidden] { display: none; }
|
||||
|
||||
/* Accordion */
|
||||
.accordion { margin-top: 16px; border: 1px solid #e5e5e5; border-radius: 8px; overflow: hidden; }
|
||||
.accordion-item + .accordion-item { border-top: 1px solid #e5e5e5; }
|
||||
.accordion-trigger {
|
||||
appearance: none; width: 100%; text-align: left; border: 0; background: #fafafa;
|
||||
padding: 16px 18px; font-size: 16px; cursor: pointer; color: #1a1a1a;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.accordion-trigger[aria-expanded="true"] { background: #eef2ff; color: #4f46e5; }
|
||||
.accordion-trigger .chev { transition: transform .15s; }
|
||||
.accordion-trigger[aria-expanded="true"] .chev { transform: rotate(180deg); }
|
||||
.accordion-panel { padding: 4px 18px 20px; font-size: 15px; line-height: 1.6; color: #4b5563; }
|
||||
.accordion-panel[hidden] { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Interactive patterns</h1>
|
||||
<p>A fixture exercising the recognized Stage-4 patterns with panels kept in the DOM.</p>
|
||||
|
||||
<h2>Tabs</h2>
|
||||
<div class="tabs">
|
||||
<div role="tablist" aria-label="Fruit">
|
||||
<button role="tab" id="t1" aria-controls="p1" aria-selected="true">Apples</button>
|
||||
<button role="tab" id="t2" aria-controls="p2" aria-selected="false" tabindex="-1">Oranges</button>
|
||||
<button role="tab" id="t3" aria-controls="p3" aria-selected="false" tabindex="-1">Pears</button>
|
||||
</div>
|
||||
<div role="tabpanel" id="p1" aria-labelledby="t1"><p>Apples are crisp and come in many varieties such as Fuji and Gala.</p></div>
|
||||
<div role="tabpanel" id="p2" aria-labelledby="t2" hidden><p>Oranges are citrus fruits rich in vitamin C and very juicy.</p></div>
|
||||
<div role="tabpanel" id="p3" aria-labelledby="t3" hidden><p>Pears have a soft, sweet flesh and a distinctive shape.</p></div>
|
||||
</div>
|
||||
|
||||
<h2>Accordion</h2>
|
||||
<div class="accordion">
|
||||
<div class="accordion-item">
|
||||
<button class="accordion-trigger" id="a1" aria-controls="s1" aria-expanded="true"><span>What is your return policy?</span><span class="chev">▾</span></button>
|
||||
<div class="accordion-panel" id="s1" role="region" aria-labelledby="a1"><p>You can return any item within 30 days of purchase for a full refund.</p></div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<button class="accordion-trigger" id="a2" aria-controls="s2" aria-expanded="false"><span>Do you ship internationally?</span><span class="chev">▾</span></button>
|
||||
<div class="accordion-panel" id="s2" role="region" aria-labelledby="a2" hidden><p>Yes, we ship to over 50 countries with tracked delivery.</p></div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<button class="accordion-trigger" id="a3" aria-controls="s3" aria-expanded="false"><span>How do I track my order?</span><span class="chev">▾</span></button>
|
||||
<div class="accordion-panel" id="s3" role="region" aria-labelledby="a3" hidden><p>A tracking link is emailed to you as soon as your order ships.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Minimal ARIA tabs: keep panels mounted, toggle [hidden] + aria-selected.
|
||||
document.querySelectorAll('[role="tablist"]').forEach((list) => {
|
||||
const tabs = [...list.querySelectorAll('[role="tab"]')];
|
||||
tabs.forEach((tab) => tab.addEventListener('click', () => {
|
||||
tabs.forEach((t) => {
|
||||
const sel = t === tab;
|
||||
t.setAttribute('aria-selected', sel ? 'true' : 'false');
|
||||
t.tabIndex = sel ? 0 : -1;
|
||||
const panel = document.getElementById(t.getAttribute('aria-controls'));
|
||||
if (panel) panel.hidden = !sel;
|
||||
});
|
||||
}));
|
||||
});
|
||||
// Minimal accordion: toggle [hidden] + aria-expanded.
|
||||
document.querySelectorAll('.accordion-trigger').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const open = btn.getAttribute('aria-expanded') === 'true';
|
||||
btn.setAttribute('aria-expanded', open ? 'false' : 'true');
|
||||
const panel = document.getElementById(btn.getAttribute('aria-controls'));
|
||||
if (panel) panel.hidden = open;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,55 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Layout + Links fixture</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; }
|
||||
body { font-family: system-ui, sans-serif; color: #101010; }
|
||||
/* full-bleed sections: authored fluid (width:100%), must stay fluid in the clone */
|
||||
.bleed { width: 100%; background: #eef2fd; padding: 16px 0; }
|
||||
.bleed.alt { background: #f6f6f6; }
|
||||
/* centered, capped container: fluid up to 960px, capped beyond */
|
||||
.container { max-width: 960px; margin: 0 auto; padding: 0 24px; }
|
||||
nav { display: flex; gap: 16px; align-items: center; }
|
||||
nav a { color: #2b50d6; text-decoration: none; }
|
||||
h1 { font-size: 40px; line-height: 1.2; margin: 24px 0; }
|
||||
/* a genuinely fixed-width element: must NOT be made fluid */
|
||||
.fixed-box { width: 220px; height: 80px; background: #ccd; }
|
||||
/* an absolute bar pinned to both edges (sticky-nav shape): width is redundant
|
||||
with left:0;right:0 and must stay fluid */
|
||||
.topbar { position: fixed; top: 0; left: 0; right: 0; height: 36px; background: rgb(20, 24, 40); color: #fff; z-index: 100; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">pinned bar</div>
|
||||
<header class="bleed">
|
||||
<nav class="container">
|
||||
<a href="/">Home</a>
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="/enterprise">Enterprise</a>
|
||||
<a href="https://example.com/external">External</a>
|
||||
<a href="#features">Features</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<!-- a full-bleed replaced element (svg): its width:100% must NOT become width:auto,
|
||||
which would collapse it to its intrinsic viewBox width at every viewport -->
|
||||
<svg class="bleed-svg" viewBox="0 0 100 12" preserveAspectRatio="none" style="display:block;width:100%;height:12px;background:rgb(200,60,60)"><rect width="100" height="12" fill="rgb(200,60,60)"/></svg>
|
||||
<section class="bleed alt">
|
||||
<div class="container">
|
||||
<h1 id="features">A full-width hero band</h1>
|
||||
<p>The band background should reach both window edges at any width.</p>
|
||||
<div class="fixed-box">fixed 220px</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="bleed">
|
||||
<div class="container">
|
||||
<p>Second full-bleed band.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
# Source LLMS Full
|
||||
|
||||
This longer source file should be preserved when the fixture exposes it.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Source LLMS
|
||||
|
||||
This text comes from the source fixture and should be preserved by the generated app.
|
||||
|
After Width: | Height: | Size: 68 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<path d="M8 8h48v48H8z" fill="#000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 108 B |
@@ -0,0 +1,65 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Mount-on-open menu fixture</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, sans-serif; }
|
||||
header { display: flex; align-items: center; gap: 24px; height: 56px; padding: 0 24px; border-bottom: 1px solid #eee; }
|
||||
.brand { font-weight: 700; }
|
||||
nav { display: flex; gap: 8px; }
|
||||
.trigger { background: none; border: 0; font: inherit; padding: 8px 12px; border-radius: 6px; cursor: pointer; color: #333; }
|
||||
.trigger[aria-expanded="true"] { background: #eef2fd; color: #2b50d6; }
|
||||
/* the panel is mounted into <body> on open (Radix-style portal) and removed on close */
|
||||
.menu-panel { position: absolute; background: #fff; border: 1px solid #e2e2e2; border-radius: 12px; box-shadow: 0 12px 32px rgba(0,0,0,0.12); padding: 16px; width: 320px; display: grid; gap: 8px; }
|
||||
.menu-panel a { display: block; padding: 10px 12px; border-radius: 8px; color: #222; text-decoration: none; }
|
||||
.menu-panel a:hover { background: #f4f6fe; }
|
||||
.menu-panel .label { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: .04em; padding: 4px 12px; }
|
||||
main { padding: 48px 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span class="brand">Acme</span>
|
||||
<nav>
|
||||
<button class="trigger" id="t-product" aria-haspopup="menu" aria-expanded="false" aria-controls="panel-product">Product</button>
|
||||
<a class="trigger" href="/pricing">Pricing</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h1>Mount-on-open menu</h1>
|
||||
<p>The Product menu mounts its panel into <body> on open and removes it on close.</p>
|
||||
</main>
|
||||
<script>
|
||||
const t = document.getElementById('t-product');
|
||||
let panel = null;
|
||||
function open() {
|
||||
if (panel) return;
|
||||
panel = document.createElement('div');
|
||||
panel.className = 'menu-panel';
|
||||
panel.id = 'panel-product';
|
||||
panel.setAttribute('role', 'menu');
|
||||
const r = t.getBoundingClientRect();
|
||||
panel.style.left = (r.left + window.scrollX) + 'px';
|
||||
panel.style.top = (r.bottom + window.scrollY + 8) + 'px';
|
||||
panel.innerHTML = '<div class="label">Build</div>' +
|
||||
'<a href="/features">Features</a>' +
|
||||
'<a href="/integrations">Integrations</a>' +
|
||||
'<a href="/changelog">Changelog</a>' +
|
||||
'<div class="label">Learn</div>' +
|
||||
'<a href="/docs">Documentation</a>' +
|
||||
'<a href="/guides">Guides</a>';
|
||||
document.body.appendChild(panel);
|
||||
t.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
function close() {
|
||||
if (panel) { panel.remove(); panel = null; }
|
||||
t.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
t.addEventListener('click', () => (t.getAttribute('aria-expanded') === 'true' ? close() : open()));
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,79 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Motion fixture — CSS keyframes</title>
|
||||
<style>
|
||||
:root { --bg: #0b0b10; --fg: #f5f5f7; --muted: #9aa0aa; --accent: #6d6aff; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; }
|
||||
body {
|
||||
background: var(--bg); color: var(--fg);
|
||||
font-family: -apple-system, system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.wrap { max-width: 920px; margin: 0 auto; padding: 64px 24px 96px; }
|
||||
|
||||
/* 1) Finite entrance: fade + slide up, ends at rest (fill forwards). */
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(28px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: 56px; font-weight: 700; line-height: 1.05; letter-spacing: -0.02em; margin: 0 0 16px;
|
||||
animation: fadeUp 700ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.hero p {
|
||||
font-size: 20px; line-height: 1.5; color: var(--muted); margin: 0; max-width: 560px;
|
||||
animation: fadeUp 700ms cubic-bezier(0.22, 1, 0.36, 1) 120ms both;
|
||||
}
|
||||
|
||||
/* 2) Infinite spinner. */
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner {
|
||||
width: 48px; height: 48px; margin: 56px 0 0; border-radius: 9999px;
|
||||
border: 4px solid rgba(255,255,255,0.15); border-top-color: var(--accent);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 3) Infinite pulse (opacity + scale). */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.55; transform: scale(1.08); }
|
||||
}
|
||||
.badge {
|
||||
display: inline-block; margin-top: 40px; padding: 8px 16px; border-radius: 9999px;
|
||||
background: var(--accent); color: #fff; font-size: 14px; font-weight: 600;
|
||||
animation: pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-top: 64px; }
|
||||
.card {
|
||||
background: #15151d; border: 1px solid #23232e; border-radius: 14px; padding: 20px;
|
||||
animation: fadeUp 600ms ease-out both;
|
||||
}
|
||||
.card h3 { margin: 0 0 8px; font-size: 18px; font-weight: 600; }
|
||||
.card p { margin: 0; font-size: 15px; line-height: 1.5; color: var(--muted); }
|
||||
.footer { margin-top: 80px; color: var(--muted); font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<section class="hero">
|
||||
<h1>Motion that replays</h1>
|
||||
<p>A deterministic fixture: heading and subtext animate in, the loader spins forever, and the badge pulses.</p>
|
||||
<div class="spinner" aria-label="Loading"></div>
|
||||
<span class="badge">Live</span>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="card"><h3>Entrance</h3><p>Cards fade and slide up on load via a CSS keyframes animation.</p></div>
|
||||
<div class="card"><h3>Looping</h3><p>The spinner uses an infinite linear rotation that never settles.</p></div>
|
||||
<div class="card"><h3>Pulsing</h3><p>The badge eases between two opacity and scale states forever.</p></div>
|
||||
</section>
|
||||
|
||||
<p class="footer">Reconstructed deterministically — the clone should replay each of these.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,64 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Motion fixture 2 — WAAPI + rotating text</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; }
|
||||
body {
|
||||
background: #ffffff; color: #111418;
|
||||
font-family: -apple-system, system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
.wrap { max-width: 880px; margin: 0 auto; padding: 80px 24px; }
|
||||
h1 { font-size: 48px; line-height: 1.1; font-weight: 800; margin: 0 0 24px; letter-spacing: -0.02em; }
|
||||
.rot { color: #5b5bff; }
|
||||
p { font-size: 18px; line-height: 1.6; color: #5a626e; margin: 0 0 16px; max-width: 600px; }
|
||||
.orbit { width: 56px; height: 56px; margin-top: 56px; border-radius: 12px; background: #5b5bff; }
|
||||
.fade { margin-top: 48px; padding: 24px; border: 1px solid #e6e8ec; border-radius: 14px; }
|
||||
.fade h3 { margin: 0 0 8px; font-size: 18px; }
|
||||
.fade p { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<h1>Build <span class="rot" id="rot">faster</span></h1>
|
||||
<p>The highlighted word rotates on an interval. The square orbits via the Web Animations API, and the card below fades in via WAAPI on load.</p>
|
||||
<div class="orbit" id="orbit" aria-hidden="true"></div>
|
||||
<div class="fade" id="fade">
|
||||
<h3>WAAPI entrance</h3>
|
||||
<p>This card is animated with element.animate(), not CSS keyframes.</p>
|
||||
</div>
|
||||
<ul style="margin-top:40px;padding:0;list-style:none;display:grid;gap:12px">
|
||||
<li style="padding:14px 16px;border:1px solid #e6e8ec;border-radius:10px">Deterministic reconstruction from observed evidence.</li>
|
||||
<li style="padding:14px 16px;border:1px solid #e6e8ec;border-radius:10px">Recognized-pattern allowlist, fixed templates, no synthesis.</li>
|
||||
<li style="padding:14px 16px;border:1px solid #e6e8ec;border-radius:10px">Gate-verified motion: reproduce, or freeze honestly.</li>
|
||||
</ul>
|
||||
<footer style="margin-top:56px;color:#9aa0aa;font-size:14px">Motion replays on load; the gates grade the settled frame.</footer>
|
||||
</main>
|
||||
<script>
|
||||
// Rotating text on an interval (the shopify/notion pattern).
|
||||
(function () {
|
||||
var el = document.getElementById('rot');
|
||||
var words = ['faster', 'cleaner', 'smarter', 'together'];
|
||||
var i = 0;
|
||||
setInterval(function () { i = (i + 1) % words.length; el.textContent = words[i]; }, 1400);
|
||||
})();
|
||||
// WAAPI: infinite orbit (translate loop) — persistent, observable at capture time.
|
||||
document.getElementById('orbit').animate(
|
||||
[
|
||||
{ transform: 'translateX(0) rotate(0deg)' },
|
||||
{ transform: 'translateX(120px) rotate(180deg)' },
|
||||
{ transform: 'translateX(0) rotate(360deg)' }
|
||||
],
|
||||
{ duration: 2400, iterations: Infinity, easing: 'ease-in-out' }
|
||||
);
|
||||
// WAAPI: finite fade+rise entrance on the card.
|
||||
document.getElementById('fade').animate(
|
||||
[ { opacity: 0, transform: 'translateY(24px)' }, { opacity: 1, transform: 'translateY(0)' } ],
|
||||
{ duration: 600, easing: 'ease-out', fill: 'both' }
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Motion fixture 3 — scroll-triggered reveals</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; }
|
||||
body { background: #0c0d12; color: #eef0f4; font-family: -apple-system, system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif; }
|
||||
.wrap { max-width: 760px; margin: 0 auto; padding: 64px 24px 160px; }
|
||||
h1 { font-size: 44px; line-height: 1.1; font-weight: 800; margin: 0 0 12px; letter-spacing: -0.02em; }
|
||||
.lede { font-size: 18px; color: #9aa2ad; margin: 0 0 48px; }
|
||||
/* Scroll-reveal: start hidden + lifted, transition in when .in is added on intersection. */
|
||||
.reveal { opacity: 0; transform: translateY(36px); transition: opacity 0.6s ease, transform 0.6s cubic-bezier(0.22,1,0.36,1); }
|
||||
.reveal.in { opacity: 1; transform: translateY(0); }
|
||||
.card { margin: 28px 0; padding: 28px; background: #15171f; border: 1px solid #242732; border-radius: 16px; }
|
||||
.card h2 { margin: 0 0 8px; font-size: 22px; font-weight: 700; }
|
||||
.card p { margin: 0; color: #9aa2ad; line-height: 1.6; }
|
||||
.spacer { height: 40vh; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<h1>Scroll to reveal</h1>
|
||||
<p class="lede">Each card below starts hidden and animates in when it scrolls into view.</p>
|
||||
<div class="spacer"></div>
|
||||
<section class="card reveal"><h2>First reveal</h2><p>Fades and slides up as it enters the viewport, driven by IntersectionObserver toggling a class with a CSS transition.</p></section>
|
||||
<section class="card reveal"><h2>Second reveal</h2><p>The same entrance, staggered down the page so it is comfortably below the initial fold.</p></section>
|
||||
<section class="card reveal"><h2>Third reveal</h2><p>A deterministic reproduction target: hidden at load, revealed on scroll, settling to full opacity.</p></section>
|
||||
<section class="card reveal"><h2>Fourth reveal</h2><p>The clone should hide these on load and reveal them on scroll, then the gate verifies it.</p></section>
|
||||
<section class="card reveal"><h2>Fifth reveal</h2><p>Content is never lost: a force-reveal timer guarantees everything appears even without scrolling.</p></section>
|
||||
</main>
|
||||
<script>
|
||||
var io = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (e) { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } });
|
||||
}, { rootMargin: '0px 0px -10% 0px' });
|
||||
document.querySelectorAll('.reveal').forEach(function (el) { io.observe(el); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Non-ARIA interactive patterns</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; color: #1a1a1a; background: #fff; }
|
||||
main { max-width: 760px; margin: 0 auto; padding: 48px 24px; }
|
||||
h1 { font-size: 30px; } h2 { font-size: 20px; margin-top: 40px; }
|
||||
|
||||
/* A custom dropdown with NO aria — just a div with a click handler. */
|
||||
.cmenu { position: relative; display: inline-block; }
|
||||
.cbtn { cursor: pointer; user-select: none; border: 1px solid #d1d5db; border-radius: 8px; padding: 10px 16px; font-size: 15px; background: #fff; }
|
||||
.cbtn.is-open { background: #eef2ff; border-color: #4f46e5; color: #4f46e5; }
|
||||
.cpanel { position: absolute; top: calc(100% + 6px); left: 0; min-width: 200px; background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,.12); padding: 8px; }
|
||||
.cpanel { display: none; }
|
||||
.cpanel.is-open { display: block; }
|
||||
.cpanel a { display: block; padding: 8px 12px; border-radius: 6px; color: #1a1a1a; text-decoration: none; }
|
||||
|
||||
/* A custom modal with NO aria — div trigger + div overlay. */
|
||||
.open-x { cursor: pointer; display: inline-block; border: 1px solid #d1d5db; border-radius: 8px; padding: 10px 16px; font-size: 15px; }
|
||||
.ovl { position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 50; display: none; align-items: center; justify-content: center; }
|
||||
.ovl.is-open { display: flex; }
|
||||
.box { background: #fff; border-radius: 14px; padding: 28px; max-width: 440px; width: 90%; }
|
||||
.x { float: right; cursor: pointer; font-size: 22px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Non-ARIA patterns</h1>
|
||||
<p>A custom dropdown and modal built from plain <div>s with click handlers and no ARIA at all — the kind the recognizer can only find via real event listeners + drive-and-diff.</p>
|
||||
|
||||
<h2>Custom dropdown</h2>
|
||||
<div class="cmenu">
|
||||
<div class="cbtn" id="cbtn">Resources ▾</div>
|
||||
<div class="cpanel" id="cpanel">
|
||||
<a href="#docs">Documentation</a>
|
||||
<a href="#guides">Guides</a>
|
||||
<a href="#api">API reference</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Custom modal</h2>
|
||||
<div class="open-x" id="openx">Contact us</div>
|
||||
<div class="ovl" id="ovl">
|
||||
<div class="box">
|
||||
<span class="x" id="closex">×</span>
|
||||
<h3>Contact us</h3>
|
||||
<p>Reach the team at hello@example.com — we usually reply within a day.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const cbtn = document.getElementById("cbtn"), cpanel = document.getElementById("cpanel");
|
||||
cbtn.addEventListener("click", () => {
|
||||
const open = cpanel.classList.toggle("is-open");
|
||||
cbtn.classList.toggle("is-open", open);
|
||||
});
|
||||
const ovl = document.getElementById("ovl");
|
||||
document.getElementById("openx").addEventListener("click", () => ovl.classList.add("is-open"));
|
||||
document.getElementById("closex").addEventListener("click", () => ovl.classList.remove("is-open"));
|
||||
ovl.addEventListener("click", (e) => { if (e.target === ovl) ovl.classList.remove("is-open"); });
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { ovl.classList.remove("is-open"); cpanel.classList.remove("is-open"); cbtn.classList.remove("is-open"); } });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Numeric Heading Repro</title>
|
||||
<style>*{margin:0;box-sizing:border-box}body{font-family:system-ui}section,footer{display:block;min-height:240px;padding:48px}h1{font-size:40px}h2{font-size:32px}h3{font-size:24px}</style></head>
|
||||
<body>
|
||||
<section><h1>Build faster than ever</h1><p>Lead paragraph for the hero block goes here.</p></section>
|
||||
<section><h2>0019 Iterate Faster</h2><p>This section heading starts with a numeric layer id.</p><a href="/x">Learn more</a></section>
|
||||
<section><h3>Pricing plans</h3><ul><li>Free</li><li>Pro</li><li>Team</li></ul></section>
|
||||
<section><h2>Frequently asked questions</h2><p>Answers to common questions.</p><span>more</span></section>
|
||||
<footer><p>© 2026 Example</p></footer>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 340 B |
@@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Polish fixture — iframe + inline-block headings</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 0; color: #111; background: #fff; }
|
||||
main { max-width: 760px; margin: 0 auto; padding: 56px 24px 96px; }
|
||||
section { margin: 0 0 64px; }
|
||||
.label { font-size: 13px; text-transform: uppercase; letter-spacing: .08em; color: #888; margin: 0 0 16px; }
|
||||
|
||||
/* Control: plain block heading */
|
||||
h1.plain { font-size: 56px; line-height: 1.1; font-weight: 700; letter-spacing: -0.02em; max-width: 520px; margin: 0; }
|
||||
|
||||
/* The linear/notion pattern: each word an inline-block, wraps across lines */
|
||||
h1.words { font-size: 56px; line-height: 1.1; font-weight: 700; letter-spacing: -0.02em; max-width: 520px; margin: 0; }
|
||||
h1.words .w { display: inline-block; }
|
||||
|
||||
/* Same, plus zero-width inline spacer spans between words (staggered-text rigs) */
|
||||
h1.spacer { font-size: 56px; line-height: 1.1; font-weight: 700; letter-spacing: -0.02em; max-width: 520px; margin: 0; }
|
||||
h1.spacer .w { display: inline-block; }
|
||||
h1.spacer .sp { display: inline-block; width: 0.28em; }
|
||||
|
||||
/* iframe embed: a sized box that should be preserved as a placeholder */
|
||||
.embed { width: 100%; max-width: 560px; height: 315px; border: 1px solid #ddd; border-radius: 12px; display: block; }
|
||||
.map { width: 320px; height: 200px; border: 0; margin-top: 24px; }
|
||||
|
||||
.grid { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 24px; }
|
||||
.chip { display: inline-block; background: #f1f5f9; border-radius: 8px; padding: 10px 16px; font-size: 15px; }
|
||||
|
||||
footer { border-top: 1px solid #eee; padding: 24px; text-align: center; color: #999; font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section>
|
||||
<p class="label">Control · plain heading</p>
|
||||
<h1 class="plain">Build better products with a faster planning tool</h1>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="label">Words · inline-block per word</p>
|
||||
<h1 class="words"><span class="w">Build</span> <span class="w">better</span> <span class="w">products</span> <span class="w">with</span> <span class="w">a</span> <span class="w">faster</span> <span class="w">planning</span> <span class="w">tool</span></h1>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="label">Words + zero-width spacers</p>
|
||||
<h1 class="spacer"><span class="w">Build</span><span class="sp"></span><span class="w">better</span><span class="sp"></span><span class="w">products</span><span class="sp"></span><span class="w">with</span><span class="sp"></span><span class="w">a</span><span class="sp"></span><span class="w">faster</span><span class="sp"></span><span class="w">planning</span><span class="sp"></span><span class="w">tool</span></h1>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="label">iframe · video embed placeholder</p>
|
||||
<iframe class="embed" src="https://www.youtube.com/embed/dQw4w9WgXcQ" title="Embedded video" allowfullscreen></iframe>
|
||||
<iframe class="map" src="https://www.openstreetmap.org/export/embed.html" title="Map"></iframe>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="label">flex chips (inline-block, control for layout)</p>
|
||||
<div class="grid">
|
||||
<span class="chip">Planning</span>
|
||||
<span class="chip">Issues</span>
|
||||
<span class="chip">Roadmaps</span>
|
||||
<span class="chip">Insights</span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer>Polish fixture · deterministic local test</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Sitemap: /sitemap.xml
|
||||
|
After Width: | Height: | Size: 68 B |
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SEO Rich Fixture</title>
|
||||
<meta name="description" content="A local fixture with rich SEO metadata for generator tests.">
|
||||
<meta name="keywords" content="clone, seo, fixture">
|
||||
<meta name="robots" content="index,follow">
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin">
|
||||
<meta name="theme-color" content="#123456">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<link rel="canonical" href="/seo-rich.html">
|
||||
<link rel="alternate" hreflang="en" href="/seo-rich.html">
|
||||
<link rel="alternate" hreflang="es" href="/es/seo-rich.html">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/seo-icon.png">
|
||||
<link rel="shortcut icon" href="/favicon.ico">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="mask-icon" href="/mask.svg" color="#123456">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="sitemap" type="application/xml" href="/sitemap.xml">
|
||||
<meta property="og:title" content="SEO Rich Fixture OG">
|
||||
<meta property="og:description" content="Open Graph description from the source page.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Fixture Site">
|
||||
<meta property="og:image" content="/og-image.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="SEO Rich Fixture Twitter">
|
||||
<meta name="twitter:description" content="Twitter card description from the source page.">
|
||||
<meta name="twitter:image" content="/twitter-image.png">
|
||||
<script type="application/ld+json" id="fixture-jsonld">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "SEO Rich Fixture",
|
||||
"url": "https://fixtures.example/seo-rich.html"
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: -apple-system, Segoe UI, Roboto, sans-serif; color: #172033; background: #f7fafc; }
|
||||
main { max-width: 880px; margin: 0 auto; padding: 64px 24px; }
|
||||
h1 { font-size: 42px; margin: 0 0 12px; letter-spacing: 0; }
|
||||
p { font-size: 18px; line-height: 1.6; margin: 0 0 18px; color: #48566f; }
|
||||
.panel { margin-top: 32px; padding: 24px; border: 1px solid #d9e2ef; border-radius: 8px; background: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>SEO Rich Fixture</h1>
|
||||
<p>This page exercises generator-level SEO inventory, metadata emission, icons, manifests, structured data, and llms.txt preservation.</p>
|
||||
<section class="panel">
|
||||
<h2>Captured content</h2>
|
||||
<p>The generated llms fallback can summarize useful page and route content when the source does not expose its own llms.txt.</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "SEO Rich Fixture",
|
||||
"short_name": "SEOFixture",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/manifest-icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/brand.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
],
|
||||
"theme_color": "#123456",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||