- Aspect-ratio changes now re-fit cards via the existing import-letterbox remap instead of stranding layers off-canvas; imports target the stack's aspect, not a hardcoded 16:9. - Clarify how the shared deck renderer is fed for stacks (dims from stack, background from card) and startCard deletion fallback. - Viewer: action regions are focusable buttons (Tab/Enter, screen readers); empty-space clicks are no-ops; goto-card keywords clamp at the ends; touch taps act as clicks; persist the edited card before launching; encrypted-stack locked state on the /p/ route. - Fix the hit-testing risk note (rotation is exact; only clip-masked layers fall back to a bounding box) and add an accessibility row. Co-Authored-By: Claude Opus 4.7 <[email protected]>
18 KiB
Artstr Stacks — interactive fullscreen pages
Goals
Turn Artstr into a Nostr-native HyperCard: a user builds a stack of fullscreen cards, each one an Artstr canvas (layers, drawings, text, images, shapes, QR), and any layer can be made interactive — click it to jump to another card or open a link. The whole stack publishes as one self-contained Nostr event and plays in a fullscreen interactive viewer.
Think interactive zines, micro-sites, portfolios, album booklets, comics, choose-your-path pages — all decentralized, forkable, no hosting.
The core realisation
A Stack is a Slide Deck with interactive layers. The Deck Builder
already gives us ~70% of this — a Stack is not a greenfield feature, it's
a focused extension of templateMode: 'deck'. What's genuinely new is
layer actions and an interactive (non-linear) viewer.
Product goals
- Stack format — a Template-tab layout: an ordered set of fullscreen cards, each an Artstr canvas, at a stack-wide aspect ratio (16:9, 9:16, 1:1, 4:3, or custom — not locked to 16:9 like slide decks).
- Interactive layers — any layer can carry an
action: jump to a card, or open a URL. Transparent hotspot layers make click-anywhere regions trivial. - Interactive viewer — a fullscreen runtime that renders a card and routes clicks on actionable layers. Non-linear: the stack decides where a click goes, not just next/prev.
- Nostr-native — publish/fork/edit-in-place as a
kind:30078addressable event, shareable bynaddr, encryptable via the private-publishing envelope.
Non-goals (v1)
- Arbitrary HTML / CSS / JavaScript. Stacks are a safe declarative schema Artstr renders — never executable web content. No iframes, no script, no custom CSS. This is the single most important constraint.
- Forms, inputs, databases, server logic — a stack is a presentation artifact, not an app.
- Per-card transitions / animations — future polish.
- Blossom media upload — v1 uses image URLs, like the rest of Artstr.
zap,open-nostr,toggle-layeractions — namespace reserved, not built in v1 (zapalone drags in the whole Lightning flow).- Scrolling / multi-viewport cards — a card is one fixed-size canvas.
Decisions (proposed — confirm before building)
- New
templateMode: 'stack', reusing the Deck Builder's card-container engine — not a flag ondeck. A Stack and a Slide Deck are distinct products (free aspect + interactivity vs. locked 16:9 + linear presenting), even though they share most code. - v1 action set:
goto-cardandopen-urlonly. Everything else is reserved namespace. open-urlshows a confirm with the destination before navigating — a published stack is untrusted content.- One event = one stack (all cards embedded inline), exactly like a deck. Multi-event stacks are explicitly not v1.
- Additive schema —
actionon layers,idon cards, astackpayload object. NoSCHEMA_VERSIONbump required.
Why this fits the repo — what's reused
A Stack reuses the Deck Builder's card-container engine almost
wholesale. An isDeckLike(mode) helper covers both 'deck' and
'stack', so this plumbing is shared, not duplicated:
- The card sorter — the thumbnail grid, add / import / duplicate /
delete / drag-reorder / inline-rename (
renderDeckSorterand friends). - Edit round-trip — open a card in the canvas editor, write back on
switch (
persistEditingDeckSlidepattern,editingIndex). - Inline-embedded storage — one self-contained payload; the publish /
load / fork path;
naddrsharing; the community feed card with a count badge. - The whole canvas editor — layers, text, shapes, the pen/pencil tools, clipping, undo/redo all apply to a card unchanged.
- Encryption — a stack payload is deck-shaped, so the
PRIVATE_PUBLISHING_FEATURE.mdenvelope wraps it with zero extra work. - The fullscreen renderer — Presenter Mode already renders a card
full-screen, letterboxed, via
renderCustomArtPreviewDOM. The interactive viewer is that runtime plus an action-routing overlay.
Genuinely new work
- A per-stack aspect ratio (decks are locked 16:9).
- The
actionfield on layers + the editor UI to set it. - Stable card
ids (sogoto-cardsurvives reordering). - The interactive viewer — action hit-testing over the rendered card.
- A public viewer route (
/p/<naddr>).
Current-state facts the plan is built on
templateMode: 'deck'— embedded ordered cards, the sorter view, edit-a-card-in-the-canvas-editor with write-back, publish/fork ascasewrap-deck, feed card with a count badge. (SeeSLIDE_DECK_FEATURE.md.)- Presenter Mode (
#presenterMode) — fullscreen, renders a card withrenderCustomArtPreviewDOM, keyboard nav, an Audience (slide-only) view. - Layer renderer positions every layer as an absolutely-placed div from
inch coordinates;
renderCustomArtPreviewDOMdoes the same at preview scale, withpointer-events:noneon layers. casewrap-*typedkind:30078events;/share/<naddr>cold-resolves;vercel.jsonrewrites/share/:idand/u/:npubto the SPA.
Data model
Stack payload (templateMode: 'stack')
{
version: SCHEMA_VERSION,
templateMode: 'stack',
templateType: 'stack',
meta: { … }, // title / category / language
stack: {
aspect: '16:9', // 16:9 | 9:16 | 1:1 | 4:3 | custom
width: 1920, height: 1080, // canvas size every card shares
theme: { fontFamily: '', background: '' }, // same as deck.theme
startCard: '<cardId>', // entry card (default: first)
cards: [
{
id: 'c_a1b2c3', // stable; goto-card targets this
name: 'Cover', // optional label in the sorter
background: '#111111',
layers: [ … ], // standard layers, may carry `action`
ignoreStackTheme: false
},
…
]
}
}
- A
cardis the deck's slide entry generalised: it gains a stableid, dropsnotes, and itslayersmay carryactions. Canvas dimensions live at the stack level (every card shares the aspect) rather than per-card — all cards in a stack are the same size. - Feeding the shared renderer: the deck's renderer / theme composition
read a per-entry
{ slide: {width,height,background}, layers }shape. For a stack, the engine synthesises that on the fly —width/heightfromstack,backgroundfrom the card — sorenderCustomArtPreviewDOMandcomposeSlideForDeckare reused untouched. (ignoreStackThememirrorsignoreDeckTheme.) startCardholds a cardid; if that card is deleted it falls back to the first card.
The action on a layer
Any layer may carry an optional action:
// Jump to another card — target is a card id or a keyword.
{ "action": { "type": "goto-card", "target": "c_a1b2c3" } }
{ "action": { "type": "goto-card", "target": "next" } } // next|prev|first|last
// Open an external link.
{ "action": { "type": "open-url", "url": "https://example.com" } }
goto-card.targetis a card id, never an index — reordering or duplicating cards never breaks a link.- A dangling target (the card was deleted) is a viewer no-op + a console warning; the editor flags it.
- Unknown
action.typevalues are ignored by the viewer — forward-safe for the reserved set (open-nostr,zap,toggle-layer, …).
Hotspots
No new layer type. A hotspot is just a shape (rect) layer that is
transparent — no fill, no stroke — carrying an action. The editor
offers an "Add hotspot" convenience that drops a transparent rect
ready for an action; otherwise any image / text / shape layer can be made
actionable.
Nostr
- Tag
casewrap-stack;d-tagstack:<title|id>(or a randomd-tag when published privately, perPRIVATE_PUBLISHING_FEATURE.md). - Forkable and editable-in-place via the existing NIP-33 flow.
naddrshareable; encryptable.
The surfaces
1. Stack Builder — the card sorter
The Deck Builder sorter, reused. A new Stack option in the Template- tab Layout panel (beside Case cover / Disc labels / Slide deck). The sidebar adds a Stack panel:
- Aspect ratio — 16:9 / 9:16 / 1:1 / 4:3 / custom. Chosen up front.
Changing it later re-fits every card by reusing the deck's existing
import-letterbox remap (
deckSlideFromImport's scale-to-fit + centre math) — layers scale and centre into the new aspect instead of being stranded off-canvas. The user confirms before a re-fit runs. - Add card (blank / import a design / import from Nostr), duplicate, delete, drag-reorder, inline-rename — all from the deck sorter. Imports letterbox onto the stack's aspect, not a hardcoded 16:9 — the existing remap is generalised to take a target width/height.
- Start card — which card the viewer opens on (default: first).
- A Stack theme panel (font + background), reused from the deck.
2. Card editor + the Action control
Clicking a card opens it in the canvas editor (the deck edit round-trip, unchanged). New in the layer panel: an Interactivity section for the selected layer —
Interactivity
Action: [ None ▾ ] None | Go to card | Open URL
→ Go to card: [ Card ▾ ] first | previous | next | last | <named card>
→ Open URL: [ https://… ]
- Layers with an action get a badge in the layer list and a subtle dashed outline on the canvas, so interactive regions are visible while editing.
- An "Add hotspot" button drops a transparent, action-ready rect.
- A dangling
goto-cardtarget is flagged in the panel.
3. The interactive viewer
The fullscreen runtime, built on the Presenter Mode engine:
- Renders the current card full-screen, letterboxed to the stack aspect,
themed (
composeSlideForDeckreused), viarenderCustomArtPreviewDOM. - An action overlay sits above the card: for each layer with an
action, a positioned hit region matching the layer's (transformed) box. The regions are real<button>s, so they are Tab-focusable, Enter/Space-activatable, and screen-reader-announced — the viewer is not mouse-only. Overlapping actionable layers resolve by z-order (topmost wins); a hovered/focused region shows a pointer cursor. - Click routing:
goto-card→ navigate (ids; or the keywordsnext/prev/first/last, clamped at the ends — no wrap);open-url→ a confirm dialog showing the destination, then open in a new tab withrel="noopener noreferrer". Clicking empty (non-action) space is a no-op — a stack wanting click-anywhere advance adds a full-card hotspot. - Keyboard fallback — arrows / space still do prev/next so a stack
with no on-card navigation is never a dead end;
Escexits. The viewer owns the keyboard (capture-phase handler), like Presenter Mode. - History — visited cards form a back-stack so a Back control / the
Backspace key can return, even across
goto-cardjumps. - Touch — taps act as clicks; fullscreen is best-effort (matching Presenter Mode). A 9:16 stack is the natural phone format.
- Launching from the editor persists the in-progress card edit first
(the
persistEditingDeckSlidestep), so the viewer always shows saved state. - Entry points: a ▶ "View" button in the Stack Builder tool palette, and an "Open Stack" button on a stack's community-browser preview.
4. Public viewer route
/p/<naddr> — a cold-loadable URL that resolves the addressable event
and boots straight into the interactive viewer (no editor chrome). Needs
a vercel.json rewrite (/p/:id → SPA), mirroring /share/:id.
/share/<naddr> of a stack continues to open the editor, as other
designs do. A /p/ link to an encrypted stack the viewer can't
decrypt shows a "This stack is private" locked state, consistent with
PRIVATE_PUBLISHING_FEATURE.md.
5. Community feed
A published stack is a normal kind:30078 event:
- Feed-card thumbnail — the start card's mini-render with a
card-count badge (e.g.
8 cards), reusing the deck preview renderer. - Publish-confirm preview — likewise the start card.
- Preview page — carries the Open Stack button (surface 3).
stackjoinsMODE_LABELS, the dedup fingerprint, the mode filters, and gets astack("Interactive page") category.
Security
- No executable content. The schema is declarative; the viewer only ever interprets the two known action types and ignores the rest. No HTML injection, no script, no iframes, no remote CSS.
open-urlis the one outward vector — always route it through a confirm dialog that shows the real destination URL, and open withrel="noopener noreferrer"in a new tab. Never auto-navigate.- Image layers stay remote URLs, as everywhere in Artstr — no binary embedded, payload size bounded to layer JSON.
- A stack imported / forked from the community is untrusted; the same rules apply when editing it (e.g. surface dangling/odd actions).
Phased delivery
A + B + C together are the minimum genuinely-useful unit — a stack with no actions and no viewer is just a free-aspect deck. Ship them as one arc, gated individually.
Phase A — Stack format
templateMode: 'stack'reusing the deck engine viaisDeckLike().- The
stackdata model; per-stack aspect ratio (presets + custom). - Stack option in the Template-tab Layout panel; the Stack + theme sidebar panels; the card sorter (add / import / duplicate / delete / reorder / rename).
- Stable card
ids. - Save / load / publish / fork as
casewrap-stack; feed card + count badge;stackinMODE_LABELS/ categories / mode filters.
Ship gate: build a 5-card 9:16 stack, reorder it, edit a card, save, reload, publish — it round-trips and shows in the feed with its count.
Phase B — Interactive layers
- The
actionfield on layers; the layer-panel Interactivity control. - "Add hotspot" convenience; actionable-layer badges + canvas outline.
- Dangling-target detection.
Ship gate: add a hotspot with a goto-card action and a button with
an open-url action; both persist through save / reload.
Phase C — Interactive viewer
- The fullscreen viewer: action overlay, click routing,
open-urlconfirm, keyboard fallback, visited-card back-stack. - ▶ View entry point in the Stack Builder palette; Open Stack on the community preview.
Ship gate: play a stack end to end by clicking hotspots; goto-card
jumps and Back works; open-url confirms then opens.
Phase D — Public viewer route + polish
/p/<naddr>route +vercel.jsonrewrite; cold-boot into the viewer.- Profile-page "feature this stack" pinning; share-link affordances.
Later
- Blossom / NIP-B7 media upload.
- More actions:
open-nostr,zap,toggle-layer. - Card transitions; stack templates (zine / portfolio / comic / landing).
- Multi-event stacks (manifest + per-card events) for very large stacks.
Risks & tradeoffs
| Risk | Mitigation |
|---|---|
| "Pages on Nostr" invites expectations of real websites (HTML/JS/SEO/hosting) | Name and frame it as interactive stacks/pages, not websites; the declarative-only schema is a hard line. |
open-url is a phishing / malware vector in shared stacks |
Mandatory confirm dialog showing the destination; new tab + noopener; never auto-navigate. |
goto-card targets break on reorder / delete |
Targets are stable card ids, never indices; dangling targets are editor-flagged and viewer no-ops. |
| A stack with only on-card navigation could trap the viewer | Keyboard prev/next + a back-stack + Esc always work, regardless of the stack's own buttons. |
| Aspect-ratio change strands existing layers off-canvas | Changing aspect re-fits every card through the existing import-letterbox remap (scale-to-fit + centre), behind a confirm — layers are never silently stranded. |
| Stack vs Deck duplication | One shared card-container engine via isDeckLike(); only aspect, actions, and the viewer branch. |
| Big stacks → large events (relay size caps) | Images are URLs; warn past ~100 KB / ~25 cards; Blossom offload + multi-event stacks deferred. |
| Layer click hit-testing | The overlay reuses the renderer's box math; a CSS transform:rotate hit region catches clicks in its true rotated shape, so rotation is exact. A clip-masked layer hit-tests on its bounding box, not the mask shape — documented limitation; authors wanting a precise non-rect hotspot use a rotated/sized rect. |
| Viewer is mouse-centric | Action regions are focusable <button>s — Tab / Enter / Space and screen readers work; keyboard prev/next is always available. |
Open questions
- Mode — confirm
templateMode: 'stack'reusing the deck engine, vs. folding actions + free aspect intodeckitself. - v1 action set —
goto-card+open-urlonly — confirmed? - Aspect presets — 16:9 / 9:16 / 1:1 / 4:3 / custom — the right set?
- Viewer — extend the Presenter Mode runtime with an interactive mode, or a separate viewer module? (Lean: extend it.)
- Route —
/p/<naddr>as proposed, or a different prefix? - Category — one
stackcategory, or split (e.g.page,zine)?
Acceptance summary
Phase A
- Stack is a Template-tab layout; per-stack aspect ratio works, and changing it re-fits existing cards (no stranded layers).
- Card sorter: add / import / duplicate / delete / reorder / rename; cards have stable ids; imports letterbox onto the stack's aspect.
- Save / load / publish / fork as
casewrap-stack; feed card shows a card-count badge.
Phase B
- Any layer can be given a
goto-cardoropen-urlaction; a transparent hotspot can be added in one click. - Actions round-trip through save / reload / publish; dangling
goto-cardtargets are flagged.
Phase C
- The interactive viewer renders cards full-screen and routes clicks on actionable layers.
goto-card(ids + keywords, clamped at the ends) navigates; a back-stack + keyboard +Escalways work;open-urlconfirms then opens in a new tab.- The viewer owns the keyboard; theme is composed in.
- Action regions are focusable buttons — Tab / Enter reach and fire them; empty-space clicks are no-ops.
Phase D
/p/<naddr>cold-boots a published stack into the viewer.