CtrlK
BlogDocsLog inGet started
Tessl Logo

adobe/stardust

Redesign an existing website to make it better. Built on top of impeccable.

71

Quality

89%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

current-state-schema.mdskills/extract/reference/

Per-page JSON schema

The shape of stardust/current/pages/<slug>.json. Every page extracted by Phase 2 of extract writes one of these. Downstream sub-commands (direct, prototype, migrate) consume it.

The file is JSON because every consumer is non-human. It carries a _provenance first key per the artifact-map convention.


Top-level shape

{
  "_provenance": {
    "writtenBy": "stardust:extract",
    "writtenAt": "2026-04-25T13:42:00Z",
    "readArtifacts": ["https://example.com/about"],
    "synthesizedInputs": [],
    "stardustVersion": "0.10.0",
    "renderedBy": "playwright",      // REQUIRED. "playwright" only — synthesis is forbidden (see § Live-render evidence)
    "fetchedAt": "2026-04-25T13:41:58Z",  // ISO 8601 timestamp of the live fetch (distinct from writtenAt)
    "waitMode": "networkidle",       // configured mode: fast | medium | spec | networkidle | domcontentloaded(fallback)
    "waitMs": 3820,                  // actual wait time, including grace and scroll pass — must be > 0
    "httpStatus": 200,               // final response status after redirects
    "contentType": "text/html",      // final response content-type (without charset)
    "heroSource": "dom"              // "dom" | "meta-fallback" — which source heroHeadline/heroLede came from (see § Hero headline)
  },
  "slug": "about",
  "url": "https://example.com/about",
  "finalUrl": "https://example.com/about/",
  "title": "About Example",
  "metaDescription": "...",
  "heroHeadline": "Designed for the way you actually work",   // see § Hero headline
  "heroLede": "One platform for your whole team, from intake to delivery.",
  "og": {
    "title": "...",
    "description": "...",
    "image": "https://example.com/og-about.jpg",
    "type": "website",
    "siteName": "Example"
  },
  "themeColor": { "light": "#ffffff", "dark": "#0a0a0a" },
  "language": "en",

  "headings": [ /* see § Headings */ ],
  "landmarks": [ /* see § Landmarks */ ],
  "ctas": [ /* see § CTAs */ ],
  "links": { "internal": [], "external": [] },
  "media": { /* see § Media */ },
  "forms": [ /* see § Forms */ ],
  "widgets": { /* see § Widgets */ },
  "dynamic": { /* see § Dynamic — script-captured evidence of the page's dynamic surface */ },
  "components": { /* see § Components */ },
  "perSectionStyle": [ /* see § Per-section style */ ],
  "embedDominance": { /* see § Embed dominance */ },
  "cssCustomProperties": [ /* see § CSS custom properties */ ],

  "screenshot": "stardust/current/assets/screenshots/about.png",

  "stats": {
    "wordCount": 612,
    "ctaCount": 4,
    "internalLinkCount": 18,
    "externalLinkCount": 3,
    "imageCount": 7
  }
}

§ Headings

Document order. Computed style snapshot of the heading itself.

{
  "level": 2,
  "text": "Our story",
  "id": "story",
  "domPath": "main > section:nth-child(2) > h2",
  "style": {
    "fontFamily": "Inter, system-ui",
    "fontWeight": 600,
    "fontSize": "clamp(2rem, 5vw, 3.5rem)",
    "lineHeight": 1.1,
    "letterSpacing": "-0.02em",
    "color": "rgb(15, 18, 23)"
  }
}

§ Hero headline

Two resolved convenience fields for the page's hero copy, computed per playwright-recipe.md § Capture list (5-bis). They exist because document-order heuristics (headings[0]) are unreliable on JS-rendered enterprise CMSes, where the visually-dominant tagline is buried among many <h2>s and the DOM carries hidden modal / promo / count states.

  • heroHeadline — the largest-font-size heading in the hero band (top ≤ ~820 px), after the junk-state filter; or, when that is empty / junk, the first sentence of metaDescription.
  • heroLede — the first substantial paragraph in the top ~1300 px, after the junk filter; or the full metaDescription as fallback.
  • _provenance.heroSource records "dom" or "meta-fallback".

Both are required (emit "" only when even the meta-description fallback is empty). headings[] remains the full, unfiltered outline; these fields do not replace it. Downstream prototype / migrate prefer heroHeadline / heroLede over re-deriving from headings[].

§ Landmarks

One entry per header, nav, main, aside, footer plus ARIA-role'd equivalents. The structure each landmark contains is in children[] with a flat list of section-level descendants — not the full DOM tree, just enough to map IA.

