# Artstr Studio External Embed Viewer ## Read-only iframe-able render of any public Artstr object ### Status **Shipped** (2026-06-01). Phases 1–6 are live on `main`. Phase 7 (hardening) shipped alongside Phase 1. Phase 9 (oEmbed, render-to-image, lightweight bundle) is unscoped future work. The previously-drafted Phase 8 (Credits overlay, postMessage handshake, explicit themes) was deliberately dropped — not needed for the use cases the feature is solving. --- ## 1. Feature Summary Add an embeddable, read-only Artstr viewer that lets external websites display any public Artstr object — design, slide, deck, pixel art, custom art, disc, disc-design, jewel insert — with a single iframe snippet: ```html ``` The viewer renders by: 1. Resolving the Nostr identifier to the latest (or pinned) event. 2. Parsing the Artstr payload. 3. Resolving any linked sub-designs in the payload via the existing `linkedDesigns` resolver (disk cache + batched prefetch reused). 4. Painting through the existing share/preview renderer with the editor chrome hidden. The embedding site only needs to swap the Nostr identifier to display a different Artstr object. --- ## 2. Product Goal Embedding public Artstr work on any site should feel as ordinary as embedding a YouTube video. Pastable into: - static HTML / Markdown / MDX, - Astro / Hugo / Jekyll, - GitHub Pages, Netlify, Vercel, - WordPress custom-HTML blocks, - Substack, Ghost, Notion (embed blocks), - Nostr long-form clients that allow iframes (Habla, YakiHonne). It also gives Artstr authors a distribution primitive that doesn't require viewers to leave their host site. --- ## 3. Core User Stories ### 3.1 Site owner embeds a public Artstr design Paste an iframe into a static blog post; visitors see the design without leaving the page. ### 3.2 Artist embeds their latest version (`naddr`) Use the addressable-event coord so updates to the source propagate to every page that embeds it. No re-embedding needed. ### 3.3 Archivist embeds an exact version (`nevent` / `note` / hex) Pin to a specific event id so the embed never changes. ### 3.4 Presenter embeds a deck Embed a deck with minimal controls so visitors can advance slides inline. ### 3.5 Collaborator embeds a host with linked sub-designs Embed a host design that pulls multiple linked layers from other authors. The embed renders the live state and surfaces the contributor chain. ### 3.6 Buyer-curious viewer encounters a premium embed Embed of a premium design shows the watermarked preview + "Open in Artstr to unlock" CTA — never asks the embedding site's visitor to authenticate or pay inside the iframe. ### 3.7 Reader follows back to Artstr A small "Open in Artstr" link in the embed lets viewers jump to the full app to fork, zap, comment, or inspect. --- ## 4. Non-Goals for MVP - ``-style rendering (requires server-side image bytes). - Server-rendered PNG / SVG endpoints. - oEmbed JSON endpoint. - Private (visibility-encrypted) event display. - In-iframe premium unlock (zap flow stays in the main app). - Edit / publish / fork from inside the embed. - Login UI / NIP-07 prompts inside the embed. - Comments, reactions, zaps inside the embed. - Designer-mode chrome (sidebars, layer list, toolbars). - Arbitrary scripts from event content (always sanitized). - Perfect dynamic auto-resize across every CMS (we expose a postMessage handshake; consumers opt in). Each is a viable Phase 2+ follow-up. --- ## 5. Identifier Strategy ### 5.1 Supported identifier types | Identifier | Meaning | Embed behavior | | -------------- | ----------------------------------------- | ---------------------------------- | | `naddr1…` | Parameterized replaceable event address | Latest version (auto-updates) | | `nevent1…` | Specific event pointer with relay hints | Exact event | | `note1…` | Specific event id (no relay hints) | Exact event | | 64-char hex | Specific event id | Exact event | ### 5.2 Product guidance copy In the embed-code generator: > Use the **latest-version embed** if you want updates to appear > automatically (`naddr`). Use the **snapshot embed** if you want > this exact version to stay unchanged (`nevent` / `note`). ### 5.3 Identifier parsing Reuse the existing `parseNostrIdentifier` family (used by `/share/` routes and the linked-designs resolver). No new parsing logic should be invented for embeds. --- ## 6. URL Design ### 6.1 Canonical route ``` /embed/ ``` Examples: ``` https://artstr.org/embed/naddr1... https://artstr.org/embed/nevent1... https://artstr.org/embed/note1... https://artstr.org/embed/0123abcd... ``` ### 6.2 Static-host fallback route Since Artstr is a static SPA, also support a query-based form so hosts without SPA rewrites still work: ``` /?embed=naddr1... /embed.html?id=naddr1... ``` The embed-code generator picks the canonical route by default and exposes the fallback under an "Advanced" disclosure. ### 6.3 Query parameters **Core (all modes):** | Param | Values | Default | Effect | | ---------- | ---------------------------- | ------------ | ------------------------------------------------------------ | | `controls` | `1` / `0` | `1` | Show/hide minimal control bar | | `theme` | `light` / `dark` / `auto` | `auto` | Background + control color scheme | | `bg` | `transparent` / `solid` | `transparent`| Iframe content background | | `fit` | `contain` / `cover` | `contain` | Scaling policy | | `open` | `1` / `0` | `1` | Show "Open in Artstr ↗" link | | `credits` | `1` / `0` | `0` | Show Credits overlay (contributors via linked layers) | | `attr` | `1` / `0` | `1` | Show "by @author" footer | | `frame` | `1` / `0` | `0` | Show outer Artstr branding chrome | **Deck-specific:** | Param | Values | Default | Effect | | ---------- | ------------- | ------- | ------------------------------------------------------- | | `slide` | `1`..`N` | `1` | Initial slide index (1-based) | | `autoplay` | `1` / `0` | `0` | Auto-advance slides on load | | `loop` | `1` / `0` | `0` | Restart at end of deck | | `interval` | ms (>= 1000) | `8000` | Autoplay interval | **Pixel-art-specific:** | Param | Values | Default | Effect | | ---------- | ------------- | ------- | ------------------------------------------------------- | | `play` | `1` / `0` | `1` | Auto-play frame animation (no-op if single-frame) | | `fps` | int >= 1 | from payload | Override frame rate | **Debug:** | Param | Values | Effect | | ---------- | ------------- | ----------------------------------------------- | | `debug` | `1` | Console-log resolver + render diagnostics | --- ## 7. Embed Code Snippets ### 7.1 Responsive 16:9 (default for slides + decks) ```html
``` ### 7.2 Cover / square (1:1 + auto-aspect) Cover, jewel, disc, and pixelart all have intrinsic aspect ratios. The generator queries the payload's natural aspect and emits `aspect-ratio` accordingly. ```html
``` ### 7.3 Chrome-free ```html ``` ### 7.4 Deck embed, deep-linked to slide 3 ```html ``` ### 7.5 Animated pixel art ```html ``` --- ## 8. Supported Artstr Object Types The embed viewer must support every public payload type Artstr publishes today. Each is rendered via the existing share/preview renderer. | Mode | Embed treatment | | ------------- | ----------------------------------------------------------------------- | | `cover` | Render case-cover artwork read-only at natural aspect | | `disc` | Render disc label sheet read-only | | `jewel` | Render jewel-case insert read-only | | `customart` | Render custom canvas read-only | | `slide` | Render single slide read-only at 16:9 (or slide's native aspect) | | `deck` | Render deck player read-only with slide controls | | `pixelart` | Render pixel art at chosen scale; auto-animate multi-frame designs | | `disc-design` | Render single disc design read-only | **MVP priority order** (matches user-impact): 1. `slide` 2. `deck` 3. `customart` 4. `cover` 5. `pixelart` 6. `disc` / `jewel` / `disc-design` ### 8.1 Layer types within any payload Whatever the top-level mode is, the payload's `layers` array can contain any combination of: text (with rich-text spans), shapes, images, QR codes, pixel-art layers (with animation), design layers (embedded or linked), grouped layers. The embed renderer inherits all of this from the existing layer rendering path; no new renderers per layer type. Specifically reused from the editor: - `renderShapeIntoElement` - `renderQrIntoElement` - `renderPixelArtIntoElement` (animation included) - `renderDesignObjectIntoElement` (embedded + linked branches) - text layers' `applyPreviewLayerTextStyle` + safe-href scrubbing - `safeTextLinkHref` for inline anchors --- ## 9. Linked Sub-Design Resolution Inside Embeds A host payload can contain linked layers (`layer.design.kind === 'linked'`) pointing at other Nostr events. The embed renderer reuses the existing `linkedDesigns` IIFE end-to-end: - `linkedDesigns.prefetchBatch(specs)` warms the cache with one batched relay round-trip across all linked refs in the host. - `linkedDesigns.resolveLinked(spec)` handles per-layer paints. - Cycle detection + depth-3 cap + per-event + per-tree byte caps all apply. - Disk cache (`artstr.linkedDesignCache.v1`) is shared with the main editor when an embed loads from the same origin. ### 9.1 Premium linked sub-designs When the host viewer is anonymous (always the case in an embed), the resolver's vault lookup misses for premium sub-designs. Those layers render the watermarked preview + a 🔒 overlay; clicking the overlay opens the linked source in artstr.org's main app for unlocking (via `Open in Artstr ↗`). ### 9.2 Private linked sub-designs The embed has no signing extension and isn't in any envelope's recipients list. Private sub-designs render the existing 🔒 "Private design / Not shared with this account" placeholder. ### 9.3 Pinned linked layers `design.pinnedEventId` is respected — the embed resolves and paints whatever the host pinned to. ### 9.4 Deck-slide pickers A linked layer with `design.slideIndex` projects the deck's chosen slide as a stand-alone slide payload before painting (reuses `_deckSlideToSlidePayload`). --- ## 10. Visibility Boundaries | Source visibility | Embed behavior | | ----------------- | ------------------------------------------------------------------------------ | | Public | Full render. | | Premium (zap-gated) | Watermarked preview JPEG from envelope + "🔒 Open in Artstr to unlock" CTA. | | Private (encrypted) | Refused with a clear "Private design" placeholder. No decryption attempts. | | Linked-premium-locked sub-layer | Watermarked preview at .45 opacity + 🔒 (same as in-app locked). | | Linked-private-locked sub-layer | Dark gray "🔒 Private design / Not shared" tile. | The embed never: - Prompts for NIP-07 sign-in. - Initiates a zap. - Decrypts private content. - Loads anything from `localStorage` it didn't write itself (vault keys live on the artstr.org origin, not on the embed consumer's origin — unless the embed is same-origin). --- ## 11. UX Spec ### 11.1 Layout ``` ┌────────────────────────────────────────┐ │ │ │ artwork surface │ ← scales to fit │ │ │ │ ├────────────────────────────────────────┤ │ ← 3 / 12 → by @author Open ↗ │ ← optional control bar └────────────────────────────────────────┘ ``` Surfaces hidden by default: - editor sidebars, toolbars, layer list, - publish UI, - community browser, - metadata panels, - raw JSON, - Designer mode. ### 11.2 Static-design embed (cover, slide, customart, pixelart, etc.) ``` [ artwork ] (optional bottom-right or footer bar) by @author Open in Artstr ↗ ``` If `controls=0` AND `attr=0` AND `open=0` → no chrome at all. ### 11.3 Deck embed ``` [ slide N ] ← 3 / 12 → ⛶ fullscreen by @author Open ↗ ``` Keyboard (when iframe is focused): | Key | Action | | ------------------------------- | --------------------- | | ArrowRight / Space / PageDown | Next slide | | ArrowLeft / PageUp | Previous slide | | Home | First slide | | End | Last slide | | F | Toggle fullscreen | | Esc | Exit fullscreen | Touch / pointer: - swipe left / right on slide surface advances, - tap on left third = previous, right third = next, center = controls toggle. ### 11.4 Pixel-art embed Multi-frame designs auto-play. A tiny play/pause toggle appears in the controls bar (or top-right corner overlay when `controls=0`). The play/pause respects `play=0` initial state. Frame rate honors `fps` param or payload's `fps`. ### 11.5 Credits overlay (opt-in via `credits=1`) Mirrors the in-app Credits panel (LINKED_DESIGNS_FEATURE.md §10.1). Renders as a thin bottom strip above the control bar: ``` 🔗 Credits @alice (3) · @bob (1) · @crrdlx (1) …more ``` Each name links to artstr.org/u/ in the parent window (`target="_top"`, `rel="noopener"`) so embed visitors can follow through. If no linked layers exist, the credits row hides entirely. ### 11.6 Locked-premium overlay ``` ┌────────────────────────────────────────┐ │ [watermarked preview] │ │ ╳ ╳ ╳ ╳ ╳ ╳ ╳ ╳ │ ← 45% opacity │ │ │ ┌──────────────────────┐ │ │ │ 🔒 Premium design │ │ │ │ Open in Artstr to │ │ │ │ unlock │ │ │ └──────────────────────┘ │ └────────────────────────────────────────┘ ``` The "Open in Artstr" link is the entire CTA — no zap flow in the iframe. ### 11.7 Loading state Centered, compact, theme-aware: ``` ⟳ Loading Artstr design… ``` Replaced as soon as the resolver returns (cache hit may make this invisible). ### 11.8 Error states | Failure | Message | | -------------------------------------- | ------------------------------------------------------------------------ | | Identifier malformed | "That's not a valid Artstr identifier." | | Event not found on any relay | "Could not find this design. The source may have been deleted or relays are unreachable." | | Event found but payload unparseable | "This event isn't a valid Artstr design." | | Event is private | "🔒 Private design — only sharers can view this." | | Event size > cap | "Source is too large to embed (cap: 1 MB)." | | Network timeout | "Couldn't reach Nostr relays. Open in Artstr ↗ to try again." | All errors include the `Open in Artstr ↗` link so the visitor has a fallback. Specific internal errors go to `console.warn` and appear in dev-tools when `?debug=1`. --- ## 12. Architecture ### 12.1 Route detection Detect embed mode before any editor bootstrapping: ```js function parseEmbedRoute() { const path = location.pathname || ''; const params = new URLSearchParams(location.search || ''); if (path.startsWith('/embed/')) { return { isEmbed: true, id: decodeURIComponent(path.slice('/embed/'.length)), params }; } if (path === '/embed.html' && params.get('id')) { return { isEmbed: true, id: params.get('id'), params }; } if (params.get('embed')) { return { isEmbed: true, id: params.get('embed'), params }; } return { isEmbed: false }; } ``` ### 12.2 Boot split ```js const route = parseEmbedRoute(); if (route.isEmbed) { document.body.classList.add('embed-mode'); bootEmbedViewer(route); } else { bootFullStudio(); } ``` If splitting bootstraps cleanly is too invasive for MVP, the embed path can short-circuit the editor init early via the `embed-mode` body class + CSS hiding all editor chrome. A follow-up phase splits the bundle for size. ### 12.3 Embed state ```js const embedState = { id: '', params: {}, status: 'idle', // idle | loading | ready | error error: '', event: null, payload: null, mode: '', pubkey: '', premium: null, // { state: 'locked', envelope, addressCoord } or null deckIndex: 0, warnings: [], }; ``` ### 12.4 Event resolution ```js async function resolveArtstrEmbed(id, opts = {}) { const pointer = parseNostrIdentifier(id); if (!pointer) throw new Error('Unsupported Artstr embed identifier.'); const hints = readRelayHints(pointer, opts.params); const relays = uniqueRelays([...hints, ...DEFAULT_NOSTR_RELAYS]); if (pointer.type === 'naddr') { return await queryLatestReplaceable(relays, pointer); } return await queryExactEvent(relays, pointer); } ``` Reuse the existing share-route resolvers when possible. The embed should NOT reinvent identifier parsing. ### 12.5 Linked sub-design prefetch After the host payload is parsed, but before render: ```js const linkedSpecs = collectLinkedSpecs(payload); if (linkedSpecs.length) { await linkedDesigns.prefetchBatch(linkedSpecs); } renderEmbedPayload(payload, container, opts); ``` `collectLinkedSpecs` walks `payload.layers` + every `payload.deck.slides[*].layers` for `kind === 'linked'` entries. The renderer then paints from a warm cache with no per-layer relay latency. ### 12.6 Rendering reuse ```js function renderEmbedPayload(payload, container, opts) { const mode = normalizeTemplateMode(payload); if (mode === 'deck') return renderEmbedDeck(payload, container, opts); // Everything else reuses the existing mode renderers. return renderDesignPayloadPreview(container, payload, { compact: true, transparentPixelArt: opts.bg !== 'solid', }); } ``` `renderEmbedDeck` adds the slide-player state machine but the slide content itself goes through `renderCustomArtPreviewDOM` (slides share the customart preview path). ### 12.7 Deck player ```js const deckRuntime = { payload: null, slides: [], index: 0, totalSlides: 0, autoplay: false, loop: false, intervalMs: 8000, timer: 0, }; function renderEmbedDeck(payload, container, opts) { /* ... */ } function goToEmbedSlide(i) { /* ... */ } function startDeckAutoplay() { /* ... */ } function stopDeckAutoplay() { /* ... */ } ``` Render strategy: - Render only the current slide (and pre-render the next at the edges of `requestIdleCallback` for snappy transitions). - Heavy decks (many large images) defer non-current slides. ### 12.8 PostMessage handshake (optional) When the host iframe loads, post `{ type: 'artstr.ready', mode, naturalAspect }` to `parent` so the embedding page can auto-resize. Accept: | Message in | Effect | | --------------------------------------- | --------------------- | | `{ type: 'artstr.nextSlide' }` | Deck: next slide | | `{ type: 'artstr.prevSlide' }` | Deck: prev slide | | `{ type: 'artstr.goToSlide', index }` | Deck: jump | | `{ type: 'artstr.play' }` | Pixel: play | | `{ type: 'artstr.pause' }` | Pixel / deck: pause | | `{ type: 'artstr.fullscreen' }` | Request fullscreen | | `{ type: 'artstr.refresh' }` | Re-resolve from relay | Out per state change: | Message out | When | | ------------------------------------------ | ----------------------------- | | `{ type: 'artstr.ready', ... }` | After first paint | | `{ type: 'artstr.slideChanged', index }` | Deck slide changes | | `{ type: 'artstr.error', code, message }` | Any error state | | `{ type: 'artstr.resize', w, h }` | Natural size reported | Origin guard: by default ignore messages from origins other than the parent's origin (sniff from `event.origin`). Embedding sites that want bidirectional control opt in by always posting to the iframe with the embed page's origin (`https://artstr.org`). --- ## 13. Embed Code Generator (in-app) ### 13.1 Entry points Add **Embed** action in: - the community preview pane (next to existing "Save JSON" / "Use/Fork"), - the share page, - post-publish toast actions, - the "My Designs" row context menu. ### 13.2 Modal layout ``` [ live preview iframe at chosen size ] Identifier: (•) Latest version (naddr1…ab12) ( ) Exact version (nevent1…xy93) Size: (•) Responsive 16:9 ( ) Cover / portrait ( ) Square ( ) Custom: [ w ] × [ h ] Options: [x] Show controls [x] Show "by @author" footer [x] Show "Open in Artstr" link [ ] Show Credits row [ ] Autoplay (deck only) [ ] Loop (deck only) Start at slide: [ 1 ] (deck only) [ Copy iframe code ] [ Copy embed URL ] [ Copy share URL ] ``` ### 13.3 Generator outputs - iframe HTML (responsive or fixed), - raw embed URL, - share URL (the existing `/share/` link), - (future) Markdown link, - (future) oEmbed-discovery URL. ### 13.4 Discoverability Surfaced in the preview pane as a small "<>" Embed button next to the existing share / save buttons. Adds an entry to the post-publish toast: "Published — copy embed code ↗". --- ## 14. Security ### 14.1 Public-only by construction The embed has no signing extension and no vault state. Every attempt to render a private envelope refuses with a clear placeholder; every premium event renders the public watermarked preview only. ### 14.2 Sanitization Linked sub-payloads come from arbitrary third parties — even when the top-level host is from a trusted author. The embed renderer reuses the existing sanitization paths: - `safeTextLinkHref` for inline anchors. - The shape allowlist (`migrateLegacyLayerTargets`). - Image URL pass-through (no script execution; `` only). - SVG layers go through the same sanitizer the editor uses before being injected. If any path turns out NOT to sanitize (audit during Phase 1), the embed feature hardens it. ### 14.3 Per-event + per-tree byte caps Reuses `LINKED_DESIGN_MAX_EVENT_BYTES` (1 MB) for the top-level embed event, and `LINKED_DESIGN_MAX_TREE_BYTES` (1 MB) for the total resolved sub-tree. Hitting the per-tree cap freezes further linked-layer paints to fallback placeholders + a console warning. ### 14.4 Frame-ancestors The embed route must allow framing from any origin: ``` Content-Security-Policy: frame-ancestors * ``` Other routes can keep stricter framing. No `X-Frame-Options: DENY|SAMEORIGIN` on `/embed/*`. Same applies to `/embed.html` and the fallback query path. ### 14.5 Relay constraints - Use `_vaultRelayUnion()` so the embed shares the linked-designs relay strategy. - Cap the number of relays per embed query (e.g., 8) to limit a malformed identifier from fanning out indefinitely. - Don't honor arbitrary `relay=` params unless the identifier itself carries hints (NIP-19). ### 14.6 No cross-origin localStorage leak The embed runs on `artstr.org` so it shares `localStorage` with the main app. That includes the disk cache, vault, NWC creds, etc. Embed code must NEVER: - Read or write the vault key. - Read or write the NWC key. - Interact with sign-in state. Reading the linked-designs disk cache is fine (it's public Nostr event content). --- ## 15. Performance ### 15.1 Cold load MVP loads the full Artstr bundle inside the iframe. Acceptable but unideal long-term. Mitigations: - Skip expensive editor init (community feed, theme processing, pixel editor, NWC connection) when `embed-mode` is set. - Render loading state immediately after `embed-mode` body class is applied — before any relay queries. - Lazy-load the deck player only when the resolved mode is `deck`. ### 15.2 Relay latency - Resolve host event in parallel with prefetchBatch for linked sub-designs once the host content arrives. - Disk cache reads zero-cost; hit rate is high on return visits. - Race default relays; first valid event wins. - For `naddr`: continue listening 500-800ms for newer replaceable events that arrive after the first valid one. ### 15.3 Heavy decks - Render current slide only by default. - Pre-render adjacent slides (index-1, index+1) at idle. - Lazy-load image layers (`loading="lazy"`). - Pixel-art frames pre-render once and cache the canvases (same as the in-app deck pixel-art animation path). ### 15.4 Bundle split (future) Phase 7: extract a smaller `embed.html` entry that loads only: - noble bundle (for signing-free relay reads — actually nothing needs signing in embeds, so noble can be dropped entirely from the embed bundle), - relay-read code, - `linkedDesigns` resolver, - preview renderers, - deck player, - a tiny embed-only CSS bundle. Expected size reduction: ~70% (skip editor + NWC + Premium encrypt code + community feed + import paths). --- ## 16. Accessibility - iframe `title` is always set (default: "Artstr design" / "Artstr slide deck" / "Artstr pixel art"). - Control bar buttons have accessible labels. - Deck controls support keyboard navigation with visible focus. - Loading / error states use readable text + don't rely on color alone. - Animated pixel art respects `prefers-reduced-motion`: pauses by default and shows a play button (`play=1` can override). - High-contrast outline on focus. --- ## 17. SEO & Social Preview ### 17.1 MVP iframe content does not appreciably help the embedding site's SEO. We accept this trade-off. ### 17.2 Open Graph for the embed URL itself When someone shares an `/embed/` URL on Nostr or Twitter, the embed route emits Open Graph tags: - `og:title` = event title tag or designObjectTitle(payload) - `og:description` = author name + "Artstr design / deck / …" - `og:image` = `/render/.png` (Phase 7 — currently the watermarked preview from a premium envelope can stand in if present) - `og:url` = canonical `/share/` ### 17.3 Future: oEmbed endpoint ``` /oembed?url=https://artstr.org/share/naddr1... ``` Returns: ```json { "version": "1.0", "type": "rich", "provider_name": "Artstr", "provider_url": "https://artstr.org", "title": "Design title", "author_name": "@alice", "html": "", "width": 800, "height": 450 } ``` Requires a serverless function (oEmbed consumers fetch JSON directly; an SPA-rendered HTML page won't satisfy them). ### 17.4 Future: image render endpoint `/render/.png`, `/render/.svg`, `/render/.jpg`. Probably backed by headless Chromium or a same-bundle SVG serializer. Lets `` users get a static fallback without iframes. --- ## 18. Implementation Plan ### Phase 0 — Discovery + reuse audit ✅ Shipped - Map existing `/share/` route resolver. - Map existing preview renderer entry points. - Confirm sanitization coverage on every layer type. - Identify the smallest possible skip-list for editor init. Deliverable: a one-pager listing reused functions and any that need extraction. ### Phase 1 — Static-design embed (vertical slice) ✅ Shipped Acceptance: - `/embed/` route detection. - `?embed=` fallback. - `embed-mode` body class hides editor chrome. - Resolves a public `naddr` to the latest event. - Renders `slide` and `customart` modes. - Loading + error states. - "Open in Artstr ↗" link works. ### Phase 2 — All static modes ✅ Shipped Acceptance: - `cover`, `disc`, `jewel`, `pixelart` (single-frame), `disc-design` all render. - Layout scales correctly per mode's natural aspect. - No scrollbars in 16:9 / square / portrait iframes. ### Phase 3 — Linked sub-designs ✅ Shipped Acceptance: - Host with linked layers paints them via `linkedDesigns.resolveLinked`. - `prefetchBatch` warms cache in one batched round-trip. - Cycle detection + depth-3 cap visible (test with crafted host). - Locked-premium sub-layer shows watermarked preview + 🔒. - Locked-private sub-layer shows the dark placeholder. ### Phase 4 — Deck player ✅ Shipped Acceptance: - Deck embed shows current slide + controls. - `?slide=N` deep-links to slide N. - Keyboard nav works when iframe focused. - Fullscreen via control button + `F` key. - `controls=0` hides controls; visitor can still keyboard-nav. - Autoplay + loop work; respect `prefers-reduced-motion`. ### Phase 5 — Animated pixel art ✅ Shipped Acceptance: - Multi-frame pixel-art auto-plays. - `play=0` starts paused; `fps` overrides payload FPS. - `prefers-reduced-motion` defaults to paused with a play CTA. ### Phase 6 — Embed code generator ✅ Shipped Acceptance: - Modal opens from community preview, share page, post-publish toast. - Identifier type selector (naddr / nevent) with explanation. - Size presets + custom. - Options checkboxes wired to query params. - Copy buttons for iframe HTML, embed URL, share URL. - Live preview of generated iframe inside the modal. ### Phase 7 — Hardening + headers ✅ Shipped (alongside Phase 1) Shipped: - `Content-Security-Policy: frame-ancestors *` on `/embed/:id` via `vercel.json` so the route is iframe-able from any origin. - SPA rewrites for `/embed/:id` (and the existing `/share/:id`, `/u/:npub`) in `vercel.json`. - Cross-origin iframe smoke-tested via the bundled `transparency-iframe.html` baseline + a stress-test host page. - Malformed-event paths show friendly errors (invalid identifier, missing-from-relays, oversized, unparseable, encrypted) with a ↻ Refresh chip on the retryable ones. - Sanitization reuses the existing `safeTextLinkHref` + shape / layer allowlists from the editor — no new attack surface beyond what the editor already covers. Optional follow-ups (open): - `?debug=1` surfacing extra resolver / renderer diagnostics. - Cap relay count per embed query (~8). The resolver already reuses `_vaultRelayUnion()` so it's bounded in practice by the user's relay list + `DEFAULT_NOSTR_RELAYS`. ### Phase 9 — Future (separate roadmap, unscoped) If we revisit this feature, the obvious next moves are: - **oEmbed endpoint** (serverless function). Lets some platforms auto-expand `artstr.org/share/` links into rich previews. - **Image render endpoint** (`/render/.png` / `.svg`). Headless-Chromium / SVG-serializer route so an `` consumer can use Artstr designs without an iframe. - **Pre-rendered Open Graph preview images** so shared embed URLs get a thumbnail on Twitter / Nostr / etc. - **Lightweight `embed.html` bundle**. The embed currently loads the full SPA inside the iframe — fine for MVP, ~250 KB heavier than necessary. Splitting an `embed.html` entry that loads only the relay-read + renderer code (no editor, no NWC, no community feed) would cut bundle size by ~70%. Out of scope (drafted earlier, deliberately dropped): - ~~Credits overlay (`?credits=1`)~~ — the in-app Credits panel already shipped; not needed inside iframe. - ~~PostMessage handshake~~ — embedding sites that need parent-side control can just use the URL params we already support and reload the iframe. - ~~Explicit `?theme=` light/dark/auto~~ — chips are blur-based and legible on any host theme; explicit theme would be more complexity than it's worth. --- ## 19. Risks - **Bundle size.** The embed loads the full SPA. Acceptable for MVP; bundle split planned for Phase 9. - **Sanitization gaps.** Linked sub-payloads from arbitrary authors widen the attack surface. Mitigation: audit during Phase 1, harden during Phase 7. - **Identifier confusion.** Authors paste an `nevent` expecting it to update; the in-app copy must clearly contrast latest vs. snapshot. - **Relay reliability.** A single down relay can stall the embed if hints point at it. Mitigation: race + timeout + fall back to defaults. - **Premium UX.** Locked-premium embed shows watermarked preview but no unlock path — viewers must follow the link out. Some authors may dislike this asymmetry; we explain in copy. --- ## 20. Acceptance Criteria for Embed v1 (MVP) Embeds are MVP-complete when ALL of these hold: - `/embed/` and `?embed=` both route correctly. - `naddr`, `nevent`, `note`, raw hex all resolve. - All eight Artstr modes render read-only. - Linked sub-designs render via `linkedDesigns.prefetchBatch` + per-layer `resolveLinked`. - Deck embeds support keyboard + on-screen prev/next + fullscreen. - Pixel-art embeds auto-animate multi-frame designs. - Locked premium sub-layers show the watermarked preview + "Open in Artstr to unlock" link. - Private events refuse with a clear placeholder; no decryption attempts. - Embed code generator can produce iframe HTML + URL + share URL. - The generated iframe works from an external static HTML page on a different origin. - Malformed / oversized / private payloads show friendly errors, not crashes. - Sanitization rejects script tags, dangerous href schemes, SVG handlers, oversized payloads. - `frame-ancestors *` is set on the embed route. --- ## 21. Out of Scope (explicitly) - ``-style PNG/SVG output. - oEmbed JSON endpoint. - Edit / publish / fork / zap / comment from the iframe. - Login UI. - Server-side rendering of any kind. - Custom themes beyond light/dark/auto. - Embed analytics (privacy-preserving counters can come later). These all belong to Phase 9+.