Draft Linked Designs spec

Spec for the next big arc: reference-based cross-design imports.
Adds a `kind: 'linked'` flavor of the existing design layer that
stores only a NIP-01 addressable-event coordinate
(30078:<pubkey>:<dTag>) and resolves to the latest source on
render. Sits alongside the existing embedded import path; user
picks at import time.

Key decisions baked in (per scoping conversation):

- Versioning: always-latest by default, with an opt-in "Pin to
  this version" toggle exposed per linked layer.
- Nesting: up to 3 levels deep with cycle detection + visible
  depth-exceeded placeholders.
- Encrypted: Phase 1 ships public-only; Phase 2 adds premium
  (renders if viewer's unlocked) and private (share-list aware).
- Attribution chain is first-class — host publish emits one
  ['p', '<contributor pubkey>', '<relay-hint>', 'design-contributor']
  tag per distinct linked-tree creator, plus an in-app Credits
  panel surfacing co-creators. Lets larger collaborative projects
  carry honest attribution natively.

Architectural shape:

- Resolver IIFE (linkedDesigns) with session cache, multi-relay
  union (mirrors _vaultRelayUnion), and a manual Refresh button
  to bypass cache.
- renderDesignObjectIntoElement gets a linked branch that calls
  the resolver before the existing render path.
- Layer-options panel exposes pin / refresh / unlink / replace.
- "Publish + link" action converts an embedded layer to a freshly-
  published source + linked reference (Phase 3 polish).
- Convert-back-to-embed action snapshots the current resolution.

Phased implementation:

- Phase 1: Public-only linking, schema, resolver, rendering, UI.
- Phase 2: Premium + private link resolution with access control.
- Phase 3: Disk cache, attribution `p`-tag emission, Credits panel,
  Publish + link workflow.

Acceptance criterion for Phase 1: a public cover design linked into
a public slide deck renders correctly; editing the source updates
the host's render on next refresh. 10-link host event stays under
typical relay size limits.

Outstanding open questions to revisit during implementation:

- Whether the depth limit should be 3 forever or configurable.
- Whether the 256 KB per-source payload cap is the right number.
- Whether to surface "last resolved" times in the layer name itself
  or only on hover.
This commit is contained in:
thePR0M3TH3AN committed 2026-05-31 16:38:26 -04:00
1 parent 59ae0e32ec
commit 87dd9426c2
1 file changed
+516
+516
View File
@@ -0,0 +1,516 @@
# 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:
```js
{
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 for pre-fetch render
pinnedEventId: null, // optional, locks to a specific event id
fallbackPreview: 'data:image/jpeg;base64,...', // optional cached preview
lastResolvedAt: 1748656800000, // ms timestamp of last successful fetch
lastResolvedSize: { w: 1920, h: 1080 }, // hint for layout
},
}
```
Embedded design layer (unchanged from today, made explicit for
contrast):
```js
{
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.
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: <title> by <creator> · 🔗 latest"
with a "Pin to this version" checkbox.
- A "Last resolved <timestamp>" line so the user knows how fresh.
- A "Refresh" button that re-queries even when the in-session cache
is warm.
---
## 7. Caching Strategy
Per-session in-memory cache keyed by address (or by `eventId` when
pinned). Three layers:
1. **Session cache** — `Map<addressOrEventId, { payload, fetchedAt }>`.
First render fetches; subsequent renders within the same session
hit cache. TTL: 5 minutes. Closing the tab clears it.
2. **Disk cache** — None for Phase 1. If real-deck round-trips show
render lag, Phase 3 polish adds a small localStorage-backed cache
keyed by address + content-hash so a return visit doesn't re-fetch.
3. **Manual refresh** — A "Refresh" button on the layer options bypasses
the cache and re-queries unconditionally. Useful for the
collaborative case where Adam knows the source author just edited.
Cache invalidation strategy:
- TTL on session cache (5 min).
- "Refresh" button per-layer.
- Switching from latest → pinned (or vice versa) busts the entry.
- Editing the layer's `ref` busts the entry for the old ref.
Cache misses degrade gracefully:
- While fetching: render a loading placeholder ("⟳ Loading linked
design...").
- On fetch failure (no relay returned the event): render the
`fallbackPreview` data URL if present, else a "🔗 Linked design
not found — refresh to retry" placeholder.
---
## 8. Render Flow
```
host design opened
→ render() walks layers
→ for each layer where layer.design.kind === 'linked':
resolver.lookup(ref, pinnedEventId)
→ check session cache → hit: return payload
→ cache miss: queryRelays(union, filter)
→ success: cache + return
→ fail: return null + emit fallback
renderDesignObjectIntoElement(el, { design: { payload } })
```
Resolver lives in a `linkedDesigns` IIFE alongside the existing
`Premium` and purchase-vault helpers in `src/index.html`. Exposes:
- `resolveLinked({ ref, pinnedEventId })` → `Promise<payload | null>`
- `clearCache()` (used by the manual Refresh button)
- `walkContributors(payload, opts)` → `Set<pubkey>` (used by the
attribution-tagging path at publish time)
Renderer changes are minimal — `renderDesignObjectIntoElement`
already handles "render a payload as a compact preview." The new
path just fetches the payload async before calling it.
---
## 9. Nesting + Cycle Detection
Up to **three levels of linking** allowed. A links B links C is fine;
C linking back to A is rejected at resolve time.
The resolver tracks `visited = Set<addressOrEventId>` through a single
top-level render pass:
```js
async function resolveLinkedTree(layer, depth, visited) {
if (depth > MAX_LINK_DEPTH) return _depthLimitPlaceholder();
const key = layer.design.pinnedEventId || layer.design.ref;
if (visited.has(key)) return _cyclePlaceholder();
visited.add(key);
const payload = await resolveLinked(layer.design);
// walk payload.layers for nested linked layers, recurse
...
}
```
`MAX_LINK_DEPTH = 3` (host counts as 0). Caps fetch storms + UX
predictability.
Cycle and depth-exceeded placeholders show a small inline label so
the user knows why nothing rendered.
---
## 10. Attribution Chain
The collaborative-project pitch is the main reason the user wants
this feature, so attribution is first-class.
### 10.1 At-render attribution
The "Credits" panel (new, surfaced from the design's metadata
sidebar) lists every distinct creator who contributed via a linked
layer in the current design. Pulled from the resolved-tree walk; lazy,
populated as resolves complete.
Displayed as: `@alice (3 designs)`, `@bob (1 design)`, etc., with
each entry linking to the contributor's `/u/<npub>` profile page.
### 10.2 At-publish attribution
When the user publishes a host design that contains linked layers,
the publish pipeline:
1. Walks the host's layers + their linked-tree resolutions (up to
depth 3) to collect contributor pubkeys.
2. Adds one `['p', '<pubkey>', '<relay-hint>', 'design-contributor']`
tag per distinct contributor.
3. Emits a `['link', '<address>', '<relay-hint>']` tag per linked
reference so other indexers / readers can crawl the graph
without re-fetching the design content.
NIP-aware clients picking up the host event can surface "co-created
by" without parsing the design payload. Relays can index by `p` to
let users find designs they've contributed to.
### 10.3 Embedded → linked conversions
When the user runs "Publish + link" on an embedded layer (see §11.3),
the embedded layer's original `pubkey` is preserved as the new
source author. Attribution survives the conversion.
---
## 11. UX / UI
### 11.1 Import flow
Existing flow: layer's right-panel + Community modal already expose
"Browse community" and "Load from file." Add a third path:
- **"Link to community design"** — opens the community browser in
link-mode. Selecting a row creates a `kind: 'linked'` layer with
the source's address.
- Existing "Browse community" remains and continues creating
embedded layers (default).
A "Link this design" action also appears in each community-result
card's row menu, so the user can pick link vs. embed at any entry
point.
### 11.2 Visual distinction in the layer list
Linked layers show a 🔗 prefix in the layer list (e.g.
`🔗 Brand logo (alice's design)`). Hover tooltip:
`Linked to alice's design · latest version · refreshed 12s ago`.
### 11.3 Layer-options panel for linked designs
When a linked layer is selected, the right-side options panel shows:
- Source title + creator pubkey (clickable to profile)
- `🔗 latest` vs `📌 pinned` toggle
- "Refresh" button (forces re-fetch)
- "Replace link…" button (opens community browser to swap target)
- "Unlink to embed" button (snapshots the currently-resolved payload
into an embedded layer + drops the link)
- "Open source in new tab" link
Position / size / rotation / opacity stay editable like any layer.
Inner layers of the linked design are NOT editable — that's the
trade-off for source updates working.
### 11.4 "Publish + link"
A right-click action on any embedded design layer (or in the layer
menu): "Publish this layer as its own design and link to it."
Pipeline:
1. Extract the embedded payload into a fresh design.
2. Publish it as a normal kind-30078 from the current user.
3. Replace the host's embedded layer with a linked layer pointing at
the new address.
Lets a user organically refactor a self-contained sub-design into
a reusable linked asset.
---
## 12. Publishing Considerations
A host design with linked layers publishes as a normal kind-30078,
just with a smaller content payload (linked layers carry only the
ref, not the full sub-payload).
- Host content shape stays loadable by old viewers that don't know
about linked layers. Legacy clients render the layer's
`fallbackPreview` (when present) or a generic "Linked design" stub.
- Attribution tags (§10.2) are additive — clients that don't parse
them ignore them. NIP-01 compliant.
- The host stays Nostr-publishable at standard relay size limits even
when linking dozens of components.
### 12.1 Premium / private design boundary (Phase 2)
When a linked layer points at a premium design, the host viewer's
unlock state controls render:
- Unlocked → render the decrypted payload from the viewer's purchase
vault.
- Locked → render a placeholder ("🔒 Linked premium design — unlock
to render") with an unlock CTA inline.
For private designs (`payload.visibility === 'private'`), Phase 2
checks the share-list at fetch time; rendering only happens for the
source author or an explicitly-shared pubkey.
Phase 1 detects an encrypted source and renders the
"Linked design — currently unsupported (premium / private)"
placeholder.
---
## 13. Security
- Sanitize text content from linked payloads the same way as forks
today — `safeTextLinkHref` etc. — since linked content arrives
from an arbitrary third party.
- Reject layer types that don't pass the existing
`migrateLegacyLayerTargets` allowlist.
- Cap fetched payload size at 256 KB per linked event to avoid pulling
in adversarially huge sub-designs.
- Depth cap (§9) prevents fetch-storm DOS.
- Cycle detection (§9) prevents stack-blowing infinite loops.
- No external URL fetching introduced — only relay queries.
---
## 14. Implementation Plan
### Phase 1 — Public-only linking
- Schema additions (`design.kind`, `design.ref`, `design.pinnedEventId`,
`design.fallbackPreview`, `design.lastResolvedAt`).
- `linkedDesigns` resolver IIFE with session cache + multi-relay
union queries (mirror of `_vaultRelayUnion`).
- Render-time wiring in `renderDesignObjectIntoElement` for the
linked branch.
- Nesting + cycle detection.
- Layer-options panel: pin / refresh / unlink / replace.
- Community browser "Link this design" path.
- Loading + missing-source + cycle placeholders.
- Acceptance: a public cover design linked into a public slide deck
renders correctly; editing the source updates the host's render
next refresh.
### Phase 2 — Premium + private
- Premium link: check `findUnlockedEntry` for the source's address
before rendering; show unlock CTA inline when locked.
- Private link: respect `payload.visibility === 'private'` + the
shared-with list; render only for authorized viewers.
- Acceptance: a premium design linked into a host renders for buyers
who've unlocked it and shows an unlock CTA for those who haven't.
### Phase 3 — Polish
- localStorage-backed disk cache for return-visit performance.
- Attribution `p`-tag emission at host publish time.
- Credits panel UI in the host design's sidebar.
- "Publish + link" workflow (convert embedded → linked).
- Optional batching of the resolver's relay queries to reduce
round-trips when a host has many linked layers.
---
## 15. Risks
- **Source disappears.** If the source author tombstones their
design (kind-5 deletion) or all relays drop the event, the host
renders the fallback preview or a placeholder. Document this in
the link-creation UI: "Linked designs depend on the source staying
on relays."
- **Performance on large compositions.** A host with 50 linked
layers triggers 50 fetches on first render. Mitigation: in-session
cache + (Phase 3) disk cache + (Phase 3) batched queries.
- **Latency on first paint.** Each linked layer flashes a loading
placeholder before the resolved payload arrives. Mitigation:
parallel kick-off + per-layer fallback preview.
- **Embed / link confusion.** Two visually-similar import paths
with very different semantics. Mitigation: 🔗 prefix in layer
list, distinct icons in the import modal, clear copy in both
buttons.
- **Attribution griefing.** A malicious user could embed a target
pubkey via a linked layer to spuriously attribute work. Mitigation:
attribution `p` tag includes the `'design-contributor'` marker so
it's distinguishable from arbitrary mention tags, and the source
author can issue a kind-5 deletion of any host event that
falsely credits them.
---
## 16. Acceptance Criteria for Phase 1
- Linked layers render the latest version of their source on each
open of the host design.
- Pinned linked layers freeze at a specific event id and don't pick
up later edits.
- Up to 3 levels of nested linking works; depth-4 and cycles emit
visible placeholders without breaking the host.
- Refresh button forces a re-fetch.
- Missing-source events render either the cached `fallbackPreview`
or a clear "not found" placeholder.
- An embedded layer + its linked-twin layer render identically when
pointing at the same source.
- Layer panel shows 🔗 prefix + creator name.
- Host event published with linked layers stays under typical relay
size limits (verified with a 10-link example).
---
## 17. Future Enhancements (not in initial release)
- **Real-time updates** — subscribe to kind-30078 with the linked
address filter so source edits notify the host viewer instantly.
- **Relay subscription budgets** — coordinate fetches across hosts
that share a tab to avoid duplicate work.
- **Designer comments / pull-requests** — let a viewer suggest an
edit to a linked source via a kind-1 comment (out of scope, but
the link graph makes this navigable).
- **Author allowlists** — a host design optionally restricts which
authors' linked layers it'll render, for safety in adversarial
contexts.
- **Diff-aware fallback** — store last-N source previews so the host
can "fall back to last known good" when the new source is broken.