{
  "tag": "main",
  "role": "main",
  "id": null,
  "classes": [],
  "innerText": "...",                  // FULL innerText, no truncation
  "children": [
    {
      "tag": "section",
      "role": null,
      "id": "hero",
      "classes": ["hero", "hero--dark"],
      "purpose": "hero",          // heuristic: "hero" | "feature-list" | "social-proof" | "cta-band" | "footer-nav" | "form" | "rich-text" | "unknown"
      "headlineRef": 0,            // index into headings[] if any
      "innerTextSummary": "first 240 chars",
      "wordCount": 87,
      "body": [                    // structured paragraphs, in DOM order
        "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
        "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
      ],
      "lists": [
        { "ordered": false, "items": ["Item one", "Item two", "Item three"] }
      ],
      "qa": [                      // populated when an accordion is detected
        { "q": "How do I cancel?",  "a": "From settings → billing → cancel." }
      ],
      "quotes": [                  // populated when testimonials / blockquotes detected
        { "text": "Best tool we ship.", "attribution": "Jane Doe, Acme",
          "rating": 5 }
      ]
    }
  ]
}

innerText is captured in full — no length cap. The innerTextSummary field stays as a 240-char preview for cheap display in reports; consumers that need the full body read innerText directly or use the structured fields below.

purpose is a heuristic guess, not ground truth. Helps direct and prototype reason about IA without re-parsing. When unsure, emit "unknown" — never invent.

body[], lists[], qa[], quotes[] are required; emit [] when the section has none of that shape (a hero card with one heading and one CTA legitimately has no paragraphs / lists / accordion / quotes).

codeBlocks[] (page-level, sibling of body) captures every visible <pre>'s innerText verbatim, in document order. On developer-tool sites the install commands are the single most load-bearing content and the prose capture skips them (stardust-style e2e finding); every downstream phase that needs a command literal reads it from here, not from body[]. Emit [] when the page has no code blocks. Capture rule: playwright-recipe.md § Capture list 7-ter. Capture rules in playwright-recipe.md § Capture list (7-bis). These fields are what migrate consumes to render real body copy under each section heading; without them every body region falls back to the placeholder-with-signature treatment (per prototype/reference/ proposed-file-shell.md § Content sourcing hierarchy) even when the source page had real prose to reuse.

§ CTAs

Every visually-button-like element. Captured per playwright-recipe.md § Capture list (8).

{
  "label": "Start free trial",
  "href": "/signup",
  "tag": "a",
  "domPath": "main > section.hero > a.btn-primary",
  "style": {
    "backgroundColor": "rgb(20, 122, 255)",
    "color": "rgb(255, 255, 255)",
    "fontFamily": "Inter, system-ui",
    "fontWeight": 600,
    "borderRadius": "8px",
    "padding": "12px 24px",
    "boxShadow": "0 1px 2px rgba(0,0,0,0.06)"
  },
  "appearsAbove": "fold"          // "fold" | "below-fold"
}

§ Links

Two arrays: internal (same host) and external. Each entry:

{ "href": "/pricing", "text": "Pricing", "domPath": "header > nav > a:nth-child(2)" }

De-duplicate by (href, text). Keep the first occurrence's domPath.

§ Media

{
  "images": [
    {
      "src": "https://cdn.example.com/connect/9f.../hero.jpg?MOD=AJPERES&CACHEID=...",
      "currentSrc": "https://cdn.example.com/connect/9f.../hero.jpg?MOD=AJPERES&CACHEID=...",
      "srcset": "...",
      "alt": "Two engineers at a whiteboard",
      "naturalWidth": 2400,
      "naturalHeight": 1600,
      "resolves": true,
      "localPath": "stardust/current/assets/media/hero-a3f9.jpg"
    }
  ],
  "inlineSvgs": [
    { "viewBox": "0 0 24 24", "domPath": "...", "markupHash": "sha256:..." }
  ],
  "cssBackgrounds": [
    {
      "url": "https://example.com/img/slide-1.png",
      "domPath": "main > section.hero",
      "boundingClientRect": { "x": 0, "y": 0, "width": 1440, "height": 720 },
      "backgroundSize": "cover",
      "backgroundPosition": "center center",
      "backgroundRepeat": "no-repeat",
      "localPath": "stardust/current/assets/media/slide-1-b7c4.png"
    }
  ],
  "videos": [],
  "iframes": [
    { "src": "https://www.youtube.com/embed/...", "title": "Demo" }
  ]
}

