SlingshotBeta

Slingshot Convention

The data-sl-* spec

Instrument your AI-generated site once. Let anyone edit text, images, and colors visually, without re-prompting the AI.

v3.0MIT LicenseFramework-agnostic

Copies a condensed version for any AI chat

Slingshot Convention v3

What changed from v2

Backwards compatibility guarantee: every valid v2 site remains connectable and editable under v3, with no required changes. All v3 additions are additive or SHOULD-level. One honest carve-out: a v2 multi-page site that reused the same region ID on two different pages (valid under v2's per-document rule) now surfaces that collision as a warning (SL001, cross-page) — never a connect-blocking error. Single-page sites — the overwhelming majority of v2 output — are untouched. See "Migrating from v2."

  • Multi-page & project-wide ID scoping (new). Region ID uniqueness is now defined per project, not per document. The manifest gains a pages array; regions may declare which page they belong to. Page identification is defined per framework, including an honest treatment of the SPA/client-router case.
  • Head/meta regions (new). Page title, meta description, OG tags, and favicon are editable via a manifest-level meta block — not DOM attributes. Project-level defaults with per-page overrides. This backs the sharing/favicon editor panel.
  • Anchor-target enumeration (new, SHOULD). data-sl-section wrappers SHOULD carry an id matching the section name, so #anchors work on the published site and editors can enumerate link targets. Existing v2 sites without ids remain conformant.
  • List/.map() guidance (new, prohibited pattern). Editable list items must be written as literal markup, never generated by mapping over a data constant. This is the top real-world conformance failure; it now has a rule, an example, and a fix.
  • Region labels (new, optional). Any manifest region may carry "label": "Hero headline" for human-readable editor UI. Optional everywhere; absence never affects conformance.
  • data-sl-embed (new region type). Videos (YouTube/Vimeo), maps, scheduling widgets, and native <video>/<audio> get a first-class URL-editable region — the user pastes a link instead of editing raw iframe HTML. data-sl-html remains the escape hatch for multi-element snippets only.
  • Clarification: alt text is editable as part of the image region — editing an image includes editing its alt.
  • Clarification: manifest lineNumber is explicitly optional and advisory. It drifts after commit-backs; editors must resolve regions by scanning filePath for the ID, never by trusting the line number.
  • Reserved (specced now, tooling lands later): Inject markers (<!-- sl-inject:head --> / <!-- /sl-inject:head -->, plus body-end) and numbered conformance rule codes (SL001–SL014).
  • Appendices: the canonical instrument-in-place prompt is now part of the spec (Appendix A); the condensed "copy as AI prompt" one-liner is updated (Appendix B).

Purpose

When generating a website, your output will likely be edited later by a non-developer through a visual editor (Slingshot). To make that possible without forcing them back into your prompt loop, instrument every meaningfully editable piece of content with data-sl-* attributes. This is a one-time annotation pass during generation. It costs you nothing in output quality and saves the user dozens of re-prompts later.

Core principle

Annotate intent, not implementation. If a human reading the page would think "I might want to change this someday," it gets a data-sl-* attribute. If it's structural or programmatic (a className, a layout div, a script tag), it doesn't.

Pages and ID scoping (NEW in v3)

The scoping rule

Region IDs are unique across the whole project — every file, every page — with the two deliberate exceptions that already existed in v2: data-sl-color and data-sl-bg-image token IDs are shared by design.

v2 said "unique within the document," which was written for single-page output. On a multi-page site, two pages each carrying data-sl-text="hero-headline" would collide in the project-wide editor. So:

  • Page-specific regions on multi-page sites: prefix with the page slug — about-hero-headline, contact-form-title.
  • Shared components (header, footer, nav rendered on every page): annotate once, in the component source. One region, appears everywhere — that's correct, not a duplicate.
  • Single-page sites change nothing: document scope and project scope are the same thing.

What a "page" is, per framework

FrameworkA page ispathsource
Vanilla HTMLeach .html filethe file's URL path (about.html/about)the file
Next.js (App Router)each page.tsx/page.jsx under app/derived from the directory routethat file
Next.js (Pages Router)each file under pages/derived from the file routethat file
Astroeach file under src/pages/derived from the file routethat file
SvelteKiteach +page.sveltederived from the directory routethat file
Nuxteach file under pages/derived from the file routethat file

The SPA / client-router case, honestly: a React/Vue app using a client-side router (React Router, Vue Router) has no build-time file↔URL mapping — routes live in code. The convention does not pretend otherwise, and the editor will never parse router code to discover pages (that's arbitrary-code parsing — non-goal #2). Instead:

  • The generating AI SHOULD enumerate the routes it just wrote into the manifest pages array, with source pointing at each route's component file. It knows the routes because it wrote them.
  • If routes are dynamic or the mapping is genuinely unclear, fall back to a single page entry ("id": "app", "path": "/"). This is fully valid — you lose per-page grouping in the editor sidebar, nothing else.
  • The manifest is the source of truth for pages; DOM scanning is the editor's fallback, never router parsing.

The pages array

"pages": [
  {
    "id": "home",
    "path": "/",
    "source": "src/app/page.tsx",
    "title": "Home"
  },
  {
    "id": "about",
    "path": "/about",
    "source": "src/app/about/page.tsx",
    "title": "About"
  }
]
  • id: kebab-case, unique within pages. Regions reference it via their optional page field.
  • path: the URL path as served.
  • source: the file that renders the page.
  • title: optional human-readable label for editor UI (same rules as region labels — never an identifier).

Regions declare "page": "about" when they belong to one page. Regions in shared components (header, footer) and global tokens (colors) omit page or set it null — meaning "appears on all pages."

Head & meta regions (NEW in v3)

Page titles, descriptions, OG tags, and favicons are content the half-stepper absolutely wants to edit — but head tags are awkward to attribute-annotate across frameworks (Next.js metadata exports, Astro frontmatter, plain <head>). So meta is a manifest-level block, not DOM attributes.

Project-level default (top level of slingshot.json), with per-page overrides (inside a page entry). Missing per-page fields inherit the project default.

"meta": {
  "filePath": "src/app/layout.tsx",
  "title": "Acme Co.",
  "description": "The future of widgets.",
  "ogTitle": "Acme Co. — The future of widgets",
  "ogDescription": "Faster. Stronger. Better.",
  "ogImage": "/images/og.jpg",
  "favicon": "/favicon.ico"
}

Field rules (all fields optional strings):

  • filePath — where these values are defined in source (root layout, index.html head, Astro frontmatter). The editor writes meta changes back to this file. If omitted, the editor scans conventional locations.
  • title, description — the <title> and <meta name="description"> values.
  • ogTitle, ogDescription — fall back to title / description when absent. Don't emit them if they'd be identical.
  • ogImage, faviconsite-relative paths into the user's repo (e.g. /images/og.jpg). Never an external Slingshot-hosted URL — images live in the user's repo or bucket, period (non-goal #14/#16).

A per-page override uses the same shape inside the page entry:

{
  "id": "about",
  "path": "/about",
  "source": "src/app/about/page.tsx",
  "meta": {
    "title": "About — Acme Co.",
    "description": "Who we are and why widgets."
  }
}

This block is what the sharing/favicon editor panel builds against: the panel reads the merged (default + override) values, writes edits to the relevant filePath, and previews the OG card from ogImage.

Required attributes

data-sl-text="<unique-id>"

For any element whose text content should be editable.

Apply to: <h1><h6>, <p>, <span>, <a>, <button>, <li>, <label>, <blockquote>, table cells.

<h1 data-sl-text="hero-headline">Welcome to our store</h1>
<p data-sl-text="hero-subtitle">The freshest produce in town.</p>
<button data-sl-text="cta-primary">Shop now</button>

ID rules: kebab-case, semantically meaningful (hero-headline, not text-1), unique within the project (see Pages and ID scoping). If the same text appears multiple times intentionally, use suffixes: footer-tagline-1, footer-tagline-2. On multi-page sites, prefix page-specific regions with the page slug: about-hero-headline.

data-sl-image="<unique-id>"

For any <img> element whose source should be editable. ALWAYS include alt.

<img
  data-sl-image="hero-bg"
  src="/images/hero.jpg"
  alt="Mountain sunrise"
/>

alt is part of the region (clarified in v3): editing an image region includes editing its alt text. Generate meaningful alt values — they're the editor's starting point, not decoration.

For images embedded as CSS backgrounds, use data-sl-bg-image instead (see Optional Attributes).

data-sl-link="<unique-id>"

For <a> elements where the URL itself should be editable separately from the text. Anchor URLs (#section-id) and relative URLs (/about) are valid and supported.

<a
  data-sl-link="header-cta-link"
  data-sl-text="header-cta-label"
  href="/signup"
>Get started</a>

<!-- Anchor URL is valid -->
<a
  data-sl-link="nav-features"
  data-sl-text="nav-features-label"
  href="#features"
>Features</a>

When data-sl-link and data-sl-text co-exist on the same element, the URL and the link text are independently editable. The parser emits two regions; the editor SHOULD display them as one composite item in its sidebar UX.

Buttons-styled-as-links: if a <button>-looking element has a URL destination, use an <a> with data-sl-link styled with button classes, not a <button data-sl-text>. The latter has no URL to edit.

<!-- RIGHT: anchor styled as button, URL editable -->
<a
  data-sl-link="hero-cta-link"
  data-sl-text="hero-cta-label"
  href="/signup"
  class="rounded-lg bg-primary px-6 py-3 text-white"
>
  Get started
</a>

<!-- WRONG: button with no URL field -->
<button data-sl-text="hero-cta-label">Get started</button>

data-sl-color="<token-name>"

For elements whose color (text, background, or border) is meant to be themed. Use this when the color comes from a CSS custom property the user might want to recolor globally.

Token IDs ARE meant to be shared. Multiple elements can carry the same data-sl-color="brand-primary" — across elements, files, and pages. Changing the token recolors every element bound to it — that's the entire point of theming. This is the only data-sl-* attribute where ID uniqueness does NOT apply (along with data-sl-bg-image).

<button
  data-sl-color="brand-primary"
  style="background: var(--brand-primary);"
>
  Subscribe
</button>

<h1
  data-sl-color="brand-primary"
  style="color: var(--brand-primary);"
>
  Welcome
</h1>

The token name should match the CSS custom property name without the -- prefix.

Property semantics: the editor updates whichever color property is currently set in the element's inline style (text color, background, or border). To override the inference, add data-sl-color-property="text|background|border".

<!-- Inference: parser sees `border: ...` in style, updates the border color -->
<div
  data-sl-color="brand-accent"
  style="border: 2px solid var(--brand-accent);"
>...</div>

<!-- Explicit override: forces background update even if other properties are set -->
<div
  data-sl-color="brand-accent"
  data-sl-color-property="background"
  style="background: var(--brand-accent); color: white;"
>...</div>

data-sl-section="<section-name>"

Wrap logical groupings (hero, features, pricing, footer) so the editor can group regions in its UI. Section wrappers are not editable for text/image regions — they only organize.

Anchor rule (NEW in v3, SHOULD): section wrappers SHOULD also carry an id attribute matching the section name:

<section data-sl-section="features" id="features">

Why: #features anchor links work on the published site with zero extra wiring, and the editor can enumerate every anchor target on the site when the user edits a data-sl-link — a dropdown of real destinations instead of a blank URL field. This is SHOULD, not MUST: existing v2 sites without ids remain fully conformant.

data-sl-section wrappers MAY also carry data-sl-color or data-sl-bg-image attributes when the entire section has themeable colors or backgrounds. Those are editable; the section wrapping itself is not.

<section data-sl-section="hero" id="hero">
  <h1 data-sl-text="hero-headline">…</h1>
  <p data-sl-text="hero-subtitle">…</p>
</section>

<section
  data-sl-section="cta"
  id="cta"
  data-sl-bg-image="cta-bg"
  style="background-image: url('/images/cta-bg.jpg');"
>
  <h2 data-sl-text="cta-headline">…</h2>
</section>

Optional attributes

data-sl-bg-image="<unique-id>"

For elements with a CSS background image that should be editable. Same shared-token rule as data-sl-color — multiple elements can carry the same data-sl-bg-image ID and changing the source updates all bound elements.

<div
  data-sl-bg-image="hero-bg"
  style="background-image: url('/images/hero.jpg');"
>

data-sl-image-focal="<x,y>"

For <img> elements (or elements with data-sl-bg-image) where the editor's crop should be focal-point-based instead of CDN-transform-based. The value is two integers 0–100 representing the focal center as percentages (x horizontal, y vertical).

<img
  data-sl-image="staff-photo"
  data-sl-image-focal="50,30"
  src="/images/team.jpg"
  alt="Our team"
  style="object-fit: cover; object-position: 50% 30%;"
/>

The editor's focal-point picker writes this attribute when the connected storage backend doesn't support CDN-side transforms (generic S3, paste-a-URL). For Supabase Storage and Cloudflare R2, the editor instead writes transform parameters to the URL itself (?width=X&height=Y&resize=cover) and data-sl-image-focal is unused.

The AI is not expected to emit this attribute during initial generation. It's added by the editor when the user crops via the focal-point UI.

data-sl-list="<unique-id>" + data-sl-list-item

For repeatable content blocks (testimonials, feature cards, pricing tiers) where the user should be able to add/remove/reorder items.

<ul data-sl-list="testimonials">
  <li data-sl-list-item>
    <p data-sl-text="testimonial-1-quote">"Amazing service."</p>
    <span data-sl-text="testimonial-1-author">— Jane D.</span>
  </li>
  <li data-sl-list-item>
    <p data-sl-text="testimonial-2-quote">"Changed my life."</p>
    <span data-sl-text="testimonial-2-author">— John S.</span>
  </li>
</ul>

The editor uses this to offer "Add testimonial" / "Remove" / drag-to-reorder UI.

data-sl-list-item is a structural marker, not a region ID. Don't add a value (it doesn't take one). The editor identifies individual items by their position within the list, not by an ID on the item itself. The editable regions inside each item still have their own IDs.

Write list items as literal markup. Never generate them by mapping over a data constant. (NEW in v3 — this is the single most common conformance failure in the wild. Prohibited going forward; validators surface it as warning SL012 rather than a blocking error, so existing sites can still connect and fix it via the instrument-in-place prompt.)

The reflex — especially in React — is to hoist repeated content into an array and .map() over it:

// ❌ WRONG — non-conformant. The content lives in a JS constant, not in markup.
const FEATURES = [
  { title: "Lightning fast", body: "Sub-second response times." },
  { title: "Always reliable", body: "99.99% uptime guaranteed." },
  { title: "Built for scale", body: "From startup to enterprise." },
];

<ul data-sl-list="features">
  {FEATURES.map((f) => (
    <li data-sl-list-item key={f.title}>
      <h3>{f.title}</h3>
      <p>{f.body}</p>
    </li>
  ))}
</ul>

This breaks in three unfixable ways: the items can't carry per-item region IDs (the JSX is one template, not three items); the editable text lives in a JavaScript array the editor will not parse (source text nodes are the contract — chasing data through code is arbitrary-code parsing, which Slingshot permanently refuses); and add/remove/reorder can't be expressed as a markup edit at all.

Write the items out:

// ✅ RIGHT — literal items, each independently addressable.
<ul data-sl-list="features">
  <li data-sl-list-item>
    <h3 data-sl-text="feature-1-title">Lightning fast</h3>
    <p data-sl-text="feature-1-body">Sub-second response times.</p>
  </li>
  <li data-sl-list-item>
    <h3 data-sl-text="feature-2-title">Always reliable</h3>
    <p data-sl-text="feature-2-body">99.99% uptime guaranteed.</p>
  </li>
  <li data-sl-list-item>
    <h3 data-sl-text="feature-3-title">Built for scale</h3>
    <p data-sl-text="feature-3-body">From startup to enterprise.</p>
  </li>
</ul>

Yes, it's more lines. It's also the entire point: three literal items are three things a non-developer can edit, duplicate, and delete. One .map() is a thing only a developer can touch. If the list is genuinely dynamic (fetched from an API at runtime), it isn't editable content — leave it unannotated.

data-sl-embed="<unique-id>" (NEW in v3)

For elements whose embedded-content source URL should be editable: video embeds (YouTube, Vimeo), maps (Google Maps), scheduling widgets (Calendly), native <video>/<audio> sources. Apply it to the <iframe>, <video>, or <audio> element itself. The editable value is the src URL — nothing else.

<!-- YouTube video: the user pastes a new video link, done -->
<iframe
  data-sl-embed="testimonial-video"
  src="https://www.youtube.com/embed/abc123"
  title="Customer story"
  allowfullscreen
></iframe>

<!-- Google Maps: the user pastes a new share/embed link -->
<iframe
  data-sl-embed="location-map"
  src="https://www.google.com/maps/embed?pb=…"
  title="Our location"
></iframe>

<!-- Native video file -->
<video data-sl-embed="hero-video" src="/videos/intro.mp4" controls></video>

ID rules: unique like text/image regions (not shared). Editor behavior: editors present a URL field, never a code editor, and SHOULD recognize common providers and normalize pasted share links to their embeddable form (e.g. youtube.com/watch?v=Xyoutube.com/embed/X, a Google Maps share link → its embed URL) — the user pastes the link they have, not the link the iframe needs. Editors SHOULD restrict values to https: URLs (or site-relative paths for native media).

Prefer data-sl-embed over data-sl-html whenever the thing being edited is really just the source URL.

data-sl-html="<unique-id>"

For arbitrary HTML blocks the user might need to edit as raw markup — multi-element embed snippets (a form embed with its <script> tag), custom widgets, glyph-based font icons that don't fit other types.

<div data-sl-html="newsletter-form">
  <!-- provider's multi-element embed snippet -->
  <form action="https://provider.example/subscribe" method="post">…</form>
  <script src="https://provider.example/embed.js"></script>
</div>

Use sparingly. data-sl-html regions are an "escape hatch" — the editor opens a code editor for them, which means the half-stepper user is dropped into raw HTML. Prefer specific attributes (data-sl-embed for iframe/video sources, data-sl-image, data-sl-link) when possible.

Prohibited patterns

Don't annotate non-content elements:

<!-- WRONG: layout containers don't need annotations -->
<div data-sl-text="wrapper" class="flex items-center">…</div>

<!-- WRONG: navigation structure isn't editable copy -->
<nav data-sl-text="nav">…</nav>

Don't nest data-sl-text inside data-sl-html:

<!-- WRONG: ambiguous editing ownership -->
<div data-sl-html="hero">
  <h1 data-sl-text="headline">…</h1>
</div>

If a region needs raw HTML editing, it owns its entire content. Otherwise, decompose into specific attributes.

Don't use generic IDs:

<!-- WRONG -->
<p data-sl-text="text-1">…</p>
<p data-sl-text="text-2">…</p>

<!-- RIGHT -->
<p data-sl-text="hero-subtitle">…</p>
<p data-sl-text="features-intro">…</p>

Don't use <button> when the action is navigation:

<!-- WRONG: no URL to edit -->
<button data-sl-text="hero-cta-label">Get started</button>

<!-- RIGHT: URL and label both editable -->
<a data-sl-link="hero-cta-link" data-sl-text="hero-cta-label" href="/signup"
   class="rounded-lg bg-primary px-6 py-3 text-white">Get started</a>

Don't annotate dynamic content:

<!-- WRONG: this is a date, not editable copy -->
<span data-sl-text="copyright-year">{currentYear}</span>

Don't generate editable list items with .map() over a data constant (NEW in v3 — see data-sl-list above for the full example and the fix). Editable content lives in markup, not in JavaScript arrays.

Don't reuse a region ID across pages (NEW in v3): hero-headline on both / and /about is a project-scope collision. Prefix page-specific regions with the page slug (about-hero-headline). Color and bg-image tokens remain the deliberate exception — shared across pages by design. (Validators surface a cross-page collision as warning SL001 — existing v2-era sites still connect.)

The manifest file

After generating the site, output a /slingshot.json file at the repo root that catalogs all editable regions, pages, and meta.

v3 manifest additions: pages array, meta block (project-level + per-page), optional label on regions, optional page on regions. Manifests declaring "version": "2" remain fully valid — the v3 fields simply require "version": "3".

{
  "version": "3",
  "generatedBy": "claude-fable-5",
  "generatedAt": "2026-07-13T19:00:00Z",
  "framework": "react",
  "meta": {
    "filePath": "src/app/layout.tsx",
    "title": "Acme Co.",
    "description": "The future of widgets.",
    "ogImage": "/images/og.jpg",
    "favicon": "/favicon.ico"
  },
  "pages": [
    {
      "id": "home",
      "path": "/",
      "source": "src/app/page.tsx",
      "title": "Home"
    },
    {
      "id": "about",
      "path": "/about",
      "source": "src/app/about/page.tsx",
      "title": "About",
      "meta": {
        "title": "About — Acme Co.",
        "description": "Who we are and why widgets."
      }
    }
  ],
  "regions": [
    {
      "id": "hero-headline",
      "type": "text",
      "section": "hero",
      "page": "home",
      "label": "Hero headline",
      "filePath": "src/components/Hero.tsx",
      "lineNumber": 12
    },
    {
      "id": "hero-bg",
      "type": "image",
      "section": "hero",
      "page": "home",
      "label": "Hero background photo",
      "filePath": "src/components/Hero.tsx",
      "lineNumber": 18
    },
    {
      "id": "hero-cta-link",
      "type": "link",
      "section": "hero",
      "page": "home",
      "label": "Hero button URL",
      "filePath": "src/components/Hero.tsx",
      "lineNumber": 24
    },
    {
      "id": "brand-primary",
      "type": "color",
      "section": null,
      "page": null,
      "label": "Brand primary color",
      "filePath": "src/index.css",
      "lineNumber": 3
    },
    {
      "id": "cta-bg",
      "type": "bg-image",
      "section": "cta",
      "page": "home",
      "label": "CTA background",
      "filePath": "src/components/Cta.tsx",
      "lineNumber": 8
    },
    {
      "id": "demo-video",
      "type": "embed",
      "section": "demo",
      "page": "home",
      "label": "Demo video",
      "filePath": "src/components/Demo.tsx",
      "lineNumber": 22
    },
    {
      "id": "newsletter-form",
      "type": "html",
      "section": "footer",
      "page": null,
      "label": "Newsletter signup embed",
      "filePath": "src/components/Footer.tsx",
      "lineNumber": 31
    },
    {
      "id": "testimonials",
      "type": "list",
      "section": "social-proof",
      "page": "home",
      "label": "Testimonials",
      "filePath": "src/components/Testimonials.tsx",
      "lineNumber": 5
    },
    {
      "id": "testimonial-1-quote",
      "type": "text",
      "section": "social-proof",
      "page": "home",
      "label": "Testimonial 1 — quote",
      "filePath": "src/components/Testimonials.tsx",
      "lineNumber": 8
    },
    {
      "id": "testimonial-1-author",
      "type": "text",
      "section": "social-proof",
      "page": "home",
      "label": "Testimonial 1 — author",
      "filePath": "src/components/Testimonials.tsx",
      "lineNumber": 9
    },
    {
      "id": "testimonial-2-quote",
      "type": "text",
      "section": "social-proof",
      "page": "home",
      "label": "Testimonial 2 — quote",
      "filePath": "src/components/Testimonials.tsx",
      "lineNumber": 13
    },
    {
      "id": "testimonial-2-author",
      "type": "text",
      "section": "social-proof",
      "page": "home",
      "label": "Testimonial 2 — author",
      "filePath": "src/components/Testimonials.tsx",
      "lineNumber": 14
    },
    {
      "id": "about-hero-headline",
      "type": "text",
      "section": "hero",
      "page": "about",
      "label": "About page headline",
      "filePath": "src/app/about/page.tsx",
      "lineNumber": 9
    }
  ]
}

Note that:

  • brand-primary has "page": null — it's a shared token used on both the home CTA and the About page header. One manifest entry, project-wide effect. Color regions point to the file containing the <style> block or CSS file where the custom property is defined, not to any one element using it.
  • Regions in shared components (header, footer) also use "page": null — they appear on every page.
  • label is optional everywhere. It's a display string for the editor sidebar and Punch List output. It is never an identifier, never required, and its absence never affects conformance. The generating AI should emit labels — they're free to write and make the editor dramatically friendlier.
  • lineNumber is optional and advisory (clarified in v3). Every commit-back shifts line numbers; editors MUST resolve a region by scanning filePath for its ID and MUST NOT treat lineNumber as ground truth. Emit it if you have it; omit it without guilt.
  • List-item child regions appear as flat top-level entries with their own IDs (e.g., the four testimonial-* entries above — every editable region inside every list item gets its own entry), not nested under the parent list.
  • section is null for regions outside any data-sl-section wrapper (typical for global color tokens).

The manifest is optional but recommended — it speeds up the editor's source-map step. Without it, the editor falls back to scanning all repo files.

Reserved: Inject markers (tooling lands later)

Reserved namespace — specced in v3, editor tooling ships later. Generators MAY emit these today; nothing breaks if they don't.

Slingshot's Inject panel writes the user's own third-party snippets (GA4, Plausible, Meta Pixel, Mailchimp, Calendly) into the user's own repo. It needs a deterministic write target. The convention reserves paired HTML comment markers:

<head>
  …
  <!-- sl-inject:head -->
  <!-- /sl-inject:head -->
</head>
<body>
  …
  <!-- sl-inject:body-end -->
  <!-- /sl-inject:body-end -->
</body>
  • Two slots: head (analytics, pixels, meta-level scripts — placed just before </head>) and body-end (widgets, chat bubbles — placed just before </body>).
  • Markers are empty at generation time. The user pastes snippets through the Inject panel; Slingshot writes them between the markers, visibly, into the user's repo. Nothing runs on Slingshot infrastructure, ever.
  • The whole sl-inject:* comment namespace is reserved for future slots.
  • Emitting the markers is OPTIONAL. Sites without them stay conformant; the Inject panel will fall back to inserting markers itself at the conventional positions when the feature ships.

Reserved: Conformance rule codes (tooling lands later)

Reserved — specced in v3 so the CLI, the validation-failed flow, and a future GitHub Action share one definition. Codes are stable once assigned; new checks get new codes.

CodeSeverityCheck
SL001error (same page/file) · warning (across pages)Duplicate region ID (non-shared types) within the project. Cross-page duplicates are a warning, not an error, so v2-era multi-page sites still connect and fix at leisure.
SL002error<img> with data-sl-image missing alt
SL003warningVisible <img> without data-sl-image
SL004warningGeneric, non-semantic region ID (text-1, image-2)
SL005errorRegion ID not kebab-case
SL006errorNested annotation (e.g. data-sl-text inside data-sl-html)
SL007warningAnnotation on a layout-only element
SL008warningNavigation <button> carrying data-sl-text (should be <a data-sl-link>)
SL009errordata-sl-list-item carrying a value
SL010warningManifest out of sync (region entry points at a file/ID that doesn't match source)
SL011warningVisible heading without data-sl-text
SL012warningEditable list rendered by .map() over a data constant
SL013warningdata-sl-color token with no matching CSS custom property
SL014warningVisible <iframe>/<video> without data-sl-embed (or an enclosing data-sl-html)

Errors block the "connect repo" validation; warnings surface in the validation report with the instrument-in-place prompt (Appendix A) as the fix path. Severity assignments are draft and may be tuned when the validator adopts the codes.

Framework-specific notes

The convention is framework-agnostic. The same data-sl-* attributes work across:

  • Vanilla HTML: attributes appear directly in .html files
  • React/JSX/TSX: attributes are JSX attributes; React passes them through to the DOM
  • Vue: use :data-sl-text="..." for dynamic IDs, plain data-sl-text="..." for static
  • Svelte: plain attribute syntax works
  • Astro: plain attribute syntax in .astro files
  • Next.js / Nuxt / SvelteKit / Remix: all framework-built routes inherit attributes

If your output is server-rendered with dynamic content, annotate the static template, not the rendered output. The editor can find the source.

For what counts as a "page" in each framework, see Pages and ID scoping above.

Conformance levels

When generating, target these coverage levels:

LevelDescription
BronzeAll <h1>, <h2>, hero text, and primary CTAs annotated. Minimum viable.
SilverAll visible text, all <img> tags, all CTAs and links. Recommended default.
GoldSilver + all repeatable lists annotated, all branded colors as tokens, all sections wrapped (with anchor ids), manifest with pages + meta + labels. The half-stepper can edit the entire site without a developer.

Default to Silver. Aim for Gold when the user's prompt mentions handing the site off, ongoing edits, or non-technical stakeholders.

Labels, the pages array, the meta block, and section anchor ids never affect validity — a v2-shaped site is valid v3. They affect how much of the site the user can edit without a developer, which is what the levels measure.

Validation checklist

Before completing your output, verify:

  • Every visible heading has data-sl-text
  • Every <img> has data-sl-image and an alt attribute
  • Every CTA "button" that navigates is an <a> with data-sl-link + data-sl-text, not a <button>
  • All non-shared region IDs are unique across the whole project (color and bg-image tokens may be shared)
  • All region IDs are kebab-case and semantically named; page-specific regions on multi-page sites are prefixed with the page slug
  • Logical groupings are wrapped in data-sl-section, each with a matching id for anchors (SHOULD)
  • Every editable list is written as literal items — no .map() over data constants
  • Every video, map, or widget <iframe> (and native <video>/<audio>) carries data-sl-embed — raw-HTML blocks (data-sl-html) only for multi-element snippets
  • No annotations on layout-only elements
  • No nested annotations (e.g., data-sl-text inside data-sl-html)
  • /slingshot.json manifest is generated (Silver+) with all applicable region types, and (Gold) pages, meta, and labels

Migrating from v2

For single-page sites — the overwhelming majority of v2 output — there is nothing to migrate. Every v2 attribute, rule, and manifest shape carries forward with identical semantics.

The one case that surfaces anything: a v2 multi-page site that reused a region ID on two different pages will see a warning (SL001, cross-page) — it still connects and edits normally. Fix by prefixing page-specific IDs with the page slug.

If you want to adopt the v3 additions on an existing site (all optional):

  1. Add id="<section-name>" to your data-sl-section wrappers (enables anchor enumeration).
  2. Bump your manifest to "version": "3" and add pages, meta, and label fields.
  3. If you have cross-page region ID collisions (only possible on multi-page sites), rename with page-slug prefixes.

That's the whole list.

Non-goals note (what this spec will never add)

The convention marks content inside labeled regions. Three recurring requests are permanently out, per the Slingshot non-goals doc:

  • No style/layout attributes. No data-sl-style, no tag-swapping (h1↔h2), no corner rounding, no spacing controls. Layout changes go back to the user's AI (non-goal #3 — Slingshot is not a layout engine).
  • No dynamic-data/CMS schema. No collections, no content models, no scheduled posts. The repo is the CMS (non-goal #7). Runtime-fetched content is not editable content.
  • No auto-instrument tooling in the spec. The spec hands prompts to the user's AI (Appendix A). Slingshot never parses, instruments, or migrates non-conformant code itself (non-goal #2).

If a future revision seems to need one of these, the revision is wrong.

Example: minimal Bronze landing page

<!DOCTYPE html>
<html>
<head>
  <!-- Title, description, OG tags, and favicon are edited via the manifest
       `meta` block (see Head & meta regions) — never data-sl-* attributes. -->
  <title>Acme Co.</title>
</head>
<body>
  <section data-sl-section="hero" id="hero">
    <h1 data-sl-text="hero-headline">The future of widgets.</h1>
    <p data-sl-text="hero-subtitle">Faster. Stronger. Better.</p>
    <a data-sl-link="hero-cta-link" data-sl-text="hero-cta-label" href="/signup">
      Get started
    </a>
  </section>
</body>
</html>

Example: full Gold React component

export default function Hero() {
  return (
    <section data-sl-section="hero" id="hero">
      <h1 data-sl-text="hero-headline" className="text-5xl font-bold">
        The future of widgets.
      </h1>
      <p data-sl-text="hero-subtitle" className="text-xl mt-4">
        Faster. Stronger. Better.
      </p>
      <img
        data-sl-image="hero-illustration"
        src="/hero.svg"
        alt="Widget illustration"
        className="my-8"
      />
      <a
        data-sl-link="hero-cta-link"
        data-sl-text="hero-cta-label"
        href="/signup"
        data-sl-color="brand-primary"
        style={{ background: "var(--brand-primary)" }}
        className="px-6 py-3 rounded-lg text-white"
      >
        Get started
      </a>

      <ul data-sl-list="hero-features" className="mt-12 grid grid-cols-3 gap-4">
        <li data-sl-list-item>
          <h3 data-sl-text="feature-1-title">Lightning fast</h3>
          <p data-sl-text="feature-1-body">Sub-second response times.</p>
        </li>
        <li data-sl-list-item>
          <h3 data-sl-text="feature-2-title">Always reliable</h3>
          <p data-sl-text="feature-2-body">99.99% uptime guaranteed.</p>
        </li>
        <li data-sl-list-item>
          <h3 data-sl-text="feature-3-title">Built for scale</h3>
          <p data-sl-text="feature-3-body">From startup to enterprise.</p>
        </li>
      </ul>
    </section>
  );
}

For the corresponding manifest — including pages, meta, labels, and a shared color token — see the full example in The manifest file above.

Appendix A: the canonical instrument-in-place prompt

For sites that already exist (most users arrive after generating). The user copies this into their own AI; their AI does the work. Slingshot never touches the code.

I have an existing website codebase. Add Slingshot Convention annotations (spec: slingshot.build/spec) to it without changing anything else. Rules:

  1. Change nothing visible. Do not redesign, restyle, rewrite copy, reorder elements, rename files, reformat code, or "improve" anything. Your only output is added attributes and one new file.
  2. Add data-sl-text="kebab-id" to every visible heading, paragraph, button label, link text, list item, label, blockquote, and table cell a non-developer might want to edit.
  3. Add data-sl-image="kebab-id" to every <img>; add an alt if missing (describe the image; that's the only new visible-adjacent content allowed). Use data-sl-bg-image for CSS background images.
  4. Add data-sl-link="kebab-id" to links whose URL should be editable. Where a <button> performs navigation, convert it to an <a> with the same classes (visual result identical).
  5. Add data-sl-color="token-name" where colors come from CSS custom properties; the token name matches the property without --. 5b. Add data-sl-embed="kebab-id" to every video/map/widget <iframe> and native <video>/<audio> whose source URL a non-developer might swap. Use data-sl-html only for multi-element embed snippets.
  6. Wrap logical groupings in data-sl-section="name" with a matching id="name" — only if it requires no structural change; otherwise skip.
  7. Mark repeatable blocks with data-sl-list="id" on the container and valueless data-sl-list-item on each item. If items are generated by .map() over a hardcoded array, unroll them into literal markup with identical output.
  8. IDs: kebab-case, semantic (hero-headline, not text-1), unique across the whole project. On multi-page sites, prefix page-specific IDs with the page slug.
  9. Don't annotate layout containers, nav structure, or dynamic values (dates, counters, fetched data). Don't nest annotations. When unsure whether something is editable content, leave it alone.
  10. Finally, create /slingshot.json at the repo root listing every region (id, type, section, page, label, filePath), plus a pages array and a meta block (title, description, OG tags, favicon) per the spec.

When done, summarize what you annotated and confirm no visible output changed.

Appendix B: condensed prompt for inline use (proposed v3 replacement)

When users want to embed the convention inline in a single generation prompt:

When generating any website code, instrument editable content with these attributes: data-sl-text="kebab-id" on headings, paragraphs, links, and buttons; data-sl-image="kebab-id" on <img> tags (always include alt — it's editable too); data-sl-link="kebab-id" on links where the URL should be editable (anchors like #section and relative paths are valid); data-sl-color="token-name" on themed colors (shared IDs OK); data-sl-section="name" with a matching id="name" wrapping logical groupings (hero, features, footer); data-sl-embed="kebab-id" on video/map/widget iframes and native video (the src URL becomes editable). For CTAs that navigate, use <a> with data-sl-link styled as a button, not <button>. Write repeatable items (data-sl-list + data-sl-list-item) as literal markup — never .map() over a data array. Use semantic IDs unique across the whole project (prefix with page slug on multi-page sites); only color and bg-image tokens may be shared. Don't annotate layout containers. Don't nest annotations. Output a /slingshot.json manifest listing all regions (with human-readable labels), a pages array, and a meta block (title, description, OG tags, favicon). This lets the user edit the site visually later without re-prompting. Spec: slingshot.build/spec


Spec version: 3.0 (supersedes v2) Date: 2026-07-13 Canonical URL: https://slingshot.build/spec License: MIT — fork it, embed it, ship it.

Use in your AI tooling

Drop the spec into your AI tool of choice. Each format is ready to save as-is.

.slingshot/SKILL.md
---
name: slingshot-convention
description: Use this skill when generating any website, landing page, or web UI that the user intends to edit later through a visual editor. Trigger this skill whenever the user asks for a website, marketing site, landing page, portfolio, or web app frontend that may need post-generation text/image/color edits without re-prompting. Apply this skill to ALL HTML, JSX, TSX, Vue, Svelte, or Astro output.
---

# Slingshot Convention v3

## What changed from v2

**Backwards compatibility guarantee: every valid v2 site remains connectable and editable under v3, with no required changes.** All v3 additions are additive or SHOULD-level. One honest carve-out: a v2 **multi-page** site that reused the same region ID on two different pages (valid under v2's per-document rule) now surfaces that collision as a **warning (SL001, cross-page)** — never a connect-blocking error. Single-page sites — the overwhelming majority of v2 output — are untouched. See "Migrating from v2."

- **Multi-page & project-wide ID scoping (new).** Region ID uniqueness is now defined **per project**, not per document. The manifest gains a `pages` array; regions may declare which page they belong to. Page identification is defined per framework, including an honest treatment of the SPA/client-router case.
- **Head/meta regions (new).** Page title, meta description, OG tags, and favicon are editable via a manifest-level `meta` block — **not** DOM attributes. Project-level defaults with per-page overrides. This backs the sharing/favicon editor panel.
- **Anchor-target enumeration (new, SHOULD).** `data-sl-section` wrappers SHOULD carry an `id` matching the section name, so `#anchors` work on the published site and editors can enumerate link targets. Existing v2 sites without `id`s remain conformant.
- **List/`.map()` guidance (new, prohibited pattern).** Editable list items must be written as literal markup, never generated by mapping over a data constant. This is the top real-world conformance failure; it now has a rule, an example, and a fix.
- **Region labels (new, optional).** Any manifest region may carry `"label": "Hero headline"` for human-readable editor UI. Optional everywhere; absence never affects conformance.
- **`data-sl-embed` (new region type).** Videos (YouTube/Vimeo), maps, scheduling widgets, and native `<video>`/`<audio>` get a first-class URL-editable region — the user pastes a link instead of editing raw iframe HTML. `data-sl-html` remains the escape hatch for multi-element snippets only.
- **Clarification:** `alt` text is editable as part of the image region — editing an image includes editing its `alt`.
- **Clarification:** manifest `lineNumber` is explicitly **optional and advisory**. It drifts after commit-backs; editors must resolve regions by scanning `filePath` for the ID, never by trusting the line number.
- **Reserved (specced now, tooling lands later):** Inject markers (`<!-- sl-inject:head -->` / `<!-- /sl-inject:head -->`, plus `body-end`) and numbered conformance rule codes (SL001–SL014).
- **Appendices:** the canonical instrument-in-place prompt is now part of the spec (Appendix A); the condensed "copy as AI prompt" one-liner is updated (Appendix B).

## Purpose

When generating a website, your output will likely be edited later by a non-developer through a visual editor (Slingshot). To make that possible without forcing them back into your prompt loop, instrument every meaningfully editable piece of content with `data-sl-*` attributes. This is a one-time annotation pass during generation. It costs you nothing in output quality and saves the user dozens of re-prompts later.

## Core principle

**Annotate intent, not implementation.** If a human reading the page would think "I might want to change this someday," it gets a `data-sl-*` attribute. If it's structural or programmatic (a className, a layout div, a script tag), it doesn't.

## Pages and ID scoping (NEW in v3)

### The scoping rule

**Region IDs are unique across the whole project** — every file, every page — with the two deliberate exceptions that already existed in v2: `data-sl-color` and `data-sl-bg-image` token IDs are shared by design.

v2 said "unique within the document," which was written for single-page output. On a multi-page site, two pages each carrying `data-sl-text="hero-headline"` would collide in the project-wide editor. So:

- **Page-specific regions on multi-page sites:** prefix with the page slug — `about-hero-headline`, `contact-form-title`.
- **Shared components** (header, footer, nav rendered on every page): annotate once, in the component source. One region, appears everywhere — that's correct, not a duplicate.
- Single-page sites change nothing: document scope and project scope are the same thing.

### What a "page" is, per framework

| Framework | A page is | `path` | `source` |
|---|---|---|---|
| **Vanilla HTML** | each `.html` file | the file's URL path (`about.html` → `/about`) | the file |
| **Next.js (App Router)** | each `page.tsx`/`page.jsx` under `app/` | derived from the directory route | that file |
| **Next.js (Pages Router)** | each file under `pages/` | derived from the file route | that file |
| **Astro** | each file under `src/pages/` | derived from the file route | that file |
| **SvelteKit** | each `+page.svelte` | derived from the directory route | that file |
| **Nuxt** | each file under `pages/` | derived from the file route | that file |

**The SPA / client-router case, honestly:** a React/Vue app using a client-side router (React Router, Vue Router) has no build-time file↔URL mapping — routes live in code. The convention does not pretend otherwise, and the editor will never parse router code to discover pages (that's arbitrary-code parsing — non-goal #2). Instead:

- The **generating AI** SHOULD enumerate the routes it just wrote into the manifest `pages` array, with `source` pointing at each route's component file. It knows the routes because it wrote them.
- If routes are dynamic or the mapping is genuinely unclear, fall back to a **single page entry** (`"id": "app", "path": "/"`). This is fully valid — you lose per-page grouping in the editor sidebar, nothing else.
- The manifest is the source of truth for pages; DOM scanning is the editor's fallback, never router parsing.

### The `pages` array

```json
"pages": [
  {
    "id": "home",
    "path": "/",
    "source": "src/app/page.tsx",
    "title": "Home"
  },
  {
    "id": "about",
    "path": "/about",
    "source": "src/app/about/page.tsx",
    "title": "About"
  }
]
```

- `id`: kebab-case, unique within `pages`. Regions reference it via their optional `page` field.
- `path`: the URL path as served.
- `source`: the file that renders the page.
- `title`: optional human-readable label for editor UI (same rules as region labels — never an identifier).

Regions declare `"page": "about"` when they belong to one page. Regions in shared components (header, footer) and global tokens (colors) omit `page` or set it `null` — meaning "appears on all pages."

## Head & meta regions (NEW in v3)

Page titles, descriptions, OG tags, and favicons are content the half-stepper absolutely wants to edit — but head tags are awkward to attribute-annotate across frameworks (Next.js metadata exports, Astro frontmatter, plain `<head>`). So meta is a **manifest-level block, not DOM attributes.**

**Project-level default** (top level of `slingshot.json`), with **per-page overrides** (inside a page entry). Missing per-page fields inherit the project default.

```json
"meta": {
  "filePath": "src/app/layout.tsx",
  "title": "Acme Co.",
  "description": "The future of widgets.",
  "ogTitle": "Acme Co. — The future of widgets",
  "ogDescription": "Faster. Stronger. Better.",
  "ogImage": "/images/og.jpg",
  "favicon": "/favicon.ico"
}
```

Field rules (all fields optional strings):

- `filePath` — where these values are defined in source (root layout, `index.html` head, Astro frontmatter). The editor writes meta changes back to this file. If omitted, the editor scans conventional locations.
- `title`, `description` — the `<title>` and `<meta name="description">` values.
- `ogTitle`, `ogDescription` — fall back to `title` / `description` when absent. Don't emit them if they'd be identical.
- `ogImage`, `favicon` — **site-relative paths into the user's repo** (e.g. `/images/og.jpg`). Never an external Slingshot-hosted URL — images live in the user's repo or bucket, period (non-goal #14/#16).

A per-page override uses the same shape inside the page entry:

```json
{
  "id": "about",
  "path": "/about",
  "source": "src/app/about/page.tsx",
  "meta": {
    "title": "About — Acme Co.",
    "description": "Who we are and why widgets."
  }
}
```

This block is what the sharing/favicon editor panel builds against: the panel reads the merged (default + override) values, writes edits to the relevant `filePath`, and previews the OG card from `ogImage`.

## Required attributes

### `data-sl-text="<unique-id>"`
For any element whose **text content** should be editable.

Apply to: `<h1>`–`<h6>`, `<p>`, `<span>`, `<a>`, `<button>`, `<li>`, `<label>`, `<blockquote>`, table cells.

```html
<h1 data-sl-text="hero-headline">Welcome to our store</h1>
<p data-sl-text="hero-subtitle">The freshest produce in town.</p>
<button data-sl-text="cta-primary">Shop now</button>
```

**ID rules:** kebab-case, semantically meaningful (`hero-headline`, not `text-1`), **unique within the project** (see Pages and ID scoping). If the same text appears multiple times intentionally, use suffixes: `footer-tagline-1`, `footer-tagline-2`. On multi-page sites, prefix page-specific regions with the page slug: `about-hero-headline`.

### `data-sl-image="<unique-id>"`
For any `<img>` element whose source should be editable. ALWAYS include `alt`.

```html
<img
  data-sl-image="hero-bg"
  src="/images/hero.jpg"
  alt="Mountain sunrise"
/>
```

**`alt` is part of the region (clarified in v3):** editing an image region includes editing its `alt` text. Generate meaningful `alt` values — they're the editor's starting point, not decoration.

For images embedded as CSS backgrounds, use `data-sl-bg-image` instead (see Optional Attributes).

### `data-sl-link="<unique-id>"`
For `<a>` elements where the URL itself should be editable separately from the text. Anchor URLs (`#section-id`) and relative URLs (`/about`) are valid and supported.

```html
<a
  data-sl-link="header-cta-link"
  data-sl-text="header-cta-label"
  href="/signup"
>Get started</a>

<!-- Anchor URL is valid -->
<a
  data-sl-link="nav-features"
  data-sl-text="nav-features-label"
  href="#features"
>Features</a>
```

When `data-sl-link` and `data-sl-text` co-exist on the same element, the URL and the link text are independently editable. The parser emits two regions; the editor SHOULD display them as one composite item in its sidebar UX.

**Buttons-styled-as-links:** if a `<button>`-looking element has a URL destination, use an `<a>` with `data-sl-link` styled with button classes, not a `<button data-sl-text>`. The latter has no URL to edit.

```html
<!-- RIGHT: anchor styled as button, URL editable -->
<a
  data-sl-link="hero-cta-link"
  data-sl-text="hero-cta-label"
  href="/signup"
  class="rounded-lg bg-primary px-6 py-3 text-white"
>
  Get started
</a>

<!-- WRONG: button with no URL field -->
<button data-sl-text="hero-cta-label">Get started</button>
```

### `data-sl-color="<token-name>"`
For elements whose color (text, background, or border) is meant to be themed. Use this when the color comes from a CSS custom property the user might want to recolor globally.

**Token IDs ARE meant to be shared.** Multiple elements can carry the same `data-sl-color="brand-primary"` — across elements, files, and pages. Changing the token recolors every element bound to it — that's the entire point of theming. This is the only `data-sl-*` attribute where ID uniqueness does NOT apply (along with `data-sl-bg-image`).

```html
<button
  data-sl-color="brand-primary"
  style="background: var(--brand-primary);"
>
  Subscribe
</button>

<h1
  data-sl-color="brand-primary"
  style="color: var(--brand-primary);"
>
  Welcome
</h1>
```

The token name should match the CSS custom property name without the `--` prefix.

**Property semantics:** the editor updates whichever color property is currently set in the element's inline style (text `color`, `background`, or `border`). To override the inference, add `data-sl-color-property="text|background|border"`.

```html
<!-- Inference: parser sees `border: ...` in style, updates the border color -->
<div
  data-sl-color="brand-accent"
  style="border: 2px solid var(--brand-accent);"
>...</div>

<!-- Explicit override: forces background update even if other properties are set -->
<div
  data-sl-color="brand-accent"
  data-sl-color-property="background"
  style="background: var(--brand-accent); color: white;"
>...</div>
```

### `data-sl-section="<section-name>"`
Wrap logical groupings (hero, features, pricing, footer) so the editor can group regions in its UI. Section wrappers are **not editable for text/image regions** — they only organize.

**Anchor rule (NEW in v3, SHOULD):** section wrappers SHOULD also carry an `id` attribute matching the section name:

```html
<section data-sl-section="features" id="features">
```

Why: `#features` anchor links work on the published site with zero extra wiring, and the editor can enumerate every anchor target on the site when the user edits a `data-sl-link` — a dropdown of real destinations instead of a blank URL field. This is SHOULD, not MUST: existing v2 sites without `id`s remain fully conformant.

`data-sl-section` wrappers MAY also carry `data-sl-color` or `data-sl-bg-image` attributes when the entire section has themeable colors or backgrounds. Those are editable; the section wrapping itself is not.

```html
<section data-sl-section="hero" id="hero">
  <h1 data-sl-text="hero-headline">…</h1>
  <p data-sl-text="hero-subtitle">…</p>
</section>

<section
  data-sl-section="cta"
  id="cta"
  data-sl-bg-image="cta-bg"
  style="background-image: url('/images/cta-bg.jpg');"
>
  <h2 data-sl-text="cta-headline">…</h2>
</section>
```

## Optional attributes

### `data-sl-bg-image="<unique-id>"`
For elements with a CSS background image that should be editable. Same shared-token rule as `data-sl-color` — multiple elements can carry the same `data-sl-bg-image` ID and changing the source updates all bound elements.

```html
<div
  data-sl-bg-image="hero-bg"
  style="background-image: url('/images/hero.jpg');"
>
```

### `data-sl-image-focal="<x,y>"`
For `<img>` elements (or elements with `data-sl-bg-image`) where the editor's crop should be focal-point-based instead of CDN-transform-based. The value is two integers 0–100 representing the focal center as percentages (x horizontal, y vertical).

```html
<img
  data-sl-image="staff-photo"
  data-sl-image-focal="50,30"
  src="/images/team.jpg"
  alt="Our team"
  style="object-fit: cover; object-position: 50% 30%;"
/>
```

The editor's focal-point picker writes this attribute when the connected storage backend doesn't support CDN-side transforms (generic S3, paste-a-URL). For Supabase Storage and Cloudflare R2, the editor instead writes transform parameters to the URL itself (`?width=X&height=Y&resize=cover`) and `data-sl-image-focal` is unused.

**The AI is not expected to emit this attribute during initial generation.** It's added by the editor when the user crops via the focal-point UI.

### `data-sl-list="<unique-id>"` + `data-sl-list-item`
For repeatable content blocks (testimonials, feature cards, pricing tiers) where the user should be able to add/remove/reorder items.

```html
<ul data-sl-list="testimonials">
  <li data-sl-list-item>
    <p data-sl-text="testimonial-1-quote">"Amazing service."</p>
    <span data-sl-text="testimonial-1-author">— Jane D.</span>
  </li>
  <li data-sl-list-item>
    <p data-sl-text="testimonial-2-quote">"Changed my life."</p>
    <span data-sl-text="testimonial-2-author">— John S.</span>
  </li>
</ul>
```

The editor uses this to offer "Add testimonial" / "Remove" / drag-to-reorder UI.

**`data-sl-list-item` is a structural marker, not a region ID.** Don't add a value (it doesn't take one). The editor identifies individual items by their position within the list, not by an ID on the item itself. The editable regions *inside* each item still have their own IDs.

**Write list items as literal markup. Never generate them by mapping over a data constant.** (NEW in v3 — this is the single most common conformance failure in the wild. Prohibited going forward; validators surface it as **warning SL012** rather than a blocking error, so existing sites can still connect and fix it via the instrument-in-place prompt.)

The reflex — especially in React — is to hoist repeated content into an array and `.map()` over it:

```jsx
// ❌ WRONG — non-conformant. The content lives in a JS constant, not in markup.
const FEATURES = [
  { title: "Lightning fast", body: "Sub-second response times." },
  { title: "Always reliable", body: "99.99% uptime guaranteed." },
  { title: "Built for scale", body: "From startup to enterprise." },
];

<ul data-sl-list="features">
  {FEATURES.map((f) => (
    <li data-sl-list-item key={f.title}>
      <h3>{f.title}</h3>
      <p>{f.body}</p>
    </li>
  ))}
</ul>
```

This breaks in three unfixable ways: the items can't carry per-item region IDs (the JSX is one template, not three items); the editable text lives in a JavaScript array the editor will not parse (source text nodes are the contract — chasing data through code is arbitrary-code parsing, which Slingshot permanently refuses); and add/remove/reorder can't be expressed as a markup edit at all.

Write the items out:

```jsx
// ✅ RIGHT — literal items, each independently addressable.
<ul data-sl-list="features">
  <li data-sl-list-item>
    <h3 data-sl-text="feature-1-title">Lightning fast</h3>
    <p data-sl-text="feature-1-body">Sub-second response times.</p>
  </li>
  <li data-sl-list-item>
    <h3 data-sl-text="feature-2-title">Always reliable</h3>
    <p data-sl-text="feature-2-body">99.99% uptime guaranteed.</p>
  </li>
  <li data-sl-list-item>
    <h3 data-sl-text="feature-3-title">Built for scale</h3>
    <p data-sl-text="feature-3-body">From startup to enterprise.</p>
  </li>
</ul>
```

Yes, it's more lines. It's also the entire point: three literal items are three things a non-developer can edit, duplicate, and delete. One `.map()` is a thing only a developer can touch. If the list is genuinely dynamic (fetched from an API at runtime), it isn't editable content — leave it unannotated.

### `data-sl-embed="<unique-id>"` (NEW in v3)
For elements whose **embedded-content source URL** should be editable: video embeds (YouTube, Vimeo), maps (Google Maps), scheduling widgets (Calendly), native `<video>`/`<audio>` sources. Apply it to the `<iframe>`, `<video>`, or `<audio>` element itself. The editable value is the `src` URL — nothing else.

```html
<!-- YouTube video: the user pastes a new video link, done -->
<iframe
  data-sl-embed="testimonial-video"
  src="https://www.youtube.com/embed/abc123"
  title="Customer story"
  allowfullscreen
></iframe>

<!-- Google Maps: the user pastes a new share/embed link -->
<iframe
  data-sl-embed="location-map"
  src="https://www.google.com/maps/embed?pb=…"
  title="Our location"
></iframe>

<!-- Native video file -->
<video data-sl-embed="hero-video" src="/videos/intro.mp4" controls></video>
```

**ID rules:** unique like text/image regions (not shared). **Editor behavior:** editors present a URL field, never a code editor, and SHOULD recognize common providers and normalize pasted share links to their embeddable form (e.g. `youtube.com/watch?v=X` → `youtube.com/embed/X`, a Google Maps share link → its embed URL) — the user pastes the link they have, not the link the iframe needs. Editors SHOULD restrict values to `https:` URLs (or site-relative paths for native media).

Prefer `data-sl-embed` over `data-sl-html` whenever the thing being edited is really just the source URL.

### `data-sl-html="<unique-id>"`
For arbitrary HTML blocks the user might need to edit as raw markup — multi-element embed snippets (a form embed with its `<script>` tag), custom widgets, glyph-based font icons that don't fit other types.

```html
<div data-sl-html="newsletter-form">
  <!-- provider's multi-element embed snippet -->
  <form action="https://provider.example/subscribe" method="post">…</form>
  <script src="https://provider.example/embed.js"></script>
</div>
```

Use sparingly. `data-sl-html` regions are an "escape hatch" — the editor opens a code editor for them, which means the half-stepper user is dropped into raw HTML. Prefer specific attributes (`data-sl-embed` for iframe/video sources, `data-sl-image`, `data-sl-link`) when possible.

## Prohibited patterns

❌ **Don't annotate non-content elements:**
```html
<!-- WRONG: layout containers don't need annotations -->
<div data-sl-text="wrapper" class="flex items-center">…</div>

<!-- WRONG: navigation structure isn't editable copy -->
<nav data-sl-text="nav">…</nav>
```

❌ **Don't nest `data-sl-text` inside `data-sl-html`:**
```html
<!-- WRONG: ambiguous editing ownership -->
<div data-sl-html="hero">
  <h1 data-sl-text="headline">…</h1>
</div>
```
If a region needs raw HTML editing, it owns its entire content. Otherwise, decompose into specific attributes.

❌ **Don't use generic IDs:**
```html
<!-- WRONG -->
<p data-sl-text="text-1">…</p>
<p data-sl-text="text-2">…</p>

<!-- RIGHT -->
<p data-sl-text="hero-subtitle">…</p>
<p data-sl-text="features-intro">…</p>
```

❌ **Don't use `<button>` when the action is navigation:**
```html
<!-- WRONG: no URL to edit -->
<button data-sl-text="hero-cta-label">Get started</button>

<!-- RIGHT: URL and label both editable -->
<a data-sl-link="hero-cta-link" data-sl-text="hero-cta-label" href="/signup"
   class="rounded-lg bg-primary px-6 py-3 text-white">Get started</a>
```

❌ **Don't annotate dynamic content:**
```html
<!-- WRONG: this is a date, not editable copy -->
<span data-sl-text="copyright-year">{currentYear}</span>
```

❌ **Don't generate editable list items with `.map()` over a data constant** (NEW in v3 — see `data-sl-list` above for the full example and the fix). Editable content lives in markup, not in JavaScript arrays.

❌ **Don't reuse a region ID across pages** (NEW in v3): `hero-headline` on both `/` and `/about` is a project-scope collision. Prefix page-specific regions with the page slug (`about-hero-headline`). Color and bg-image tokens remain the deliberate exception — shared across pages by design. (Validators surface a cross-page collision as warning SL001 — existing v2-era sites still connect.)

## The manifest file

After generating the site, output a `/slingshot.json` file at the repo root that catalogs all editable regions, pages, and meta.

**v3 manifest additions:** `pages` array, `meta` block (project-level + per-page), optional `label` on regions, optional `page` on regions. Manifests declaring `"version": "2"` remain fully valid — the v3 fields simply require `"version": "3"`.

```json
{
  "version": "3",
  "generatedBy": "claude-fable-5",
  "generatedAt": "2026-07-13T19:00:00Z",
  "framework": "react",
  "meta": {
    "filePath": "src/app/layout.tsx",
    "title": "Acme Co.",
    "description": "The future of widgets.",
    "ogImage": "/images/og.jpg",
    "favicon": "/favicon.ico"
  },
  "pages": [
    {
      "id": "home",
      "path": "/",
      "source": "src/app/page.tsx",
      "title": "Home"
    },
    {
      "id": "about",
      "path": "/about",
      "source": "src/app/about/page.tsx",
      "title": "About",
      "meta": {
        "title": "About — Acme Co.",
        "description": "Who we are and why widgets."
      }
    }
  ],
  "regions": [
    {
      "id": "hero-headline",
      "type": "text",
      "section": "hero",
      "page": "home",
      "label": "Hero headline",
      "filePath": "src/components/Hero.tsx",
      "lineNumber": 12
    },
    {
      "id": "hero-bg",
      "type": "image",
      "section": "hero",
      "page": "home",
      "label": "Hero background photo",
      "filePath": "src/components/Hero.tsx",
      "lineNumber": 18
    },
    {
      "id": "hero-cta-link",
      "type": "link",
      "section": "hero",
      "page": "home",
      "label": "Hero button URL",
      "filePath": "src/components/Hero.tsx",
      "lineNumber": 24
    },
    {
      "id": "brand-primary",
      "type": "color",
      "section": null,
      "page": null,
      "label": "Brand primary color",
      "filePath": "src/index.css",
      "lineNumber": 3
    },
    {
      "id": "cta-bg",
      "type": "bg-image",
      "section": "cta",
      "page": "home",
      "label": "CTA background",
      "filePath": "src/components/Cta.tsx",
      "lineNumber": 8
    },
    {
      "id": "demo-video",
      "type": "embed",
      "section": "demo",
      "page": "home",
      "label": "Demo video",
      "filePath": "src/components/Demo.tsx",
      "lineNumber": 22
    },
    {
      "id": "newsletter-form",
      "type": "html",
      "section": "footer",
      "page": null,
      "label": "Newsletter signup embed",
      "filePath": "src/components/Footer.tsx",
      "lineNumber": 31
    },
    {
      "id": "testimonials",
      "type": "list",
      "section": "social-proof",
      "page": "home",
      "label": "Testimonials",
      "filePath": "src/components/Testimonials.tsx",
      "lineNumber": 5
    },
    {
      "id": "testimonial-1-quote",
      "type": "text",
      "section": "social-proof",
      "page": "home",
      "label": "Testimonial 1 — quote",
      "filePath": "src/components/Testimonials.tsx",
      "lineNumber": 8
    },
    {
      "id": "testimonial-1-author",
      "type": "text",
      "section": "social-proof",
      "page": "home",
      "label": "Testimonial 1 — author",
      "filePath": "src/components/Testimonials.tsx",
      "lineNumber": 9
    },
    {
      "id": "testimonial-2-quote",
      "type": "text",
      "section": "social-proof",
      "page": "home",
      "label": "Testimonial 2 — quote",
      "filePath": "src/components/Testimonials.tsx",
      "lineNumber": 13
    },
    {
      "id": "testimonial-2-author",
      "type": "text",
      "section": "social-proof",
      "page": "home",
      "label": "Testimonial 2 — author",
      "filePath": "src/components/Testimonials.tsx",
      "lineNumber": 14
    },
    {
      "id": "about-hero-headline",
      "type": "text",
      "section": "hero",
      "page": "about",
      "label": "About page headline",
      "filePath": "src/app/about/page.tsx",
      "lineNumber": 9
    }
  ]
}
```

Note that:

- **`brand-primary` has `"page": null`** — it's a shared token used on both the home CTA and the About page header. One manifest entry, project-wide effect. Color regions point to the file containing the `<style>` block or CSS file where the custom property is defined, not to any one element using it.
- **Regions in shared components** (header, footer) also use `"page": null` — they appear on every page.
- **`label` is optional everywhere.** It's a display string for the editor sidebar and Punch List output. It is never an identifier, never required, and its absence never affects conformance. The generating AI should emit labels — they're free to write and make the editor dramatically friendlier.
- **`lineNumber` is optional and advisory** (clarified in v3). Every commit-back shifts line numbers; editors MUST resolve a region by scanning `filePath` for its ID and MUST NOT treat `lineNumber` as ground truth. Emit it if you have it; omit it without guilt.
- List-item child regions appear as flat top-level entries with their own IDs (e.g., the four `testimonial-*` entries above — every editable region inside every list item gets its own entry), not nested under the parent list.
- `section` is `null` for regions outside any `data-sl-section` wrapper (typical for global color tokens).

The manifest is optional but recommended — it speeds up the editor's source-map step. Without it, the editor falls back to scanning all repo files.

## Reserved: Inject markers (tooling lands later)

> **Reserved namespace — specced in v3, editor tooling ships later.** Generators MAY emit these today; nothing breaks if they don't.

Slingshot's Inject panel writes the *user's own* third-party snippets (GA4, Plausible, Meta Pixel, Mailchimp, Calendly) into the *user's own repo*. It needs a deterministic write target. The convention reserves paired HTML comment markers:

```html
<head>
  …
  <!-- sl-inject:head -->
  <!-- /sl-inject:head -->
</head>
<body>
  …
  <!-- sl-inject:body-end -->
  <!-- /sl-inject:body-end -->
</body>
```

- Two slots: `head` (analytics, pixels, meta-level scripts — placed just before `</head>`) and `body-end` (widgets, chat bubbles — placed just before `</body>`).
- Markers are **empty at generation time**. The user pastes snippets through the Inject panel; Slingshot writes them between the markers, visibly, into the user's repo. Nothing runs on Slingshot infrastructure, ever.
- The whole `sl-inject:*` comment namespace is reserved for future slots.
- Emitting the markers is OPTIONAL. Sites without them stay conformant; the Inject panel will fall back to inserting markers itself at the conventional positions when the feature ships.

## Reserved: Conformance rule codes (tooling lands later)

> **Reserved — specced in v3 so the CLI, the validation-failed flow, and a future GitHub Action share one definition.** Codes are stable once assigned; new checks get new codes.

| Code | Severity | Check |
|---|---|---|
| **SL001** | error (same page/file) · warning (across pages) | Duplicate region ID (non-shared types) within the project. Cross-page duplicates are a warning, not an error, so v2-era multi-page sites still connect and fix at leisure. |
| **SL002** | error | `<img>` with `data-sl-image` missing `alt` |
| **SL003** | warning | Visible `<img>` without `data-sl-image` |
| **SL004** | warning | Generic, non-semantic region ID (`text-1`, `image-2`) |
| **SL005** | error | Region ID not kebab-case |
| **SL006** | error | Nested annotation (e.g. `data-sl-text` inside `data-sl-html`) |
| **SL007** | warning | Annotation on a layout-only element |
| **SL008** | warning | Navigation `<button>` carrying `data-sl-text` (should be `<a data-sl-link>`) |
| **SL009** | error | `data-sl-list-item` carrying a value |
| **SL010** | warning | Manifest out of sync (region entry points at a file/ID that doesn't match source) |
| **SL011** | warning | Visible heading without `data-sl-text` |
| **SL012** | warning | Editable list rendered by `.map()` over a data constant |
| **SL013** | warning | `data-sl-color` token with no matching CSS custom property |
| **SL014** | warning | Visible `<iframe>`/`<video>` without `data-sl-embed` (or an enclosing `data-sl-html`) |

Errors block the "connect repo" validation; warnings surface in the validation report with the instrument-in-place prompt (Appendix A) as the fix path. Severity assignments are draft and may be tuned when the validator adopts the codes.

## Framework-specific notes

The convention is **framework-agnostic**. The same `data-sl-*` attributes work across:

- **Vanilla HTML:** attributes appear directly in `.html` files
- **React/JSX/TSX:** attributes are JSX attributes; React passes them through to the DOM
- **Vue:** use `:data-sl-text="..."` for dynamic IDs, plain `data-sl-text="..."` for static
- **Svelte:** plain attribute syntax works
- **Astro:** plain attribute syntax in `.astro` files
- **Next.js / Nuxt / SvelteKit / Remix:** all framework-built routes inherit attributes

If your output is **server-rendered with dynamic content**, annotate the static template, not the rendered output. The editor can find the source.

For what counts as a "page" in each framework, see **Pages and ID scoping** above.

## Conformance levels

When generating, target these coverage levels:

| Level | Description |
|-------|-------------|
| **Bronze** | All `<h1>`, `<h2>`, hero text, and primary CTAs annotated. Minimum viable. |
| **Silver** | All visible text, all `<img>` tags, all CTAs and links. Recommended default. |
| **Gold** | Silver + all repeatable lists annotated, all branded colors as tokens, all sections wrapped (with anchor `id`s), manifest with `pages` + `meta` + labels. The half-stepper can edit the entire site without a developer. |

**Default to Silver.** Aim for Gold when the user's prompt mentions handing the site off, ongoing edits, or non-technical stakeholders.

Labels, the `pages` array, the `meta` block, and section anchor `id`s never affect *validity* — a v2-shaped site is valid v3. They affect how much of the site the user can edit without a developer, which is what the levels measure.

## Validation checklist

Before completing your output, verify:

- [ ] Every visible heading has `data-sl-text`
- [ ] Every `<img>` has `data-sl-image` and an `alt` attribute
- [ ] Every CTA "button" that navigates is an `<a>` with `data-sl-link` + `data-sl-text`, not a `<button>`
- [ ] All non-shared region IDs are unique **across the whole project** (color and bg-image tokens may be shared)
- [ ] All region IDs are kebab-case and semantically named; page-specific regions on multi-page sites are prefixed with the page slug
- [ ] Logical groupings are wrapped in `data-sl-section`, each with a matching `id` for anchors (SHOULD)
- [ ] Every editable list is written as literal items — no `.map()` over data constants
- [ ] Every video, map, or widget `<iframe>` (and native `<video>`/`<audio>`) carries `data-sl-embed` — raw-HTML blocks (`data-sl-html`) only for multi-element snippets
- [ ] No annotations on layout-only elements
- [ ] No nested annotations (e.g., `data-sl-text` inside `data-sl-html`)
- [ ] `/slingshot.json` manifest is generated (Silver+) with all applicable region types, and (Gold) `pages`, `meta`, and labels

## Migrating from v2

**For single-page sites — the overwhelming majority of v2 output — there is nothing to migrate.** Every v2 attribute, rule, and manifest shape carries forward with identical semantics.

**The one case that surfaces anything:** a v2 multi-page site that reused a region ID on two different pages will see a **warning** (SL001, cross-page) — it still connects and edits normally. Fix by prefixing page-specific IDs with the page slug.

If you want to *adopt* the v3 additions on an existing site (all optional):

1. Add `id="<section-name>"` to your `data-sl-section` wrappers (enables anchor enumeration).
2. Bump your manifest to `"version": "3"` and add `pages`, `meta`, and `label` fields.
3. If you have cross-page region ID collisions (only possible on multi-page sites), rename with page-slug prefixes.

That's the whole list.

## Non-goals note (what this spec will never add)

The convention marks content **inside** labeled regions. Three recurring requests are permanently out, per the Slingshot non-goals doc:

- **No style/layout attributes.** No `data-sl-style`, no tag-swapping (h1↔h2), no corner rounding, no spacing controls. Layout changes go back to the user's AI (non-goal #3 — Slingshot is not a layout engine).
- **No dynamic-data/CMS schema.** No collections, no content models, no scheduled posts. The repo is the CMS (non-goal #7). Runtime-fetched content is not editable content.
- **No auto-instrument tooling in the spec.** The spec hands prompts to the *user's* AI (Appendix A). Slingshot never parses, instruments, or migrates non-conformant code itself (non-goal #2).

If a future revision seems to need one of these, the revision is wrong.

## Example: minimal Bronze landing page

```html
<!DOCTYPE html>
<html>
<head>
  <!-- Title, description, OG tags, and favicon are edited via the manifest
       `meta` block (see Head & meta regions) — never data-sl-* attributes. -->
  <title>Acme Co.</title>
</head>
<body>
  <section data-sl-section="hero" id="hero">
    <h1 data-sl-text="hero-headline">The future of widgets.</h1>
    <p data-sl-text="hero-subtitle">Faster. Stronger. Better.</p>
    <a data-sl-link="hero-cta-link" data-sl-text="hero-cta-label" href="/signup">
      Get started
    </a>
  </section>
</body>
</html>
```

## Example: full Gold React component

```jsx
export default function Hero() {
  return (
    <section data-sl-section="hero" id="hero">
      <h1 data-sl-text="hero-headline" className="text-5xl font-bold">
        The future of widgets.
      </h1>
      <p data-sl-text="hero-subtitle" className="text-xl mt-4">
        Faster. Stronger. Better.
      </p>
      <img
        data-sl-image="hero-illustration"
        src="/hero.svg"
        alt="Widget illustration"
        className="my-8"
      />
      <a
        data-sl-link="hero-cta-link"
        data-sl-text="hero-cta-label"
        href="/signup"
        data-sl-color="brand-primary"
        style={{ background: "var(--brand-primary)" }}
        className="px-6 py-3 rounded-lg text-white"
      >
        Get started
      </a>

      <ul data-sl-list="hero-features" className="mt-12 grid grid-cols-3 gap-4">
        <li data-sl-list-item>
          <h3 data-sl-text="feature-1-title">Lightning fast</h3>
          <p data-sl-text="feature-1-body">Sub-second response times.</p>
        </li>
        <li data-sl-list-item>
          <h3 data-sl-text="feature-2-title">Always reliable</h3>
          <p data-sl-text="feature-2-body">99.99% uptime guaranteed.</p>
        </li>
        <li data-sl-list-item>
          <h3 data-sl-text="feature-3-title">Built for scale</h3>
          <p data-sl-text="feature-3-body">From startup to enterprise.</p>
        </li>
      </ul>
    </section>
  );
}
```

For the corresponding manifest — including `pages`, `meta`, labels, and a shared color token — see the full example in **The manifest file** above.

## Appendix A: the canonical instrument-in-place prompt

For sites that already exist (most users arrive *after* generating). The user copies this into their own AI; their AI does the work. Slingshot never touches the code.

> I have an existing website codebase. Add Slingshot Convention annotations (spec: slingshot.build/spec) to it **without changing anything else**. Rules:
>
> 1. **Change nothing visible.** Do not redesign, restyle, rewrite copy, reorder elements, rename files, reformat code, or "improve" anything. Your only output is added attributes and one new file.
> 2. Add `data-sl-text="kebab-id"` to every visible heading, paragraph, button label, link text, list item, label, blockquote, and table cell a non-developer might want to edit.
> 3. Add `data-sl-image="kebab-id"` to every `<img>`; add an `alt` if missing (describe the image; that's the only new visible-adjacent content allowed). Use `data-sl-bg-image` for CSS background images.
> 4. Add `data-sl-link="kebab-id"` to links whose URL should be editable. Where a `<button>` performs navigation, convert it to an `<a>` with the same classes (visual result identical).
> 5. Add `data-sl-color="token-name"` where colors come from CSS custom properties; the token name matches the property without `--`.
> 5b. Add `data-sl-embed="kebab-id"` to every video/map/widget `<iframe>` and native `<video>`/`<audio>` whose source URL a non-developer might swap. Use `data-sl-html` only for multi-element embed snippets.
> 6. Wrap logical groupings in `data-sl-section="name"` with a matching `id="name"` — only if it requires no structural change; otherwise skip.
> 7. Mark repeatable blocks with `data-sl-list="id"` on the container and valueless `data-sl-list-item` on each item. If items are generated by `.map()` over a hardcoded array, unroll them into literal markup with identical output.
> 8. IDs: kebab-case, semantic (`hero-headline`, not `text-1`), unique across the whole project. On multi-page sites, prefix page-specific IDs with the page slug.
> 9. Don't annotate layout containers, nav structure, or dynamic values (dates, counters, fetched data). Don't nest annotations. When unsure whether something is editable content, leave it alone.
> 10. Finally, create `/slingshot.json` at the repo root listing every region (id, type, section, page, label, filePath), plus a `pages` array and a `meta` block (title, description, OG tags, favicon) per the spec.
>
> When done, summarize what you annotated and confirm no visible output changed.

## Appendix B: condensed prompt for inline use (proposed v3 replacement)

When users want to embed the convention inline in a single generation prompt:

> When generating any website code, instrument editable content with these attributes: `data-sl-text="kebab-id"` on headings, paragraphs, links, and buttons; `data-sl-image="kebab-id"` on `<img>` tags (always include alt — it's editable too); `data-sl-link="kebab-id"` on links where the URL should be editable (anchors like `#section` and relative paths are valid); `data-sl-color="token-name"` on themed colors (shared IDs OK); `data-sl-section="name"` with a matching `id="name"` wrapping logical groupings (hero, features, footer); `data-sl-embed="kebab-id"` on video/map/widget iframes and native video (the src URL becomes editable). For CTAs that navigate, use `<a>` with `data-sl-link` styled as a button, not `<button>`. Write repeatable items (`data-sl-list` + `data-sl-list-item`) as literal markup — never `.map()` over a data array. Use semantic IDs unique across the whole project (prefix with page slug on multi-page sites); only color and bg-image tokens may be shared. Don't annotate layout containers. Don't nest annotations. Output a `/slingshot.json` manifest listing all regions (with human-readable labels), a `pages` array, and a `meta` block (title, description, OG tags, favicon). This lets the user edit the site visually later without re-prompting. Spec: slingshot.build/spec
---

**Spec version:** 3.0 (supersedes v2)
**Date:** 2026-07-13
**Canonical URL:** https://slingshot.build/spec
**License:** MIT — fork it, embed it, ship it.

Validate your code

Paste any HTML to check it against the convention. Runs the same validator the Slingshot editor uses.

Slingshot Convention v3 · MIT License · Questions? github.com/d-sunn/slingshot