mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-05 05:00:39 +00:00
- 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]>
404 lines
18 KiB
Markdown
404 lines
18 KiB
Markdown
# 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
|
|
|
|
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`
|
|
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.
|
|
|
|
### 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.
|
|
|
|
---
|
|
|
|
## 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 (`renderDeckSorter` and friends).
|
|
- **Edit round-trip** — open a card in the canvas editor, write back on
|
|
switch (`persistEditingDeckSlide` pattern, `editingIndex`).
|
|
- **Inline-embedded storage** — one self-contained payload; the publish /
|
|
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.
|
|
- **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.
|
|
|
|
### 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.
|
|
5. 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 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.
|
|
- `casewrap-*` typed `kind:30078` events; `/share/<naddr>` cold-resolves;
|
|
`vercel.json` rewrites `/share/:id` and `/u/:npub` to the SPA.
|
|
|
|
---
|
|
|
|
## Data model
|
|
|
|
### Stack payload (`templateMode: 'stack'`)
|
|
|
|
```js
|
|
{
|
|
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 `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.
|
|
|
|
### The `action` on a layer
|
|
|
|
Any layer may carry an optional `action`:
|
|
|
|
```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" } }
|
|
```
|
|
|
|
- `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`, …).
|
|
|
|
### 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`-tag `stack:<title|id>` (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.
|
|
|
|
---
|
|
|
|
## 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-card` target 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 (`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 **`<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 keywords
|
|
`next` / `prev` / `first` / `last`, **clamped at the ends — no wrap**);
|
|
`open-url` → a confirm dialog showing the destination, then open in a
|
|
new tab with `rel="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; `Esc` exits. 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-card` jumps.
|
|
- **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 `persistEditingDeckSlide` step), 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).
|
|
- `stack` joins `MODE_LABELS`, the dedup fingerprint, the mode filters,
|
|
and gets a `stack` ("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-url` is the one outward vector** — always route it through a
|
|
confirm dialog that shows the real destination URL, and open with
|
|
`rel="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 via `isDeckLike()`.
|
|
- The `stack` data 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 `id`s.
|
|
- Save / load / publish / fork as `casewrap-stack`; feed card + count
|
|
badge; `stack` in `MODE_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 `action` field 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-url`
|
|
confirm, 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.json` rewrite; 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 `id`s, 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
|
|
|
|
1. **Mode** — confirm `templateMode: 'stack'` reusing the deck engine,
|
|
vs. folding actions + free aspect into `deck` itself.
|
|
2. **v1 action set** — `goto-card` + `open-url` only — confirmed?
|
|
3. **Aspect presets** — 16:9 / 9:16 / 1:1 / 4:3 / custom — the right set?
|
|
4. **Viewer** — extend the Presenter Mode runtime with an interactive
|
|
mode, or a separate viewer module? (Lean: extend it.)
|
|
5. **Route** — `/p/<naddr>` as proposed, or a different prefix?
|
|
6. **Category** — one `stack` category, 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-card` or `open-url` action; a
|
|
transparent hotspot can be added in one click.
|
|
- [ ] Actions round-trip through save / reload / publish; dangling
|
|
`goto-card` targets 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 + `Esc` always work; `open-url` confirms 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.
|