diff --git a/docs/STACKS_FEATURE.md b/docs/STACKS_FEATURE.md index b68afd3..c14958c 100644 --- a/docs/STACKS_FEATURE.md +++ b/docs/STACKS_FEATURE.md @@ -4,63 +4,77 @@ 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**. +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, album booklets, comics, -choose-your-path pages — all decentralized, forkable, no hosting. +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 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*. +**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 1. **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). -2. **Interactive layers** — any layer can carry an `action`: jump to a - card, or open a URL. Transparent **hotspot** layers make - click-anywhere regions trivial. -3. **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. -4. **Nostr-native** — publish/fork/edit-in-place as a `kind:30078` +2. **Interactivity** — a card has `onEnter` / `onExit` action chains; any + layer has an `onClick` action chain. Actions navigate between cards, + open links, and show / hide / toggle layers on the current card. +3. **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. +4. **Interactive runtime** — a fullscreen player that renders a card, + routes clicks through `onClick` chains, runs `onEnter`/`onExit`, and + tracks per-card layer visibility. +5. **Nostr-native** — publish / fork / edit-in-place as a `kind:30078` addressable event, shareable by `naddr`, 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-layer` actions** — namespace reserved, - not built in v1 (`zap` alone drags in the whole Lightning flow). -- **Scrolling / multi-viewport cards** — a card is one fixed-size canvas. +- **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 — `onClick` is 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 are `click`, `card-enter`, `card-exit`. +- **Forms, inputs, server logic** — a stack is a presentation artifact, + not an app. ### Decisions (proposed — confirm before building) -1. **New `templateMode: 'stack'`**, reusing the Deck Builder's - card-container engine — *not* a flag on `deck`. A Stack and a Slide - Deck are distinct products (free aspect + interactivity vs. locked - 16:9 + linear presenting), even though they share most code. -2. **v1 action set: `goto-card` and `open-url` only.** Everything else is - reserved namespace. -3. **`open-url` shows a confirm** with the destination before navigating - — a published stack is untrusted content. -4. **One event = one stack** (all cards embedded inline), exactly like a - deck. Multi-event stacks are explicitly not v1. -5. Additive schema — `action` on layers, `id` on cards, a `stack` payload - object. No `SCHEMA_VERSION` bump required. +1. **New `templateMode: 'stack'`**, reusing the Deck Builder's card- + container engine via an `isDeckLike(mode)` helper — not a flag on + `deck`. A Stack and a Slide Deck are distinct products. +2. **Interactivity is an attribute, not a layer type** — cards carry + `onEnter`/`onExit`, layers carry `onClick`; each is an action chain. +3. **v1 action set:** `goto-card`, `go-back`, `open-url`, `show-layer`, + `hide-layer`, `toggle-layer`. Everything else is reserved namespace. +4. **`open-url` shows a confirm** with the destination before navigating. +5. **One event = one stack** (all cards embedded inline), like a deck. +6. Additive schema — `onClick` on layers, `onEnter`/`onExit` + `id` on + cards, a `stack` payload object. No `SCHEMA_VERSION` bump required. --- @@ -78,33 +92,38 @@ wholesale. An `isDeckLike(mode)` helper covers both `'deck'` and load / fork path; `naddr` sharing; 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. + tools, clipping, undo/redo all apply to a card unchanged. Every layer + already has a stable `id` (`makeLayerId()`) and a `visible` flag — the + exact hooks `show`/`hide`/`toggle` actions need. +- **Theme composition** — `composeSlideForDeck` is reused + (`ignoreStackTheme` mirrors `ignoreDeckTheme`). - **Encryption** — a stack payload is deck-shaped, so the `PRIVATE_PUBLISHING_FEATURE.md` envelope 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. + interactive runtime is that renderer plus an action layer. ### Genuinely new work 1. A per-stack **aspect ratio** (decks are locked 16:9). -2. The **`action`** field on layers + the editor UI to set it. -3. Stable **card `id`s** (so `goto-card` survives reordering). -4. The **interactive viewer** — action hit-testing over the rendered card. +2. The **behaviour model** — `onClick` on layers, `onEnter`/`onExit` on + cards, each an ordered **action chain**. +3. The **action-builder UI** in the editor. +4. The **interactive runtime** — executes chains, tracks per-card layer + visibility, hit-tests clickable layers. 5. A public **viewer route** (`/p/`). ### 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 as - `casewrap-deck`, feed card with a count badge. (See - `SLIDE_DECK_FEATURE.md`.) + edit-a-card-with-write-back, publish/fork as `casewrap-deck`, feed card + with a count badge. (See `SLIDE_DECK_FEATURE.md`.) - Presenter Mode (`#presenterMode`) — fullscreen, renders a card with - `renderCustomArtPreviewDOM`, keyboard nav, an Audience (slide-only) - view. -- Layer renderer positions every layer as an absolutely-placed div from - inch coordinates; `renderCustomArtPreviewDOM` does the same at preview - scale, with `pointer-events:none` on layers. + `renderCustomArtPreviewDOM`, keyboard nav, capture-phase keyboard + ownership, an Audience (slide-only) view. +- Every layer has a stable `id` and a `visible` boolean; the layer + renderer positions each layer as an absolutely-placed div from inch + coordinates. - `casewrap-*` typed `kind:30078` events; `/share/` cold-resolves; `vercel.json` rewrites `/share/:id` and `/u/:npub` to the SPA. @@ -123,14 +142,16 @@ wholesale. An `isDeckLike(mode)` helper covers both `'deck'` and 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 + theme: { fontFamily: '', background: '' }, startCard: '', // entry card (default: first) cards: [ { id: 'c_a1b2c3', // stable; goto-card targets this - name: 'Cover', // optional label in the sorter + name: 'Cover', background: '#111111', - layers: [ … ], // standard layers, may carry `action` + layers: [ … ], // standard layers; may carry onClick + onEnter: [ , … ], // runs when the card is shown + onExit: [ , … ], // runs when leaving the card ignoreStackTheme: false }, … @@ -139,53 +160,90 @@ wholesale. An `isDeckLike(mode)` helper covers both `'deck'` and } ``` -- A `card` is the deck's slide entry generalised: it gains a stable `id`, - drops `notes`, and its `layers` may carry `action`s. 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`/`height` - from `stack`, `background` from the card — so `renderCustomArtPreviewDOM` - and `composeSlideForDeck` are reused untouched. (`ignoreStackTheme` - mirrors `ignoreDeckTheme`.) -- **`startCard`** holds a card `id`; if that card is deleted it falls back - to the first card. +- A `card` is the deck's slide entry generalised: a stable `id`, no + `notes`, plus `onEnter` / `onExit` action 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`/`height` from `stack`, `background` + from the card — so `renderCustomArtPreviewDOM` / `composeSlideForDeck` + are reused untouched. +- **`startCard`** holds a card `id`; if that card is deleted it falls + back to the first card. -### The `action` on a layer +### Layer interactivity -Any layer may carry an optional `action`: +Any layer gains one optional field — an `onClick` action chain: ```js -// 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" } } +{ + id: 'l_x1', type: 'shape', /* …normal layer fields… */ + visible: true, + onClick: [ , , … ] // empty / absent = not interactive +} ``` -- `goto-card.target` is 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.type` values are ignored by the viewer — forward-safe - for the reserved set (`open-nostr`, `zap`, `toggle-layer`, …). +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`. -### Hotspots +### Triggers (v1) -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. +| 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: + +```js +{ "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-back` **end the chain** — actions after them are + ignored (you're on a different card now). The editor flags trailing + actions. +- `show` / `hide` / `toggle` `target` a layer **on the current card**; + cross-card layer manipulation is not v1. +- **`onEnter` / `onExit` chains may not contain `goto-card` or `go-back`** + — only `onClick` can navigate. This makes card-enter→navigate→card-enter + loops structurally impossible. +- Chains are capped at **20 actions**; the runtime ignores the excess. +- Unknown `action.type` values 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`-tag `stack:` (or a random `d`-tag - when published privately, per `PRIVATE_PUBLISHING_FEATURE.md`). -- Forkable and editable-in-place via the existing NIP-33 flow. -- `naddr` shareable; encryptable. +- Tag `casewrap-stack`; `d`-tag `stack:` (random when published + privately, per `PRIVATE_PUBLISHING_FEATURE.md`). +- Forkable, editable-in-place (NIP-33), `naddr`-shareable, encryptable. --- @@ -197,85 +255,104 @@ 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. +- **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 — the - existing remap is generalised to take a target width/height. -- **Start card** — which card the viewer opens on (default: first). + 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 + the Action control +### 2. Card editor — drawing 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 — +unchanged). All existing tools apply. New affordances: + +- **Add hotspot** — drops a transparent, click-ready rect. +- A layer with a non-empty `onClick` shows 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**: ``` -Interactivity - Action: [ None ▾ ] None | Go to card | Open URL - → Go to card: [ Card ▾ ] first | previous | next | last | - → Open URL: [ https://… ] +When this layer is clicked: [+ Add action ▾] + ⠿ 1. Hide layer → [ Intro Text ▾ ] ✕ + ⠿ 2. Show layer → [ Secret Panel ▾ ] ✕ + ⠿ 3. Go to card → [ Chapter 2 ▾ ] ✕ ``` -- 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-card` target is flagged in the panel. +- **+ 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 for `open-url`. +- Rows are **drag-reorderable** (⠿) and removable. +- The editor **warns** on: actions after a `goto-card`/`go-back` + (unreachable); a `goto-card` whose target card was deleted (dangling); + navigation actions placed in a card's `onEnter`/`onExit` (not allowed). +- `target`s are stored as **ids** (card id, layer id) — reordering or + renaming never breaks a chain. -### 3. The interactive viewer +### 4. The interactive runtime -The fullscreen runtime, built on the Presenter Mode engine: +The fullscreen player, built on the Presenter Mode engine: - Renders the current card full-screen, letterboxed to the stack aspect, - themed (`composeSlideForDeck` reused), via `renderCustomArtPreviewDOM`. -- 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 **`