localPath is set only for media stardust successfully downloaded. Failed downloads have localPath: null and a downloadError field.

src / currentSrc are captured with the query string intact (enterprise DAM/CDN URLs carry load-bearing ?MOD=…&CACHEID=… params; stripping them 404s). resolves is the result of a capture-time HEAD/GET (2xx + image content-type) issued with a browser User-Agent + Referer — see playwright-recipe.md § Capture list (11) § Source-URL fidelity. migrate omits or repairs (never authors) any image whose resolves is false, which is how about:error is prevented before it ships.

cssBackgrounds[] captures every element whose computed backgroundImage resolves to one or more url(...) references and whose rendered boundingClientRect is ≥100×80 px at the captured viewport. Smaller elements are filtered as icon backgrounds (chevrons, sprite glyphs, list bullets) — see playwright-recipe.md § Capture list (11). When an element declares multiple background-image layers (url(a.png), linear-gradient(...)), emit one entry per url(...) layer; gradients are not captured here (they live in _brand-extraction.json#motifs.gradients).

This is the field that lets hero images applied via CSS — full-bleed hero sections, parallax banners, section backgrounds — surface in extract output. Without it, <img>-only capture silently misses the visual hero on most page-builder / WordPress / Squarespace sites.

§ Forms

{
  "action": "/api/contact",
  "method": "post",
  "fields": [
    { "type": "email", "name": "email", "label": "Your email", "required": true },
    { "type": "textarea", "name": "message", "label": "Message", "required": true }
  ],
  "thirdParty": null               // or "stripe" | "calendly" | "typeform" | "mailchimp" | ...
}

§ Widgets

{
  "modals": [{ "trigger": "button.open-pricing", "domPath": "..." }],
  "accordions": [{ "domPath": "...", "itemCount": 6 }],
  "tabs": [{ "domPath": "...", "tabCount": 3 }]
}

Empty arrays are valid; missing keys are not.

§ Dynamic

Script-captured by crawl.mjs only with --dynamics (migration-bound: prepare-migration, replica and migrate set it) — per-page reach evidence of what the page fetched and how it was rendered, never a classification. The network side is recorded by a response listener attached before navigation; the DOM side is read from the settled document. The stardust:dynamics sub-skill probes archetypes in depth (_dynamics.json) and folds these sections into each finding's reach; decisions live in stardust/dynamic-features.md. Absent section = the crawl ran without the flag (redesign-only work).

{
  "endpoints": [                       // xhr/fetch/eventsource responses + anything JSON, deduped by METHOD + host + path pattern
    {
      "method": "GET",
      "host": "www.example.com",
      "path": "/api/news/{n}",          // ids collapsed: /{n} numeric, /{uuid}, /{hash} (16+ hex)
      "query": ["page", "sort"],        // query KEY names only, sorted — values are never recorded
      "resourceType": "fetch",          // playwright resourceType: xhr | fetch | eventsource | other (when matched by JSON content-type)
      "contentType": "application/json",
      "status": 200,
      "bytes": 4812,                    // content-length header; null when chunked
      "hits": 2,                        // calls collapsed into this row on this page
      "example": "https://www.example.com/api/news/1234",
      "sameSite": true                  // loose eTLD+1 match against the page's host
    }
  ],
  "thirdPartyScriptHosts": [{ "host": "js.vendor.com", "count": 3 }],   // script responses from other sites; same-site scripts are not listed
  "truncated": false,                  // true when the 150-endpoint cap was hit — treat the list as a sample
  "inlineData": [                      // <script type="application/json"> blobs (ld+json excluded — that's metadata)
    { "id": "__NEXT_DATA__", "type": "application/json", "bytes": 48213, "topLevelKeys": ["props", "page", "buildId"] }
  ],
  "globalState": ["__NEXT_DATA__"],    // well-known hydration globals present on window (dataLayer counts as evidence, not hydration)
  "frameworkHints": ["next"],          // DOM fingerprints: next | gatsby | nuxt | react | angular | vue | sveltekit | astro | turbo | webflow | wordpress | shopify | hubspot-forms | marketo-forms | aem-sites
  "forms": [                           // visible forms, ≤20; complements § Forms (which carries the field schema)
    { "action": "https://www.example.com/search", "hasAction": true, "method": "get", "sameOrigin": true, "fieldCount": 1, "fieldNames": ["q"], "search": true }
  ],
  "ariaLiveRegions": 0,
  "triggers": [{ "marker": "aria-haspopup=dialog", "href": null }],   // modal-trigger markers per page (reach for M findings)
  "mediaIds": ["987654"],                                             // player ids / player iframe srcs per page (reach for V findings)
  "summary": {
    "sameSiteEndpoints": 2, "thirdPartyEndpoints": 1, "thirdPartyScriptHosts": 1,
    "inlineDataBlobs": 1, "forms": 2, "searchForms": 1,
    "hydrated": true                   // frameworkHints or a hydration global present — the page was (at least partly) client-rendered
  }
}

Rules:

  • hasAction: false means the form has no action attribute — the action value shown is the page URL by HTML default and the form is almost certainly JS-submitted; look for a matching POST in endpoints.
  • search: true is a heuristic (role=search, type=search, a q|s|query|search|keyword(s)|term field, or /search in the action).
  • Empty arrays are valid. A page crawled with --dynamics and no dynamic surface still carries the section with zeroed summary counts; a missing section means the crawl ran without the flag.

_crawl-log.json#dynamicSurface

The site-level roll-up, written once per crawl: the same rows keyed across pages with pages (how many pages hit it) and examples[] (≤3 slugs). Sections: endpoints (≤300), thirdPartyScriptHosts, frameworkHints, globalState, formTargets, plus the counters pages, pagesWithSameSiteData, pagesWithSearchForm, pagesHydrated, truncatedPages. This is the view Phase 4.5 reads first; per-page dynamic is for drilling into one row.

§ Components

A closed-list inventory of recognisable component types per page. This is separate from § Widgets (which captures interactive ARIA roles) and § Landmarks (structural). Components fills the gap: visual patterns the site repeats that aren't necessarily ARIA-tagged.

The vocabulary is fixed — do not invent new keys. If a page uses something the vocabulary doesn't cover, log it under components.other with a free-form kind label.

{
  "cards":           { "count": 12, "examples": [".team-member", ".story-card"] },
  "grids":           { "count": 4,  "examples": ["main > section.team .row", ".stories .grid"] },
  "accordions":      { "count": 1,  "examples": ["details.faq"] },
  "tabs":            { "count": 0,  "examples": [] },
  "tables":          { "count": 2,  "examples": ["table.data"] },
  "modals":          { "count": 1,  "examples": ["[role=\"dialog\"].newsletter"] },
  "carousels":       { "count": 0,  "examples": [] },
  "videos":          { "count": 1,  "examples": ["video.hero-bg"] },
  "iframes":         { "count": 1,  "examples": ["iframe[src*=\"datawrapper\"]"] },
  "dataVizEmbeds":   { "count": 1,  "examples": ["iframe[src*=\"datawrapper\"]", "[class*=\"chart\"]"] },
  "teamTiles":       { "count": 8,  "examples": [".team-member"] },
  "pricingTiles":    { "count": 0,  "examples": [] },
  "testimonialCards":{ "count": 3,  "examples": [".testimonial"] },
  "logoStrip":       { "count": 1,  "examples": [".partner-logos"] },
  "timeline":        { "count": 0,  "examples": [] },
  "breadcrumbs":     { "count": 1,  "examples": ["nav.breadcrumb"] },
  "statRow":         { "count": 1,  "examples": [".impact-stats"] },
  "ctaBand":         { "count": 1,  "examples": ["section.cta-band"] },
  "formFields":      { "count": 6,  "examples": ["form input", "form textarea"] },
  "other":           []
}

Detection selectors (apply in order; first match wins per element):

keyselector heuristic
cards.card, [class*="card"]:not([class*="card-grid"]), article inside a grid
gridsparent of ≥3 visually-equal-width siblings (CSS grid or flex with wrap)
accordionsdetails, [role="region"][aria-labelledby] paired with [aria-expanded]
tabs[role="tablist"], .tabs containing [role="tab"]
tablestable (skip layout tables: [role="presentation"])
modalsdialog, [role="dialog"]
carousels[class*="carousel"], [class*="swiper"], [class*="slick"]
videosvideo element
iframesevery iframe
dataVizEmbedsiframe[src*="datawrapper"], iframe[src*="flourish"], iframe[src*="tableau"], [class*="chart"], canvas[class*="chart"]
teamTiles[class*="team"] [class*="member"], [class*="staff"], repeated card with <img> + name + role
pricingTiles[class*="pricing"] [class*="tier"], repeated card containing currency symbol + CTA
testimonialCards[class*="testimonial"], blockquote with cite
logoStripcontainer with ≥4 sibling img/svg of similar height, no text
timeline[class*="timeline"], ol[class*="step"]
breadcrumbsnav[aria-label*="breadcrumb" i], [class*="breadcrumb"]
statRowcontainer with ≥3 siblings each containing a number ≥10 + label
ctaBandfull-width section whose content is dominated by a heading + 1–2 CTAs
formFieldsevery form field across all forms on the page

count is the number of matching elements; examples is the first 2 distinct CSS selectors (sufficient to find them again, not always unique). Empty arrays are valid.

§ Embed dominance

Cross-origin iframes that carry a page's primary content. When the site CSS doesn't reach inside, the brand-surface extraction silently misses what is in fact the entire visual identity of these pages.

{
  "dominated": true,
  "iframeSrc": "https://app.datawrapper.de/...",
  "viewportCoveragePct": 78,         // % of viewport occupied by the iframe at 1440x900
  "mainHeightCoveragePct": 88,       // % of <main> height occupied
  "screenshot": "stardust/current/assets/screenshots/data-dashboard.png"
}

Set dominated: true when either viewportCoveragePct > 50 or mainHeightCoveragePct > 80. When dominated: false, the other fields can be null.

The screenshot is already captured by every page (per playwright-recipe.md § Capture list (14)); for embed-dominated pages, surface it explicitly here so direct and prototype know to reason from the screenshot rather than the (empty) computed-style data.

§ CSS custom properties

Every CSS custom property defined at :root (read via getComputedStyle(document.documentElement) and filtered to names starting with --).

[
  { "name": "--color-primary", "value": "#147aff" },
  { "name": "--space-md", "value": "16px" }
]

An empty array is itself a meaningful signal — it means the site ships no design tokens, which the Tensions detector flags. Do not omit the key; emit [] explicitly.

§ Per-section style

One entry per direct child of main (or per section landmark for non-main-using sites). The numbers feed _brand-extraction.json so brand-surface aggregation has a stable input.

{
  "sectionRef": "main > section:nth-child(1)",
  "purpose": "hero",
  "background": { "color": "rgb(8, 12, 20)", "hasImage": true, "hasGradient": false },
  "text": { "dominantColor": "rgb(255, 255, 255)" },
  "spacing": { "paddingBlock": "96px", "paddingInline": "48px", "gap": "24px" },
  "borderRadius": "12px",
  "fontFamilies": ["Inter", "Söhne"],
  "shadowsUsed": ["0 4px 16px rgba(0,0,0,0.12)"]
}

Required vs optional

Every top-level key listed above is required to be present in the JSON. Missing data within a key is represented by an empty array, empty object, or explicit null — never by omitting the key. This keeps consumers simple.

The exceptions: og, themeColor, forms, widgets may be empty objects. Empty arrays for headings, landmarks, ctas, links.* are valid (and unusual — log a warning).

Versioning

The schema version is implicit in _provenance.stardustVersion. If the schema evolves, downstream consumers branch on the version. Backward-compatible additions do not require a version bump.

Live-render evidence (synthesis is forbidden)

Every per-page JSON file is the result of a Playwright (or Playwright MCP) live render against the source URL. Synthesizing a page record from _brand-extraction.json + URL patterns + captured photo IDs is forbidden, even when the synthesized shape would be plausible. The 2026-04-30 e-commerce cascade ran "successfully" for four phases on a 25-page inventory where 20 pages had been synthesized this way; the failure was invisible until a meta-question exposed the missing live-render evidence.

The forbidden shortcut is signed for in _provenance:

fieldrequired valueenforced where
renderedBy"playwright"extract write-time + validateProvenance() at every downstream phase
waitMsinteger > 0same
fetchedAtISO 8601 timestamp stringsame
httpStatusinteger (the final response status after redirects)same
waitModeone of fast / medium / spec / networkidle / domcontentloaded / a <mode>(fallback) formsame

extract (and extract --prep) must refuse to mark a page extracted in state.json without these five fields populated from a real Playwright render. Sub-agents delegated to perform extraction must return a per-page evidence table (slug / waitMode / waitMs / fetchedAt) and explicitly forbid synthesis in their prompt — "must actually invoke Playwright per page" alone is not sufficient; spec-level prompts must list the synthesis shortcut by name and forbid it.

Downstream phases (direct --prep, prototype, migrate, prepare-migration orchestrator) call validateProvenance(page) per skills/stardust/reference/state-machine.md § Provenance validation on entry, before any work, and abort with a clear error if any page in scope has missing or fabricated provenance. The double-guard (write-time refusal at extract + read-time validation at every consumer) is intentional defense-in-depth: single-layer guards have already missed at least one synthesis failure mode in production.

skills

README.md

tile.json