The embed feature is essentially complete — phases 0-6 are live
on main, Phase 7 hardening shipped alongside Phase 1. Updated
the spec to reflect that:
- Status block flipped from "Nothing in this spec is implemented
yet" to "Shipped (2026-06-01)" with date.
- Every shipped phase gets a ✅ marker on its heading.
- Phase 7 (Hardening + headers) body rewritten to record what
actually shipped (CSP frame-ancestors, SPA rewrites, error
handling, sanitization reuse) plus what's still optional
(?debug=1 surface, hard relay-count cap).
- Phase 8 (Polish) deliberately dropped — the previously-drafted
Credits overlay, postMessage handshake, and explicit
light/dark/auto theming weren't needed for the use cases the
feature is actually solving. Replaced with a clear "dropped"
note listing each item and why.
- Phase 9 (Future) reframed as the natural follow-up arcs if we
revisit (oEmbed endpoint, render-to-image, OG images,
lightweight embed.html bundle).
The Acceptance Criteria for Embed v1 stays valid as-is — every
bullet is true.
36 KiB
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:
<iframe
src="https://artstr.org/embed/naddr1..."
width="800" height="450"
loading="lazy" allowfullscreen
style="border:0; max-width:100%; aspect-ratio:16/9;"
></iframe>
The viewer renders by:
- Resolving the Nostr identifier to the latest (or pinned) event.
- Parsing the Artstr payload.
- Resolving any linked sub-designs in the payload via the existing
linkedDesignsresolver (disk cache + batched prefetch reused). - 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
<img src="…">-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/<id> routes and the linked-designs resolver). No new
parsing logic should be invented for embeds.
6. URL Design
6.1 Canonical route
/embed/<nostr-id>
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)
<div style="position:relative;width:100%;max-width:960px;aspect-ratio:16/9;">
<iframe
src="https://artstr.org/embed/naddr1..."
title="Artstr design"
loading="lazy" allowfullscreen
style="position:absolute;inset:0;width:100%;height:100%;border:0;"
></iframe>
</div>
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.
<div style="position:relative;width:100%;max-width:480px;aspect-ratio:1/1.4;">
<iframe
src="https://artstr.org/embed/naddr1..."
title="Artstr cover"
loading="lazy"
style="position:absolute;inset:0;width:100%;height:100%;border:0;"
></iframe>
</div>
7.3 Chrome-free
<iframe
src="https://artstr.org/embed/naddr1...?controls=0&open=0&attr=0"
title="Artstr design"
width="800" height="450"
loading="lazy"
style="border:0;max-width:100%;"
></iframe>
7.4 Deck embed, deep-linked to slide 3
<iframe
src="https://artstr.org/embed/naddr1...?slide=3&controls=1"
title="Artstr slide deck"
width="960" height="540"
loading="lazy" allowfullscreen
style="border:0;max-width:100%;aspect-ratio:16/9;"
></iframe>
7.5 Animated pixel art
<iframe
src="https://artstr.org/embed/naddr1...?play=1"
title="Pixel art"
width="320" height="320"
loading="lazy"
style="border:0;image-rendering:pixelated;"
></iframe>
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):
slidedeckcustomartcoverpixelartdisc/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:
renderShapeIntoElementrenderQrIntoElementrenderPixelArtIntoElement(animation included)renderDesignObjectIntoElement(embedded + linked branches)- text layers'
applyPreviewLayerTextStyle+ safe-href scrubbing safeTextLinkHreffor 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
localStorageit 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:
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
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
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
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:
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
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
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
requestIdleCallbackfor 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/<id>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:
safeTextLinkHreffor inline anchors.- The shape allowlist (
migrateLegacyLayerTargets). - Image URL pass-through (no script execution;
<img>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-modeis set. - Render loading state immediately after
embed-modebody 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,
linkedDesignsresolver,- 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
titleis 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=1can 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/<id> 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/<id>.png(Phase 7 — currently the watermarked preview from a premium envelope can stand in if present)og:url= canonical/share/<id>
17.3 Future: oEmbed endpoint
/oembed?url=https://artstr.org/share/naddr1...
Returns:
{
"version": "1.0",
"type": "rich",
"provider_name": "Artstr",
"provider_url": "https://artstr.org",
"title": "Design title",
"author_name": "@alice",
"html": "<iframe src=\"https://artstr.org/embed/naddr1...\" width=\"800\" height=\"450\" style=\"border:0\" allowfullscreen></iframe>",
"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/<id>.png, /render/<id>.svg, /render/<id>.jpg.
Probably backed by headless Chromium or a same-bundle SVG
serializer. Lets <img src="..."> users get a static fallback
without iframes.
18. Implementation Plan
Phase 0 — Discovery + reuse audit ✅ Shipped
- Map existing
/share/<id>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/<naddr>route detection.?embed=<id>fallback.embed-modebody class hides editor chrome.- Resolves a public
naddrto the latest event. - Renders
slideandcustomartmodes. - Loading + error states.
- "Open in Artstr ↗" link works.
Phase 2 — All static modes ✅ Shipped
Acceptance:
cover,disc,jewel,pixelart(single-frame),disc-designall 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. prefetchBatchwarms 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=Ndeep-links to slide N.- Keyboard nav works when iframe focused.
- Fullscreen via control button +
Fkey. controls=0hides 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=0starts paused;fpsoverrides payload FPS.prefers-reduced-motiondefaults 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/:idviavercel.jsonso the route is iframe-able from any origin.- SPA rewrites for
/embed/:id(and the existing/share/:id,/u/:npub) invercel.json. - Cross-origin iframe smoke-tested via the bundled
transparency-iframe.htmlbaseline + 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=1surfacing 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/<id>links into rich previews. - Image render endpoint (
/render/<id>.png/.svg). Headless-Chromium / SVG-serializer route so an<img src=…>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.htmlbundle. The embed currently loads the full SPA inside the iframe — fine for MVP, ~250 KB heavier than necessary. Splitting anembed.htmlentry 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 (— the in-app Credits panel already shipped; not needed inside iframe.?credits=1)PostMessage handshake— embedding sites that need parent-side control can just use the URL params we already support and reload the iframe.Explicit— chips are blur-based and legible on any host theme; explicit theme would be more complexity than it's worth.?theme=light/dark/auto
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
neventexpecting 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/<id>and?embed=<id>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-layerresolveLinked. - 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)
<img>-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+.