Replace the thin single-action-per-layer model with real interactive logic: - Cards gain onEnter/onExit chains; layers gain an onClick chain. Each is an ordered action chain, not a single link. - v1 action set: goto-card, go-back, open-url, show-layer, hide-layer, toggle-layer — reveals, secret panels, multi-step buttons, branching. - New action-builder surface: a no-JSON panel with reorderable rows, id-based target pickers, and authoring warnings. - Runtime tracks a per-card layer-visibility map (reset on re-entry) and executes chains; action overlay regions are focusable buttons. - Safety: onEnter/onExit cannot navigate (no enter->goto->enter loops); chains capped and non-recursive; unknown action types ignored. - Re-phased A (format) -> B (behaviour authoring) -> C (runtime) -> D (route) -> E (stack logic: variables/conditionals, future tier). Interactivity stays a declarative, interpreted attribute of existing layers — no new layer types, no HTML/JS, no eval. Co-Authored-By: Claude Opus 4.7 <[email protected]>
23 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 object can be made to do something — a click runs an ordered action chain that navigates, reveals, hides, or links. The whole stack publishes as one self-contained Nostr event and plays in a fullscreen interactive runtime.
Think interactive zines, micro-sites, portfolios, comics, choose-your- path pages, simple games — decentralized, forkable, no hosting.
The core realisation
A Stack is a Slide Deck whose cards have interactive logic. The Deck Builder already gives us the card container, sorter, edit round-trip, embed-inline storage, publish/fork path, and a fullscreen renderer. Stacks are a focused extension of that — not a greenfield feature. What's genuinely new is the behaviour system: triggers, action chains, and a runtime that executes them.
A stack is a collection of visual cards where any object can do something — and "something" is an ordered list of actions, not a single link.
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).
- Interactivity — a card has
onEnter/onExitaction chains; any layer has anonClickaction chain. Actions navigate between cards, open links, and show / hide / toggle layers on the current card. - Action builder — a no-code panel: "When this is clicked: 1. Hide layer X 2. Show layer Y 3. Go to card Z." It should feel like Apple Shortcuts, not like writing code.
- Interactive runtime — a fullscreen player that renders a card,
routes clicks through
onClickchains, runsonEnter/onExit, and tracks per-card layer visibility. - 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. A stack is a safe declarative
schema Artstr interprets — never executable web content. No iframes,
no script, no custom CSS, no
eval. An action chain is data. This is the single most important constraint. - A scripting language. v1 has triggers + action chains, not variables, counters, or conditionals. Those are a defined later tier (Phase E) — the action model is built to accept them without a rewrite.
- New layer types for interactivity —
onClickis an attribute of the existing layers. (Audio / video layers are a real future addition, but later.) - Asset library, modals, Nostr embeds, Blossom upload, static-HTML export, stack templates — all later.
- Extra triggers (
hover,timer,keypress,double-click) — later; v1 triggers areclick,card-enter,card-exit. - Forms, inputs, server logic — a stack is a presentation artifact, not an app.
Decisions (proposed — confirm before building)
- New
templateMode: 'stack', reusing the Deck Builder's card- container engine via anisDeckLike(mode)helper — not a flag ondeck. A Stack and a Slide Deck are distinct products. - Interactivity is an attribute, not a layer type — cards carry
onEnter/onExit, layers carryonClick; each is an action chain. - v1 action set:
goto-card,go-back,open-url,show-layer,hide-layer,toggle-layer. Everything else is reserved namespace. open-urlshows a confirm with the destination before navigating.- One event = one stack (all cards embedded inline), like a deck.
- Additive schema —
onClickon layers,onEnter/onExit+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. Every layer
already has a stable
id(makeLayerId()) and avisibleflag — the exact hooksshow/hide/toggleactions need. - Theme composition —
composeSlideForDeckis reused (ignoreStackThememirrorsignoreDeckTheme). - 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 runtime is that renderer plus an action layer.
Genuinely new work
- A per-stack aspect ratio (decks are locked 16:9).
- The behaviour model —
onClickon layers,onEnter/onExiton cards, each an ordered action chain. - The action-builder UI in the editor.
- The interactive runtime — executes chains, tracks per-card layer visibility, hit-tests clickable layers.
- 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-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, capture-phase keyboard ownership, an Audience (slide-only) view. - Every layer has a stable
idand avisibleboolean; the layer renderer positions each layer as an absolutely-placed div from inch coordinates. 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: '' },
startCard: '<cardId>', // entry card (default: first)
cards: [
{
id: 'c_a1b2c3', // stable; goto-card targets this
name: 'Cover',
background: '#111111',
layers: [ … ], // standard layers; may carry onClick
onEnter: [ <action>, … ], // runs when the card is shown
onExit: [ <action>, … ], // runs when leaving the card
ignoreStackTheme: false
},
…
]
}
}
- A
cardis the deck's slide entry generalised: a stableid, nonotes, plusonEnter/onExitaction chains. - Feeding the shared renderer: the deck renderer reads a per-entry
{ slide:{width,height,background}, layers }shape. For a stack the engine synthesises that —width/heightfromstack,backgroundfrom the card — sorenderCustomArtPreviewDOM/composeSlideForDeckare reused untouched. startCardholds a cardid; if that card is deleted it falls back to the first card.
Layer interactivity
Any layer gains one optional field — an onClick action chain:
{
id: 'l_x1', type: 'shape', /* …normal layer fields… */
visible: true,
onClick: [ <action>, <action>, … ] // empty / absent = not interactive
}
A hotspot is just a transparent shape layer (no fill, no stroke) with
an onClick. No new layer type — the editor offers an "Add hotspot"
convenience that drops a transparent, click-ready rect; otherwise any
text / image / shape layer can be given an onClick.
Triggers (v1)
| Trigger | Lives on | Fires when |
|---|---|---|
onClick |
a layer | the layer is clicked / tapped / Enter-activated |
onEnter |
a card | the card becomes visible |
onExit |
a card | the runtime leaves the card |
Named slots, not a generic trigger list — onHover, onDoubleClick,
etc. slot in later as additional named slots.
Actions
An action chain is an ordered array of actions. v1 action types:
{ "type": "goto-card", "target": "c_a1b2c3" } // or next|prev|first|last
{ "type": "go-back" } // pop the visit back-stack
{ "type": "open-url", "url": "https://example.com" }
{ "type": "show-layer", "target": "l_x1" } // layer id on this card
{ "type": "hide-layer", "target": "l_x1" }
{ "type": "toggle-layer", "target": "l_x1" }
Execution rules:
- A chain runs top to bottom.
goto-card/go-backend the chain — actions after them are ignored (you're on a different card now). The editor flags trailing actions.show/hide/toggletargeta layer on the current card; cross-card layer manipulation is not v1.onEnter/onExitchains may not containgoto-cardorgo-back— onlyonClickcan navigate. This makes card-enter→navigate→card-enter loops structurally impossible.- Chains are capped at 20 actions; the runtime ignores the excess.
- Unknown
action.typevalues are silently ignored — forward-safe for the Phase E logic actions (set-variable,if, …) and reserved ones (open-nostr,zap,play-audio, …).
Runtime layer visibility
Show/hide/toggle never mutate the stored design. The runtime keeps a
per-card visibility map (layerId → bool), initialised from each
layer's authored visible. Re-entering a card resets that card's map
to the authored state, then runs onEnter — so behaviour is predictable
and re-playable. (Persisting runtime state across visits is the Phase E
variables tier.)
A composeCardForRuntime(card, visibilityMap) helper — analogous to
composeSlideForDeck — yields the {layers} the renderer draws.
Nostr
- Tag
casewrap-stack;d-tagstack:<title|id>(random when published privately, perPRIVATE_PUBLISHING_FEATURE.md). - Forkable, editable-in-place (NIP-33),
naddr-shareable, 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 via the deck's import-letterbox remap (scale-to-fit + centre), behind a confirm — layers are never stranded off-canvas.
- 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.
- Start card — which card the runtime opens on (default: first).
- A Stack theme panel (font + background), reused from the deck.
- A per-card onEnter / onExit editor (an action-chain editor; see surface 3) reachable from the card's context menu.
2. Card editor — drawing
Clicking a card opens it in the canvas editor (the deck edit round-trip, unchanged). All existing tools apply. New affordances:
- Add hotspot — drops a transparent, click-ready rect.
- A layer with a non-empty
onClickshows an action badge in the layer list and a subtle dashed outline on canvas, so interactive regions are visible while authoring.
3. The action builder
The heart of the feature. A panel — opened for the selected layer
(onClick) or for a card (onEnter / onExit) — that edits one action
chain with no JSON:
When this layer is clicked: [+ Add action ▾]
⠿ 1. Hide layer → [ Intro Text ▾ ] ✕
⠿ 2. Show layer → [ Secret Panel ▾ ] ✕
⠿ 3. Go to card → [ Chapter 2 ▾ ] ✕
- + Add action menu: Go to card · Go back · Open URL · Show layer · Hide layer · Toggle layer.
- Each action row has a type-appropriate target picker — a card
dropdown (the keywords first / previous / next / last plus every
named card) for
goto-card; a layer dropdown (layers on this card) for show/hide/toggle; a URL field foropen-url. - Rows are drag-reorderable (⠿) and removable.
- The editor warns on: actions after a
goto-card/go-back(unreachable); agoto-cardwhose target card was deleted (dangling); navigation actions placed in a card'sonEnter/onExit(not allowed). targets are stored as ids (card id, layer id) — reordering or renaming never breaks a chain.
4. The interactive runtime
The fullscreen player, built on the Presenter Mode engine:
- Renders the current card full-screen, letterboxed to the stack aspect,
themed, with the runtime visibility map applied
(
composeCardForRuntime). - On entering a card: reset its visibility map → run
onEnter. - An action overlay sits above the card: for each layer with a
non-empty
onClick, a positioned hit region matching the layer's (transformed) box, rendered as a real<button>— Tab-focusable, Enter/Space-activatable, screen-reader-announced. Overlapping interactive layers resolve by z-order (topmost wins); a hovered/focused region shows a pointer cursor. - Activating a layer runs its
onClickchain.goto-cardruns the current card'sonExit, then navigates, then the new card'sonEnter.open-urlshows a confirm with the destination, then opens a new tab withrel="noopener noreferrer".show/hide/togglemutate the visibility map and re-render the card. Clicking empty (non-action) space is a no-op. - Navigation safety net — arrows / space still do prev/next, a
visited-card back-stack powers
go-backand Backspace,Escexits. So a stack with broken or missing on-card navigation is never a dead end.goto-cardkeywords clamp at the ends (no wrap). - The runtime owns the keyboard (capture-phase handler), like Presenter Mode. Launching from the editor persists the in-progress card edit first.
- Touch — taps act as clicks; fullscreen is best-effort.
- Two modes: preview (inside the editor — the ▶ View button in the
Stack Builder tool palette) and published (
/p/<naddr>). - Entry points: ▶ View in the Stack Builder palette; Open Stack on a stack's community-browser preview page.
5. Public viewer route
/p/<naddr> — a cold-loadable URL that resolves the addressable event
and boots straight into the runtime (no editor chrome). Needs a
vercel.json rewrite (/p/:id → SPA), mirroring /share/:id.
/share/<naddr> of a stack still opens the editor. A /p/ link to an
encrypted stack the viewer can't decrypt shows a "This stack is
private" locked state, per PRIVATE_PUBLISHING_FEATURE.md.
6. 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 4).
stackjoinsMODE_LABELS, the dedup fingerprint, the mode filters, and gets astack("Interactive page") category.
Security
- No executable content. The schema is declarative; the runtime only
ever interprets the known action types and ignores the rest. No HTML
injection, no script, no iframes, no remote CSS, no
eval. open-urlis the one outward vector — always behind a confirm dialog showing the real destination; new tab;rel="noopener noreferrer"; onlyhttp(s):/mailto:schemes — neverjavascript:ordata:.- Action chains are bounded — capped length, no recursion (a chain
cannot call another chain), and
onEnter/onExitcannot navigate, so there is no loop or runaway-execution surface. - Image layers stay remote URLs, as everywhere in Artstr — payload bounded to layer/action JSON.
- A forked/imported stack is untrusted;
sanitizeStackJson()drops unknown action types and clamps chain lengths on load.
Phased delivery
A + B + C together are the minimum genuinely-useful unit — a stack with no behaviour is just a free-aspect deck; behaviour with no runtime can't be played. 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, edit a card, save, reload, publish — round-trips; shows in the feed with its count.
Phase B — Behaviour authoring
- Layer
onClick+ cardonEnter/onExitaction chains in the data model;sanitizeStackJson()validation on load. - The action set:
goto-card,go-back,open-url,show-layer,hide-layer,toggle-layer. - The action-builder panel (surface 3): add / reorder / remove actions, id-based target pickers, the warnings.
- "Add hotspot"; action badges on interactive layers.
Ship gate: give a hotspot an onClick of hide A → show B → go to
card 2; give a card an onEnter that shows a layer; both round-trip
through save / reload / publish; dangling targets are flagged.
Phase C — Interactive runtime
- The fullscreen runtime:
composeCardForRuntime, the action overlay, chain execution, per-card visibility,onEnter/onExit, the navigation safety net,open-urlconfirm. - ▶ View entry point in the Stack Builder palette; Open Stack on the community preview.
Ship gate: play a stack end to end — clicking hotspots reveals/hides
layers and jumps cards; onEnter fires; Back + keyboard + Esc always
work; open-url confirms then opens.
Phase D — Public viewer route + polish
/p/<naddr>route +vercel.jsonrewrite; cold-boot into the runtime.- Profile-page "feature this stack" pinning.
Phase E — Stack logic (the scripting tier)
statevariables on the stack; actionsset-variable,increment-variable; a conditional action{ type:'if', condition, then:[…], else:[…] }. Local-only state (localStorage), reset on runtime start.- A condition builder in the action panel.
The v1 action model already tolerates these (unknown types are ignored), so Phase E is additive — no schema rewrite.
Later / backlog
- Audio / video layer types and
play/pauseactions;onMediaEnd. - More triggers:
onHover,onDoubleClick,onTimer,onKeyPress. open-nostr(npub / note / naddr) andzapactions; Nostr embeds.- Card transitions; stack templates (zine / portfolio / comic / landing).
- Blossom / NIP-B7 media upload; an asset library.
- Modal overlays; static-HTML export; multi-event stacks.
Risks & tradeoffs
| Risk | Mitigation |
|---|---|
| Action chains become a back-door scripting language | v1 is a fixed, finite action set; no variables/branching until Phase E; chains can't call chains; the runtime interprets, never evals. |
onEnter → goto-card → onEnter infinite loop |
onEnter/onExit chains structurally cannot contain navigation actions; only onClick navigates. |
open-url is a phishing / malware vector |
Confirm dialog showing the destination; new tab + noopener; scheme allowlist; never javascript:/data:. |
| Targets break on reorder / delete / rename | Actions reference stable card / layer ids, never indices or names; dangling targets are editor-flagged and runtime no-ops. |
| A stack traps the viewer (bad on-card nav) | Keyboard prev/next, a back-stack, and Esc always work regardless of the stack's own buttons. |
| Aspect-ratio change strands layers | Changing aspect re-fits every card via the import-letterbox remap, behind a confirm. |
| Runtime visibility drifts / becomes unpredictable | Per-card map reset to authored visible on every (re-)entry; show/hide/toggle never mutate stored data. |
| Stack vs Deck code duplication | One shared card-container engine via isDeckLike(); only aspect, behaviour, and the runtime branch. |
| 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 (rotation is exact); a clip-masked layer hit-tests on its bounding box — documented limitation. |
| Viewer is mouse-centric | Action regions are focusable <button>s — Tab / Enter / Space and screen readers work. |
| Big stacks → large events (relay size caps) | Images are URLs; warn past ~100 KB / ~25 cards; Blossom + multi-event stacks deferred. |
Open questions
- Mode — confirm
templateMode: 'stack'reusing the deck engine. - v1 action set — the six above — confirmed? (
open-nostrdeferred.) - Aspect presets — 16:9 / 9:16 / 1:1 / 4:3 / custom — right set?
- Runtime — extend the Presenter Mode engine, or a separate module? (Lean: extend it.)
- Route —
/p/<naddr>as proposed? - Category — one
stackcategory, or split (page,zine)? - Re-entry semantics — confirm a card resets its layer visibility on every entry (vs. remembering it). v1 plan: reset.
Acceptance summary
Phase A
- Stack is a Template-tab layout; per-stack aspect works, and changing it re-fits existing cards.
- 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
- A layer can be given an
onClickchain and a card anonEnter/onExitchain via the no-JSON action builder. - Action chains support goto-card / go-back / open-url / show / hide / toggle-layer; rows reorder; targets are id-based.
- Chains round-trip through save / reload / publish; dangling targets, trailing-after-navigation, and navigation-in-onEnter are all flagged.
Phase C
- The runtime renders cards full-screen and executes
onClick,onEnter,onExitchains. - show / hide / toggle change layer visibility live; visibility resets on card re-entry.
goto-card(ids + clamped keywords) andgo-backnavigate; a back-stack + keyboard +Escalways work;open-urlconfirms.- Action regions are focusable buttons; empty-space clicks are no-ops; the runtime owns the keyboard; theme is composed in.
Phase D
/p/<naddr>cold-boots a published stack into the runtime.