Files
Artstr-Studio/docs/LINKED_DESIGNS_FEATURE.md
T
thePR0M3TH3AN f8cf8570f2 Linked Designs spec: tighten before implementation
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.
2026-05-31 16:38:26 -04:00

26 KiB
Raw Blame History

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:

  1. 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.
  2. 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 p tag 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 and renderDesignObjectIntoElement renderer already accept any payload and render via the existing renderDesignPayloadPreview compact-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 for kinds: [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 eventId is written to pinnedEventId. Renderer queries by ids: [<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:

  1. 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 to pinnedSnapshot if the event is gone. This restores the old embed-style survivability at the cost of host-event size.
  2. The fallbackPreview field (§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: