mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-10-27 08:40:39 +00:00
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.
This commit is contained in:
1 parent
87dd9426c2
commit
f8cf8570f2
1 file changed
+157
-17
+157
-17
@@ -110,15 +110,42 @@ Linked design layer:
|
|||||||
design: {
|
design: {
|
||||||
kind: 'linked', // disambiguates from embedded
|
kind: 'linked', // disambiguates from embedded
|
||||||
ref: '30078:<creatorPubkey>:<dTag>', // NIP-01 addressable coord
|
ref: '30078:<creatorPubkey>:<dTag>', // NIP-01 addressable coord
|
||||||
mode: 'cover', // hint for pre-fetch render
|
mode: 'cover', // hint: source-design template mode
|
||||||
pinnedEventId: null, // optional, locks to a specific event id
|
pinnedEventId: null, // optional, locks to a specific event id
|
||||||
fallbackPreview: 'data:image/jpeg;base64,...', // optional cached preview
|
pinnedSnapshot: null, // optional, see §6
|
||||||
lastResolvedAt: 1748656800000, // ms timestamp of last successful fetch
|
fallbackPreview: null, // optional, see "persistence" below
|
||||||
lastResolvedSize: { w: 1920, h: 1080 }, // hint for layout
|
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
|
Embedded design layer (unchanged from today, made explicit for
|
||||||
contrast):
|
contrast):
|
||||||
|
|
||||||
@@ -171,6 +198,29 @@ opt-in.
|
|||||||
don't propagate until the host author manually un-pins or re-pins
|
don't propagate until the host author manually un-pins or re-pins
|
||||||
to a newer event.
|
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.
|
The toggle is visible on every linked-design layer's right-panel.
|
||||||
Switching states is non-destructive; flipping back to "always latest"
|
Switching states is non-destructive; flipping back to "always latest"
|
||||||
just nulls `pinnedEventId`.
|
just nulls `pinnedEventId`.
|
||||||
@@ -231,6 +281,47 @@ host design opened
|
|||||||
renderDesignObjectIntoElement(el, { design: { payload } })
|
renderDesignObjectIntoElement(el, { design: { payload } })
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 8.1 Sync vs async render
|
||||||
|
|
||||||
|
`render()` and `renderDesignObjectIntoElement` are synchronous today.
|
||||||
|
A linked layer can't return its content synchronously on first encounter
|
||||||
|
(relay query is async). Three patterns considered:
|
||||||
|
|
||||||
|
1. **Block render** on resolver — bad UX, stalls every layer behind the
|
||||||
|
first network round-trip.
|
||||||
|
2. **Pre-fetch all linked layers** before any render — predictable
|
||||||
|
state but visibly delays first paint.
|
||||||
|
3. **Sync render with placeholder, async upgrade** — initial render
|
||||||
|
shows a "⟳ Loading…" or `fallbackPreview` placeholder, kicks off the
|
||||||
|
resolver, then mutates the rendered DOM node when the resolver
|
||||||
|
resolves. *This is the chosen pattern.*
|
||||||
|
|
||||||
|
Implementation shape:
|
||||||
|
|
||||||
|
- `renderDesignObjectIntoElement(el, layer)` synchronously renders the
|
||||||
|
placeholder + tags the DOM element with `data-linked-pending`.
|
||||||
|
- The resolver's promise resolution finds that DOM element by layer id
|
||||||
|
and calls `renderDesignPayloadPreview(el, payload, opts)` to replace
|
||||||
|
the placeholder. Self-aborts if the element left the DOM mid-flight
|
||||||
|
(covers the case where a re-render swaps it out).
|
||||||
|
- Cached resolutions (session cache hit) skip the placeholder phase:
|
||||||
|
the renderer reads from cache synchronously and renders the payload
|
||||||
|
immediately. So the placeholder only appears on the very first fetch
|
||||||
|
(or after a manual refresh).
|
||||||
|
|
||||||
|
Trade-off accepted: per-layer loading flicker on cold open. Mitigated
|
||||||
|
by parallel resolver kick-off across all linked layers + the optional
|
||||||
|
`fallbackPreview` for visually-stable cold loads.
|
||||||
|
|
||||||
|
### 8.2 Autosave + cold start
|
||||||
|
|
||||||
|
Artstr autosaves `state` to localStorage on every change. A linked
|
||||||
|
layer's in-memory fields (`lastResolvedAt`, `lastResolvedSize`,
|
||||||
|
`fallbackPreview` when not opted-in to persist) are lost on tab
|
||||||
|
reload. On `restoreAutosave`, the resolver kicks off in the background
|
||||||
|
for every linked layer so the editor's first paint shows placeholders
|
||||||
|
that fill in within a few hundred ms (cache + relay round-trip).
|
||||||
|
|
||||||
Resolver lives in a `linkedDesigns` IIFE alongside the existing
|
Resolver lives in a `linkedDesigns` IIFE alongside the existing
|
||||||
`Premium` and purchase-vault helpers in `src/index.html`. Exposes:
|
`Premium` and purchase-vault helpers in `src/index.html`. Exposes:
|
||||||
|
|
||||||
@@ -307,9 +398,22 @@ let users find designs they've contributed to.
|
|||||||
|
|
||||||
### 10.3 Embedded → linked conversions
|
### 10.3 Embedded → linked conversions
|
||||||
|
|
||||||
When the user runs "Publish + link" on an embedded layer (see §11.3),
|
When the user runs "Publish + link" on an embedded layer (see §11.4),
|
||||||
the embedded layer's original `pubkey` is preserved as the new
|
the embedded payload is published as a fresh kind-30078 under the
|
||||||
source author. Attribution survives the conversion.
|
**current user's** pubkey (Artstr can't sign on behalf of the original
|
||||||
|
creator). The replacement linked layer therefore credits the current
|
||||||
|
user, not whoever first authored the embedded content. Today's
|
||||||
|
embedded layers don't carry source-author metadata, so we have no
|
||||||
|
provenance to preserve forward.
|
||||||
|
|
||||||
|
In practice the conversion is useful for layers the *current user*
|
||||||
|
authored manually but later wants to extract as reusable. For
|
||||||
|
content originating elsewhere, the conversion will credit the wrong
|
||||||
|
person — the action should warn explicitly in copy ("This publishes
|
||||||
|
the layer's contents under your Nostr identity. Don't use this for
|
||||||
|
designs you didn't make."). A future enhancement could capture the
|
||||||
|
original author at embed time so the chain survives, but that's out
|
||||||
|
of scope for this arc.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -364,7 +468,26 @@ Pipeline:
|
|||||||
the new address.
|
the new address.
|
||||||
|
|
||||||
Lets a user organically refactor a self-contained sub-design into
|
Lets a user organically refactor a self-contained sub-design into
|
||||||
a reusable linked asset.
|
a reusable linked asset. Important attribution caveat in §10.3 —
|
||||||
|
the published copy goes under the current user's pubkey, so this
|
||||||
|
should not be used for layers that originated from a third party.
|
||||||
|
|
||||||
|
### 11.5 Picking initial layer dimensions
|
||||||
|
|
||||||
|
`addDesignLayerFromPayload` today reads the embedded payload's aspect
|
||||||
|
ratio to choose initial layer w/h. A linked layer has no payload yet
|
||||||
|
when it's created (resolve is async).
|
||||||
|
|
||||||
|
Solution: the layer-add path runs a synchronous resolver lookup first
|
||||||
|
(blocks UI ~< 1 s on cold relays). If resolution succeeds, the
|
||||||
|
initial layer uses the source aspect like an embed would. If
|
||||||
|
resolution fails or times out (1.5 s), the layer is added at a
|
||||||
|
default 1920×1080 (16:9) aspect, with a toast suggesting the user
|
||||||
|
"Refresh the layer when the source is available" — they can resize
|
||||||
|
manually afterward.
|
||||||
|
|
||||||
|
The link-creation modal also previews the source design at choose
|
||||||
|
time so the user knows what they're linking before commit.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -385,12 +508,18 @@ ref, not the full sub-payload).
|
|||||||
### 12.1 Premium / private design boundary (Phase 2)
|
### 12.1 Premium / private design boundary (Phase 2)
|
||||||
|
|
||||||
When a linked layer points at a premium design, the host viewer's
|
When a linked layer points at a premium design, the host viewer's
|
||||||
unlock state controls render:
|
**purchase-vault entry** controls render — NOT the soft-gate
|
||||||
|
decryption itself, since the soft-gate KDF is deterministic from
|
||||||
|
public material and any browser could technically decrypt without
|
||||||
|
payment (§ PREMIUM_DESIGNS_FEATURE.md). The access check is
|
||||||
|
"does `findUnlockedEntry(eventId, address)` find an entry for the
|
||||||
|
linked source?":
|
||||||
|
|
||||||
- Unlocked → render the decrypted payload from the viewer's purchase
|
- Vault entry present → render the decrypted payload (already in the
|
||||||
vault.
|
cache from the vault).
|
||||||
- Locked → render a placeholder ("🔒 Linked premium design — unlock
|
- No vault entry → render a placeholder ("🔒 Linked premium design —
|
||||||
to render") with an unlock CTA inline.
|
unlock to render") with an inline unlock CTA that runs the normal
|
||||||
|
premium-unlock flow against the linked source's address.
|
||||||
|
|
||||||
For private designs (`payload.visibility === 'private'`), Phase 2
|
For private designs (`payload.visibility === 'private'`), Phase 2
|
||||||
checks the share-list at fetch time; rendering only happens for the
|
checks the share-list at fetch time; rendering only happens for the
|
||||||
@@ -409,8 +538,14 @@ placeholder.
|
|||||||
from an arbitrary third party.
|
from an arbitrary third party.
|
||||||
- Reject layer types that don't pass the existing
|
- Reject layer types that don't pass the existing
|
||||||
`migrateLegacyLayerTargets` allowlist.
|
`migrateLegacyLayerTargets` allowlist.
|
||||||
- Cap fetched payload size at 256 KB per linked event to avoid pulling
|
- **Per-event cap**: cap fetched payload size at 256 KB per linked
|
||||||
in adversarially huge sub-designs.
|
event to avoid pulling in adversarially huge sub-designs.
|
||||||
|
- **Per-tree cap**: cap *total* resolved payload bytes for a single
|
||||||
|
host render at 1 MB. Depth-3 fan-out can theoretically multiply
|
||||||
|
fetches (one root linking to many designs each linking to many);
|
||||||
|
the tree cap stops a coordinated DOS at the host level. When the
|
||||||
|
cap is hit, subsequent linked layers in the same render render the
|
||||||
|
fallback placeholder and emit a console warning.
|
||||||
- Depth cap (§9) prevents fetch-storm DOS.
|
- Depth cap (§9) prevents fetch-storm DOS.
|
||||||
- Cycle detection (§9) prevents stack-blowing infinite loops.
|
- Cycle detection (§9) prevents stack-blowing infinite loops.
|
||||||
- No external URL fetching introduced — only relay queries.
|
- No external URL fetching introduced — only relay queries.
|
||||||
@@ -495,8 +630,13 @@ placeholder.
|
|||||||
- An embedded layer + its linked-twin layer render identically when
|
- An embedded layer + its linked-twin layer render identically when
|
||||||
pointing at the same source.
|
pointing at the same source.
|
||||||
- Layer panel shows 🔗 prefix + creator name.
|
- Layer panel shows 🔗 prefix + creator name.
|
||||||
- Host event published with linked layers stays under typical relay
|
- A host event with 10 linked layers serializes to under 5 KB of
|
||||||
size limits (verified with a 10-link example).
|
layer overhead (link refs ~ 110 bytes each) and publishes without
|
||||||
|
hitting any of the common relay caps (Damus / nos.lol / Primal /
|
||||||
|
Snort / nostr.band — all accept ≥ 64 KB content).
|
||||||
|
- In-memory fields (`lastResolvedAt`, `lastResolvedSize`,
|
||||||
|
non-opted-in `fallbackPreview`) are stripped from the serialized
|
||||||
|
layer at publish time so they never inflate the host event.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user