Second-pass review surfaced eight issues worth pinning down before we start coding. Each addressed in-place rather than as a TODO so the implementation has a clear target: 1. §5.1 — Persisted vs in-memory fields. The host's serialized `content` was implicitly inflating with `fallbackPreview`, `lastResolvedAt`, `lastResolvedSize` from the initial schema. Now explicit: only `kind` / `ref` / `mode` / `pinnedEventId` / `pinnedSnapshot` persist; the rest are runtime-only and stripped at publish time. A linked layer footprint is ~110 bytes serialized. 2. §6.1 — Pinned + source deletion. New section covering the case where the source author kind-5-deletes a pinned event. Adds a `pinnedSnapshot` field that the renderer prefers-live-falls-back- to-snapshot, plus the existing `fallbackPreview` for visual-only fallback. Default = pin without snapshot, user opts in for permanence. 3. §8.1 — Sync vs async render decision. The renderer is synchronous today; this section explicitly picks "sync placeholder + async upgrade" over "block render" or "pre-fetch all". DOM tagging + abort-on-detach behaviour documented so the implementation doesn't end up re-deciding mid-build. 4. §8.2 — Autosave + cold start. localStorage restore loses in-memory resolver state, so on `restoreAutosave` we kick off the resolver for every linked layer in the background. 5. §10.3 — Embedded → linked attribution: corrected an inaccurate claim. Embedded layers don't carry source-author metadata, so "Publish + link" credits the CURRENT user, not whoever originally authored the embedded content. Action copy should warn explicitly. 6. §12.1 — Premium-link access check: clarified that the gate is the host viewer's purchase-vault entry (not technical decryption capability, since the soft-gate KDF is deterministic). 7. §13 — Added a per-tree cap (1 MB total resolved payload per host render) on top of the per-event cap. Depth-3 fan-out could theoretically multiply fetches; the tree cap stops a coordinated DOS at the host level. 8. §11.5 — Picking initial layer dimensions for a new linked layer (sync resolver lookup with a 1.5 s timeout, falls back to 16:9 default). Plus a link-creation modal preview so the user can confirm what they're linking before commit. Also tightened the Phase 1 acceptance criteria around serialized size: explicit budgets for 10-link host events and verification that in-memory fields are stripped at publish.
26 KiB
Artstr Studio Linked Designs
Cross-Reference Imports + Auto-Update + Attribution Chain
Status
Spec draft. Not yet implemented. This document is the canonical plan; when work begins it follows this doc, and updates land here first.
1. Feature Summary
Artstr currently supports embedded cross-template imports: any
design can be pulled into another as a type: 'design' layer (or as
a type: 'pixelart' layer, or as a deck slide, or into a disc-sheet
slot). Every existing import is a deep JSON.parse(JSON.stringify(...))
clone — once placed, the new layer has no relationship to its source
event. Source edits don't propagate; host-event size grows linearly
with embedded content.
This spec adds a parallel linked flavor of design imports.
A linked layer stores only the NIP-01 addressable-event coordinate
(30078:<creatorPubkey>:<dTag>) of its source. The renderer fetches
the latest event at that address on demand and renders it in place.
Two architectural wins this unlocks:
- Auto-update. The source author edits their design — every host design that links to it picks up the change next render. Brand assets, logos, common components stay in sync without coordination.
- Smaller host events. A host with N linked layers is the size of its own metadata + N coordinates, not N × source-payload. Side- steps the per-event relay size limit for large composite designs.
A third win, called out by the user during scoping: attribution
chain. Every linked layer's source pubkey is computable at render
time. The host's published event carries a p tag for every distinct
contributing creator, so Nostr-aware clients + relays can surface
"co-created by" without manual tagging.
Embedded imports stay as the default for everything that ships today. Linked imports are an opt-in alternative offered alongside, with a clear UI distinction.
2. Goals
- A new
design.kind: 'linked'variant of the existing design layer. - Address-coordinate-based resolution that returns "always latest" by default, with an opt-in "pin to this version" toggle exposed in the layer-options panel.
- Three levels of nesting allowed (linked design can itself contain linked layers, up to depth 3) with cycle detection.
- Attribution chain: published host events carry one
ptag per distinct contributing creator, plus an in-app "Credits" panel that lists who contributed. - Convert-direction commands: "Unlink to embed" (snapshot to current version) and "Publish + link" (extract an embedded layer into its own event and replace it with a link).
- Graceful degradation when relays don't return the source (loading
state → placeholder + optional cached
fallbackPreview).
3. Non-Goals
- Linked premium / private designs in Phase 1. Encrypted-design link resolution requires the viewer's unlock state for premium and the share-list for private; both ship in Phase 2.
- Real-time push when the source updates. The renderer polls on each render + offers a manual Refresh button; no relay subscription is held open for linked sources.
- Editing the linked source through the host. A linked layer is read-only at the host level; to modify the source, the original creator edits the source event.
- Cross-relay write replication. Source-author publishes as they do today; linked rendering reads from the host viewer's normal relay set.
4. Existing Artstr Fit
- The
type: 'design'layer type andrenderDesignObjectIntoElementrenderer already accept any payload and render via the existingrenderDesignPayloadPreviewcompact-mode path. Linked layers reuse the same renderer; only the source of the payload changes. - The purchase-vault arc already established multi-relay union reads
via
_vaultRelayUnion()for kind-30078 events. Linked-design resolution borrows the same pattern (NIP-65 write relays + a default fallback set), keyed by address. - Address-tolerant lookup helpers (
premiumAddressCoord,findUnlockedEntry) already exist; the linked-design resolver follows the same shape.
5. Schema
Linked design layer:
{
id: 'layer-...',
type: 'design',
name: 'Linked: <source title>',
target: 'canvas',
x, y, w, h,
rotate, opacity, z,
design: {
kind: 'linked', // disambiguates from embedded
ref: '30078:<creatorPubkey>:<dTag>', // NIP-01 addressable coord
mode: 'cover', // hint: source-design template mode
pinnedEventId: null, // optional, locks to a specific event id
pinnedSnapshot: null, // optional, see §6
fallbackPreview: null, // optional, see "persistence" below
lastResolvedAt: null, // optional, in-memory ms timestamp
lastResolvedSize: null, // optional, in-memory { w, h }
},
}
5.1 Persisted vs in-memory fields
The host event's content is what gets published to relays, so every
field there counts toward the per-event size budget. To keep linked
layers small:
- Persisted (serialized to host event):
kind,ref,mode,pinnedEventId,pinnedSnapshot(only when pinning, see §6). - In-memory only (stripped at publish time):
lastResolvedAt,lastResolvedSize,fallbackPreview. These are populated by the resolver after a successful fetch and used for runtime UI (timestamp display, layout hints, offline fallback) but never written into the serialized layer when the host publishes.
A pre-publish hook in _publishPremiumDesign and the regular publish
path strips the in-memory fields from design before signing. A linked
layer's serialized footprint is then roughly:
{kind:'linked', ref:'30078:<pubkey>:<dTag>', mode:'cover', pinnedEventId:null}
— ~110 bytes including overhead. 50 linked layers = ~5.5 KB, well under
any relay's content cap.
fallbackPreview is the one exception that's optionally persisted —
the user can tick "Cache a fallback preview" on the link-options panel
to embed a small JPEG (subject to the existing premium-preview 50 KB
cap) that survives source deletion. Off by default.
Embedded design layer (unchanged from today, made explicit for contrast):
{
type: 'design',
design: {
kind: 'embedded', // implicit / absent on legacy layers
mode: 'cover',
payload: { /* full design JSON */ },
},
...
}
Legacy layers without design.kind are treated as embedded for
backwards compatibility.
Top-level host-event tags (added at publish time for linked-design attribution):
['p', '<contributor pubkey>', '<relay-hint>', 'design-contributor']
['p', '<contributor pubkey>', '<relay-hint>', 'design-contributor']
...
One p tag per distinct contributing pubkey discovered by walking
the host's linked layers (and their linked layers, up to depth 3).
The fourth element marks the tag as design-contributor specifically
so it doesn't collide with p tags emitted for replies / mentions
in other Nostr contexts.
6. Versioning Model
PPTX-style "always latest" is the default. Pinning is the advanced opt-in.
- Default (always latest):
pinnedEventId === null. Renderer queries relays forkinds: [30078], '#d': [dTag], authors: [pubkey]and uses the most recent event. NIP-01 addressable semantics guarantee relays only retain the latest event per address, so this is essentially free. - Pinned to version: user toggles "Pin to this version" in the
layer options. The current resolved
eventIdis written topinnedEventId. Renderer queries byids: [<eventId>]instead of by address — only that exact event is rendered. Source-author edits don't propagate until the host author manually un-pins or re-pins to a newer event.
6.1 Pinned + source deletion
A subtle but important case: if the source author tombstones the pinned event (kind-5 delete), relays will discard it and the pinned event id won't resolve anywhere. The pin no longer protects against disappearance.
Two complementary mitigations:
- The pin-mode toggle also offers a checkbox "Embed snapshot for
permanence" that captures the resolved payload into
design.pinnedSnapshot(full design JSON). The renderer prefers the live fetched event, falls back topinnedSnapshotif the event is gone. This restores the old embed-style survivability at the cost of host-event size. - The
fallbackPreviewfield (§5.1) is the lower-cost option for visual-fidelity-only fallback: a small JPEG that renders when the source disappears but isn't editable.
Default for new pinned layers: pin without snapshot. The user explicitly opts in to snapshot for designs they want to outlive the source.
The toggle is visible on every linked-design layer's right-panel.
Switching states is non-destructive; flipping back to "always latest"
just nulls pinnedEventId.
UI surfaces:
- Layer-options panel: "Source: