Commit Graph
264 Commits
Author SHA1 Message Date
thePR0M3TH3AN 2fc555a4ff pen tool: live draft stroke width matches the panel value
The pen draft path was using a canvas-relative thickness (lineW * 2,
derived from the canvas dimensions) for its preview stroke, so the
ribbon you saw while drawing looked different from the path you got
after committing. The committed path stores its stroke width in
pixel-space (1 unit = 1 px @ 96 dpi); the overlay renders in inch
space, so the live preview now divides _penToolOpts.penStrokeWidth
by 96 — same trick the pencil draft already uses — and adds round
line-cap / line-join so curves look consistent with the committed
path.
2026-06-02 09:40:59 -04:00
thePR0M3TH3AN cd54d8357f marquee box-select on canvas with the Select tool
Click-and-drag on an empty area of the canvas (with the V tool
active) now draws a marching-ants rectangle. On release, every
layer whose bounding box intersects the rectangle is selected;
Shift / Ctrl / Cmd held during the drag adds to the existing
selection instead of replacing it.

Plain clicks (no drag past the 4 px deadzone) still clear the
selection, preserving the previous "click empty area to deselect"
behaviour. Other tools own their own canvas drags (Pen creates
anchors, Hand pans, Shape drags out a primitive, Direct Select
marquees over anchors), so this only kicks in for V.

Shift+click on a layer to add or remove it from the current
selection was already wired — confirmed working through the
existing additive path in startLayerDrag.
2026-06-02 09:38:52 -04:00
thePR0M3TH3AN eb9aec19d8 pathfinder: Unite / Subtract / Intersect / Exclude on shape selection
Boolean ops on 2+ selected shape layers, backed by the vendored
polygon-clipping library (29 KB minified, MIT) loaded from
src/vendor/. Adds a new "Pathfinder" details block in the sidebar
under Align & distribute.

- Unite combines all selected shapes into one outline.
- Subtract removes upper shapes from the bottom-most shape.
- Intersect keeps only the overlapping region.
- Exclude keeps only the non-overlapping regions (XOR).

Pipeline: each selected layer's pen path is sampled to a polygon
(16 samples per Bézier segment, in world inches), the chosen op
runs, and the result is rebuilt as a new pen layer that inherits
the bottom-most input layer's z-order, target, fill and stroke —
matching Illustrator's Pathfinder convention. Non-pen primitives
(rect, circle, etc.) are auto-converted to pen paths first.

Limitations baked in:
- Rotated layers aren't supported yet (we'd need to rotate the
  sampled polygon before clipping). The op refuses with a toast.
- Curves are flattened — the result is a polygon approximation
  with `fill-rule: evenodd` so multi-ring outputs (donut from a
  subtraction) render correctly. The renderer learned to honor
  shape.fillRule for this commit.
- Multi-polygon results stay rendered correctly but only the
  first polygon's outer ring is preserved in pen.nodes, so
  Direct Select editing post-op is partial.
2026-06-02 08:54:26 -04:00
thePR0M3TH3AN 9bb401bea2 free transform: rotation handle on the selection bbox
A 9th handle (round, accent-colored, sitting above the bounding box
on a thin connector line) joins the existing 8 scale handles on both
the single-layer overlay and the multi-layer selection box.

- Single layer: drag rotates the layer about its own centre, with
  Shift snapping to 15° increments. The existing resize handles
  already account for layer.rotate in their local-frame math, so
  resizing keeps working at any rotation.
- Multi-layer selection: drag rotates each layer's centre around the
  selection bbox's centre AND advances each layer's own rotation by
  the same delta — the group spins as a rigid body. Pre-snapshotting
  the centres + rotations keeps the math stable across long drags.
- Locked / generated-disc layers are filtered out as everywhere else.
2026-06-02 08:45:51 -04:00
thePR0M3TH3AN ce1023a14e align + distribute panel for selected layers
A new "Align & distribute" details block in the layer-controls sidebar
surfaces eight ops: left / horizontal-centre / right and top / middle
/ bottom alignment, plus horizontal and vertical distribution.

- Multi-selection (2+ layers) aligns within the selection's own
  bounding box, so the outer layers stay put and the rest snap into
  line with them.
- Single-layer selection aligns to the layer's target bounds (page
  panel / disc face / custom canvas), making it easy to centre or
  pin a single asset without setting up a dummy second layer.
- Distribute requires three or more layers — the outermost two stay
  pinned and the inner layers slide so consecutive centres are
  evenly spaced along the chosen axis. Helpers toast a hint when the
  selection is too small.
- Locked / generated-disc layers are skipped automatically.

Flip H / V were prototyped here but pulled out — they need a renderer
extension to honour per-layer scaleX/scaleY, which is a separate change.
2026-06-02 08:43:39 -04:00
thePR0M3TH3AN cb130b82cd eyedropper tool (I) — sample colors via the native picker
A new ⊙ button in the tool palette and the I keyboard shortcut open
the browser's window.EyeDropper picker so the user can sample any
pixel anywhere on screen (not just our canvas) and apply the
sampled hex to a smart target:

- Selected shape layer → fill color (Shift = stroke color).
- Selected text layer  → text color.
- No layer selected    → pen / shape tool's default fill (or
  stroke), so the next thing drawn picks up the sampled hue.

The eyedropper is a transient action rather than a persistent tool —
invoking it doesn't change the active tool, so users can sample
mid-edit without losing their cursor. Falls back to a hint toast in
browsers that don't yet support window.EyeDropper (Safari).
2026-06-02 08:41:06 -04:00
thePR0M3TH3AN 0cb028c3f1 pen tool: side-panel "Path actions" block for anchor + path ops
A new "Path actions" block appears in the right-side tool options panel
whenever Pen (P) or Direct Selection (A) is active and an editable pen
layer is selected. The block surfaces the most useful anchor- / path-
level operations as buttons so they're discoverable without keyboard
shortcuts:

- Pointed corner / Curved smooth — flip the selected anchors (or all,
  if none are selected) between handle-less corner and symmetric-handle
  smooth, matching the Alt-click convert behavior.
- Delete anchor — bulk-delete the current anchor selection (Backspace
  equivalent).
- Average — move selected anchors to their centroid (Cmd+Alt+J).
- Join endpoints — close an open path when both its endpoints are
  selected (Cmd+J); button auto-disables otherwise.
- Close / Open path — toggle the path's closed flag.
- Reverse direction — flip the order anchors are drawn in.

A small hint at the top of the block reflects the current selection
size, so the user knows what an action will affect. The "Join" button
auto-disables when join would be a no-op; "Average" requires at least
two anchors. Direct Selection also gets a short panel reminding the
user how anchor editing works (multi-select via Shift / marquee, arrow
keys nudge selected anchors).
2026-06-01 21:46:45 -04:00
thePR0M3TH3AN d50284ecef pen tool: Alt-click anchor now truly converts curved ↔ pointed
The presence of handles is now the source of truth for whether a
node is pointed or curved, so Alt-click does a full flip in either
direction:

- Anchor with any handle → drops both handles and demotes to a real
  corner. The path renders straight segments through it.
- Anchor with no handles → grows symmetric Bézier handles aimed along
  the chord through this anchor (using neighbouring anchors when they
  exist, falling back to the single available neighbour for endpoints
  of an open path). Handle length is 1/3 of the chord, so the curve
  bends visibly without ballooning out.

Previously the toggle only flipped the kind flag without touching
handles, so a smooth-to-corner click left the curve looking unchanged
and a corner-with-no-handles click did nothing visible at all.
2026-06-01 21:42:48 -04:00
thePR0M3TH3AN 62f2a7592e pen tool: Join (Cmd+J) + Average (Cmd+Alt+J)
- Cmd+J closes an open path when both its endpoints are part of the
  current anchor selection. No-op if the path is already closed or
  the selection doesn't cover both endpoints; the user gets a hint
  toast in that case.
- Cmd+Alt+J averages every selected anchor to the centroid of the
  selection. Handles slide with their anchors so local curvature is
  preserved. Requires at least two anchors selected.
2026-06-01 21:37:11 -04:00
thePR0M3TH3AN c3c58e67f5 pen tool: Spacebar reposition + Alt mid-draw asymmetric (Illustrator parity)
While the pen tool's click-drag is still down (the gesture that pulls
Bézier handles out of a freshly-placed anchor), two modifiers now
match Illustrator's pen behavior:

- Hold Alt during the drag and only the outgoing handle is pulled.
  The incoming side stays at the anchor, giving an asymmetric corner
  that's perfect for tangent-then-corner transitions without lifting
  the pen.
- Hold Space and the drag stops growing handles and starts sliding
  the anchor itself, carrying any handles along. Release Space to
  resume handle-pulling from the anchor's new position. Mouse delta
  is tracked from the moment each mode is entered, so toggling Space
  on/off mid-drag doesn't introduce drift.
2026-06-01 21:36:05 -04:00
thePR0M3TH3AN 0fc45ae9fc pen tool: auto-add / auto-delete + convert + handle-break (Illustrator parity)
- Pen tool over an existing segment now auto-inserts an anchor at the
  click with no modifier (was Alt-only). Pen over an anchor deletes it
  on click — Illustrator's default "Auto Add/Delete Anchor" behavior.
- Drag (past a small threshold) on an existing anchor with the Pen tool
  converts the anchor on the fly: pulls symmetric handles from the
  drag, promoting corner → smooth. Alt-drag pulls only the outgoing
  handle for an asymmetric corner.
- Alt-click an anchor in Pen mode still toggles corner ↔ smooth (drops
  or pairs handles without a drag), preserving the existing modifier.
- Alt-drag a handle in Direct Selection breaks the smooth-anchor's
  handle symmetry: the node becomes a corner so the opposite handle
  stays put while the dragged one moves independently.
- Endpoint-of-open-path still wins over auto-delete: hovering the start
  or last anchor of an open path shows the "continue" pointer cue, and
  clicking resumes drawing rather than deleting.
- Hover cursors updated: − (delete) over a deletable anchor, + (add)
  over a segment in Pen mode, grab over a segment in Direct Select.
2026-06-01 21:35:21 -04:00
thePR0M3TH3AN 1d64306a33 pen tool: continue an open path from its endpoint (Illustrator parity)
With the Pen tool, clicking an endpoint of an open path resumes
drawing from that anchor. The source path is hidden for the duration
of the draft; on commit (Enter / click-back-to-start / Escape) the
old layer is dropped and the new (longer) path takes its place,
preserving the original z-order, target, fill and stroke. Clicking
the start anchor reverses the node order so the next click extends
in the natural direction. Cancelling the draft restores the source
path unchanged.
2026-06-01 21:29:03 -04:00
thePR0M3TH3AN 547ff5d1c6 pen tool: Illustrator parity polish (multi-anchor + drag-segment + marquee)
- Multi-anchor selection. Shift-click an anchor to add/remove it from
  the selection set; click without modifier selects just that anchor.
  Dragging any selected anchor moves the whole set rigidly (anchors +
  handles), so reshape operations affect groups in one gesture.
- Arrow keys nudge selected anchors. Direct Selection (or Pen) with at
  least one anchor in the selection: ←/→/↑/↓ moves the anchors and
  their handles by 1px (10px with Shift) in pixel-space; bbox refits
  automatically. Falls back to the existing layer-nudge when no anchor
  is selected.
- Marquee anchor box-select. In Direct Selection, drag from empty
  space inside the layer to draw a dashed rectangle; on release, every
  anchor whose center lies inside the box is selected. Shift+drag adds
  to the existing selection.
- Drag a curve segment to bow it. Equal-split delta distributed
  across the two inner control points so the cursor tracks the grabbed
  point; smooth anchors on either end keep their handles mirrored. Lines
  are auto-promoted to cubics on first bow.
- Alt-click a segment in Direct Selection now inserts an anchor at the
  click (mirrors the Pen tool's segment-insert), and Alt-click an
  anchor still toggles corner/smooth.
- Backspace deletes every selected anchor at once (bulk delete), with
  the "path needs at least two anchors" guard applied to the resulting
  size.
2026-06-01 21:24:24 -04:00
thePR0M3TH3AN c73aab2c20 pen tool: Illustrator parity foundation (Direct Select + cursor + Shift constrain)
- New Direct Selection tool (A) as a separate palette button, distinct
  from the regular Select tool. Anchors and handles only appear when
  Pen or Direct Select is the effective tool, matching Illustrator's
  V/A split.
- Cmd/Ctrl temporary toggle: holding the modifier from any non-Pen tool
  flips the effective tool to Direct Selection so a quick anchor tweak
  doesn't require dropping the current tool; release restores it.
- Hover cursor feedback on pen/direct-select: shows + when over the
  end of an open path, - over an existing anchor (delete-adjacent),
  ✕ near the start to close, default crosshair otherwise.
- Shift constrains pen segments to 0/45/90 degrees while drawing and
  while previewing the next click, matching Illustrator's constrain.

Foundation for the multi-anchor select + drag-segment + continue-path
endpoint work that follows.
2026-06-01 21:19:26 -04:00
thePR0M3TH3AN 8ca7a2468a Pen tool: select-mode anchor editing
Selecting a pen-shape layer with the Select tool now reveals its
anchors + handles for inline editing — no need to switch to the
Pen tool first. Drag anchors / handles to reshape; right-click
or Backspace to delete; Alt-click toggles corner ↔ smooth. Pen
mode keeps the richer affordances (segment-click to insert
anchor, click-to-create-new-path) since those would conflict with
the Select tool's normal layer-click behavior.

Three small changes:

- getEditablePenLayer drops the strict 'pen' tool requirement and
  also accepts 'select'.
- syncPenOverlay keeps the overlay alive in select mode when a
  pen-shape layer is selected; render() now re-syncs each frame
  so the overlay appears / disappears as selection changes
  (without requiring a tool change).
- renderPenEditHandles marks anchor + handle elements
  pointer-events: all (with `cursor: pointer`) while the overlay
  base stays pointer-events: none. Empty-space clicks fall
  through to the layer's normal selection behavior; only clicks
  on anchors / handles register as edit ops.

onPenPointerDown gains a select-mode branch that:
- Only handles anchor / handle hits; segment clicks pass through
  to the layer (so dragging the path body still selects / moves
  the layer as expected).
- preventDefault + stopPropagation on anchor / handle hits so
  the underlying layer-drag handler doesn't also fire.
2026-06-01 21:08:06 -04:00
thePR0M3TH3AN 9c638c0676 Pen tool: pixel-space viewBox so stroke widths match across shapes
Two pen paths with the same stored stroke number rendered at very
different on-screen thicknesses — strokes scaled with layer size
because nodes lived in a 0-100 normalized viewBox. The pencil tool
solved this exact problem (see commitPencilStroke ~line 9580 with
the inline comment about pixel-space viewBox = "true on-screen
thickness, identical no matter how long the stroke is"). Switching
pen paths to the same scheme.

Pen nodes now live in a pixel-space viewBox where 1 unit = 1 px
at 96 dpi: viewBox dims = (layer.w * 96, layer.h * 96). Because
the viewBox aspect matches the layer aspect, the renderer's
sx == sy (uniform scale) AND the same stroke-width number produces
the same device-pixel thickness on every pen path regardless of
size.

Five touchpoints updated:

- finishPenPath: node remap multiplies bbox-relative coords by 96
  instead of normalizing to 0-100; viewBox.w / viewBox.h written
  as bbox.w*96 / bbox.h*96.
- penNodeToInch / inchToPenNode: convert via /96 (was /100 of
  layer dim).
- regenPenLayerD: viewBox rewritten as (layer.w*96, layer.h*96)
  on every edit. Catches user-resizes via corner handles too.
- refitPenLayerIfNeeded: bounds checked against pixel-space
  viewBox; layer position/size translated via /96; node remap is
  now a pure translation (no rescale, since the units stay
  pixel-space).
- "Make editable path" conversion: 0-100 primitive nodes mapped
  to pixel-space relative to the existing layer's dimensions.

Strokes are now consistent across pen paths of any size, and the
in-progress draft (rendered in the pen overlay's inch-space) still
matches the final committed path's visual stroke weight at the
canvas's natural 96-DPI mapping.
2026-06-01 17:06:57 -04:00
thePR0M3TH3AN 3831b39b2e Pen tool: write viewBox.h to match layer aspect for uniform strokes
The previous "uniform-scale remap" commit fixed node coordinates to
land in 0-100 × 0-(100*h/w), but the renderer (buildShapeSvgInner
path branch) reads shape.viewBox and computes sx = 100/vb.w,
sy = vbH/vb.h. With shape.viewBox.h hardcoded to 100, sx ≠ sy for
non-square layers and the path got stretched again — strokes
distorted around curves after the path was committed, even though
the in-progress draft (which renders inside the pen overlay, not
the layer SVG) looked fine.

Fix is the matching half: also write shape.viewBox.h = 100*lh/lw
wherever we touch the pen data, so sx == sy and the renderer
applies a uniform scale.

- finishPenPath now sets viewBox.h = vbH from the bbox aspect.
- regenPenLayerD (called on every edit drag + insert/delete/toggle)
  recomputes vbH from current layer.w/h before writing the new d
  string. This catches the rare case where layer.w/h change between
  refit calls without my refit running.
- The "Make editable path" conversion remaps primitiveToPenNodes /
  parsePathDToNodes results (which always come in 0-100×0-100) into
  the layer's actual aspect space, and writes viewBox.h to match.
  Result: converting a rect → editable path on a non-square layer
  keeps its proportions, and the resulting pen path edits cleanly
  with uniform strokes.

Strokes now stay a constant width all the way around closed
shapes, even after the path is fully committed and re-rendered
through the standard shape-layer pipeline.
2026-06-01 16:46:58 -04:00
thePR0M3TH3AN e5c06b7415 Pen tool: uniform-scale remap (fixes stroke distortion) + smaller handles
Two visual issues on top of the Phase D close-out commit:

1. **Stroke width varied along curves.** The pre-existing pipeline
   stored nodes in 0-100 × 0-100 viewBox space and let the SVG
   renderer pick `vbH = 100 * layer.h / layer.w` with
   `preserveAspectRatio="none"`. For any non-square layer, the
   0-100×0-100 node range got stretched non-uniformly into a 100×vbH
   viewBox, distorting the path: strokes appeared wider where the
   curve traveled more in one axis. Phase D's refit made this much
   more likely because dragging anchors out of bounds routinely
   produced non-square layers.

   Fix in two places:
   - finishPenPath: uniform-scale the bbox by `100/bbox.w` so y
     range lands at exactly `100 * h/w = vbH`. The viewBox aspect
     matches the layer aspect, no stretching.
   - refitPenLayerIfNeeded: same uniform-scale invariant. Also
     respects the layer's current vbH when deciding whether nodes
     are "out of bounds" — was previously hard-coded at 100, which
     wrongly fired refit on every drag in already-non-square layers.

2. **Anchor + handle glyphs were too prominent.** On a 1920px-wide
   slide preview the old `dotR = 0.013 * unit` rendered ~25px
   dots. Reduced to `0.0075 * unit` (~12px) and stroke from
   `0.003 * unit` to `0.0018 * unit`. The in-progress draft
   renderer matches.

Strokes now stay constant width all the way around a curve, and
the editing UI reads as overlay rather than as part of the artwork.
2026-06-01 16:36:19 -04:00
thePR0M3TH3AN b825181356 Pen tool Phase D close-out: add/delete anchor, smooth/corner, bbox refit
Finished the deferred Phase D pieces from PEN_TOOL_FEATURE.md.

New affordances on the editable pen layer:

- Click a segment between anchors → inserts a new anchor at the
  click point via de Casteljau cubic split. Curve shape is
  preserved exactly: a's hOut, b's hIn, and the new mid-anchor's
  handles all derive from the split. Lines (no handles either
  side) get a plain midpoint anchor with no handles. The new
  anchor is selected and immediately drag-able.
- Right-click an anchor → delete it. Suppresses the browser
  context menu on the pen overlay always.
- Backspace / Delete with an anchor selected → delete it.
  Layer-delete still wins when no anchor is selected. The pen
  tool's selected-anchor sentinel `_penSelectedAnchor` is reset
  on tool change.
- Alt-click an anchor → toggle between `kind: 'corner'` (handles
  move independently) and `kind: 'smooth'` (handles stay mirrored
  across the anchor). Promoting to smooth re-derives the missing
  handle (or re-mirrors both if independent) so the curve
  actually becomes continuous. New nodes from pen click-drag
  default to smooth; new nodes from a single click are corners.
- Drag an anchor or handle past the original 0–100 viewBox →
  the layer's bounding rect auto-refits. New `refitPenLayerIfNeeded`
  computes the bbox over all nodes + handles, translates
  layer.x/y by the bbox offset (scaled to current w/h), updates
  layer.w/h, then remaps every node into the new 0–100 space.
  Path stays visually anchored to the canvas — no more clipping
  at the layer edge.

Anchor glyphs now distinguish kind: smooth = circle (existing
look), corner = square. The selected anchor renders with an amber
stroke (#f97316) so the Backspace-to-delete target is unmistakable.

Spec updated to mark Phase D complete and the deferred items
shipped. Rotated paths are still not node-editable in v1 (the
deferral remains).
2026-06-01 16:25:38 -04:00
thePR0M3TH3AN 9fe574c41d External embed Phase 5: pixel-art play/pause + fps + reduced-motion
Animated pixel-art embeds (frames > 1) now respect three controls
per docs/EXTERNAL_EMBED_FEATURE.md §6.3 / §11.4:

- ?play=1 / ?play=0 — explicit auto-play toggle. If the param
  isn't set, autoplay follows the visitor's prefers-reduced-motion
  preference: motion-OK users get auto-play, reduced-motion users
  get start-paused with a play CTA. ?play=1 forces auto-play even
  in reduced-motion mode (the spec's "user opt-in" wording).
- ?fps=N — override frame rate, clamped to 1-60. Falls back to the
  payload's stored fps (default 8) when not set.
- A play/pause chip in the embed footer's left side that toggles
  on click. Re-renders the artwork with the new autoplay state;
  the renderer's RAF self-stops when the canvas leaves the DOM, so
  the swap is leak-free.

Renderer changes (renderPixelArtPreviewDOM):
- New opts.autoplay (default true) gates the requestAnimationFrame.
  When false, frame 0 is drawn but no animation loop starts.
- New opts.fps (number, optional) overrides pa.fps. Defaults
  unchanged for all existing callers (linked-design renderer,
  feed cards, modal preview) because they don't pass either opt.

Single-frame pixel art is unaffected — the play/pause chip only
appears for frames.length > 1, and the renderer ignores autoplay
for the still-image path.
2026-06-01 15:28:09 -04:00
thePR0M3TH3AN d29afaea8c External embed Phase 4: deck player with keyboard + fullscreen
Public multi-slide deck embeds get a paginated viewer instead of
the single-thumbnail-with-badge that was hitting before (the
shared renderDeckPreviewDOM compact path).

_paintEmbed now branches on mode === 'deck' && slides > 1 into a
new _paintEmbedDeck that:

- Reads ?slide=N (1-based) from the route params for the initial
  index; clamps to [0, slides.length-1].
- Renders the current slide via composeSlideForDeck +
  renderDesignPayloadPreview into a .designObjectFitWrap and
  fitMiniPreview-scales it.
- Adds prev / counter / next pill chips to the footer left side
  (only when slides > 1 and controls=1), and the existing author
  chip after them.
- Adds a fullscreen chip ⛶ + the Open-in-Artstr link to the
  footer right side.
- Wires keyboard nav on document.keydown:
    ArrowRight / PageDown / Space → next
    ArrowLeft  / PageUp           → prev
    Home → first; End → last
    F → toggle fullscreen
  Single handler is swapped via the _paintEmbedDeck._kbd sentinel
  so re-renders (e.g., the refresh-error path) don't stack
  listeners. Form-element targets (none today, future-proof) are
  ignored so the embed doesn't eat typing.
- New _toggleEmbedFullscreen helper calls
  documentElement.requestFullscreen() / exitFullscreen with a
  graceful catch.

Touch swipe, autoplay (?autoplay / ?loop / ?interval), and
prefers-reduced-motion respect are deferred — they fit naturally
on top of this scaffold whenever we want them.
2026-06-01 15:25:07 -04:00
thePR0M3TH3AN 425e734238 Template browser: drop redundant Preview button from cards
Both card builders (buildFeedCard + buildResultCard) had a
dedicated "Preview" button on the action row that just called
openDesignPreview(row). The whole card already has a click
handler doing exactly that, so the button was redundant — it
just added visual noise next to the Use/Fork (or premium-unlock)
CTA.

Removed the prevBtn from both builders. Existing card-click
behavior unchanged, so the preview still opens on click; the
action row now has only the reactions + the action button(s)
specific to that card state.
2026-06-01 15:20:15 -04:00
thePR0M3TH3AN 9673d7d39f External embed: subtle transparency checker in preview pane
Swapped the embed code generator's live-preview backdrop from
the dev-time bright magenta/yellow checker to the standard
Photoshop-style light-gray/white transparency indicator
(#e5e7eb / #ffffff, 14×14 tiles). Reads as "this is where
transparency shows through" without screaming.
2026-06-01 10:55:36 -04:00
thePR0M3TH3AN af39a4ec71 External embed: code generator uses ?embed= URL (universal)
The generator was emitting /embed/<id> URLs (the canonical clean
path from the spec). Vercel rewrites that to / in production —
but on hosts without SPA rewrites (including local dev with
python http.server, and many static hosts users will paste into
WITHOUT Vercel-style routing), it 404s. The Embed modal's live
preview showed a "404 — File not found" error on localhost.

Switched to the ?embed=<id> query form. Universal: works on any
host that serves the index.html at /, no rewrite dependency.
The published Vercel deploy supports both forms via the existing
rewrites, so the change is backward-compatible for production
consumers.

If anyone wants the clean /embed/<id> form they can hand-edit
the snippet after copying; the embed runtime accepts all three
forms (path, hash, query) on the receive side.
2026-06-01 10:45:00 -04:00
thePR0M3TH3AN 52c8539257 External embed: fix code-dialog modal visibility
openEmbedCodeDialog set modal.style.display = '' to show the
modal, which falls back to the CSS default `.modal { display:
none }` — so the dialog never appeared. Every other modal in the
app sets `'flex'` explicitly. Matching that pattern.

Also fixes the symptom "Embed button doesn't work / doesn't
copy anything" — the dialog was always present in the DOM but
invisible, so the copy buttons inside it couldn't be reached.
2026-06-01 10:38:33 -04:00
thePR0M3TH3AN 21725b35ff External embed: code generator dialog in preview pane
Per docs/EXTERNAL_EMBED_FEATURE.md §13 / §18 Phase 6 — the embed-
code generator now ships alongside Phase 1's viewer so authors
can grab iframe snippets directly from the community preview.

UX:
- New "Embed ↗" button in the preview pane's action row, between
  the share-link button and Use/Fork. Hidden for private rows
  (embed refuses them anyway); shown for locked-premium rows so
  designs can be embedded as watermarked-preview teasers pre-
  purchase.
- Click opens #embedCodeModal: a single-screen dialog with
  identifier selector, size preset, options checkboxes, live
  iframe preview, and the generated iframe HTML in a copy-ready
  textarea + Copy URL / Copy iframe code buttons.

Controls:
- Identifier: naddr (latest version, auto-updates) | nevent
  (snapshot, frozen to this exact event). naddr is the default
  but falls back to nevent when the row has no d-tag.
- Size: responsive 16:9 (recommended) | square 320 / 480 | fixed
  640×360 / 800×450 / 960×540 | custom WxH.
- Options: controls / author chip / "Open in Artstr" link /
  credits row — each toggled via a query param (controls=0,
  attr=0, open=0, credits=1).

The live-preview pane uses a pink/yellow checker background so
transparency in the embedded design shows through visually
(verifies that the iframe is actually transparent before the
user pastes the snippet on their own site).

Copy buttons use navigator.clipboard.writeText with a graceful
fallback toast on permission denial. The URL extracted from the
generated iframe's src=, so URL/iframe stay in sync.
2026-06-01 10:34:48 -04:00
thePR0M3TH3AN c9585c02be External embed: more aggressive html/body bg reset + debug log
Defensive: also setProperty('background-image', 'none', 'important')
and ('background-color', 'transparent', 'important') on both
documentElement and body. Catches any inherited background-image
that might be painting a pattern instead of a flat color.

Added a console.log('[embed] boot v3', { route }) at the top of
bootEmbedViewer so the user can confirm the new code is reaching
the iframe — if the log doesn't appear in the iframe's dev-tools
console, the browser cache hasn't refreshed yet.
2026-06-01 09:48:41 -04:00
thePR0M3TH3AN c3a8aa9946 External embed: actually force html transparent (CSS + inline)
Previous fix set documentElement.style.background = 'transparent'
but didn't beat the global

  html, body { background: var(--bg); ... }

rule at the top of the stylesheet. --bg is #e8f4f8 (light cyan,
reads as near-white), so pixel-art transparent pixels were showing
that light cyan through instead of the host page background.

Two fixes layered:

1. CSS rule using :has() so html is targeted only when an
   embed-mode body is its descendant:

     html:has(body.embed-mode), body.embed-mode {
       background: transparent !important;
     }

   :has() is supported in all current browsers (Chrome 105+,
   Safari 15.4+, Firefox 121+). The !important flag beats the
   global var(--bg) declaration.

2. Inline override via style.setProperty('background',
   'transparent', 'important') on both documentElement and body
   at the top of bootEmbedViewer. Inline + !important = highest
   specificity possible, so this guarantees transparency even on
   browsers/edge cases where :has() doesn't trigger immediately
   on dynamic class change.

Net effect: from <html> → <body> → #embedRoot → .embedStage →
.designObjectFitWrap → renderer's pixel-art box → canvas, every
layer is transparent. Host backgrounds (color, image, gradient)
now actually show through transparent pixel-art cells.
2026-06-01 09:40:12 -04:00
thePR0M3TH3AN 34fefe6d79 External embed: refresh button on relay errors + html transparency
Two follow-ups from the size/aspect stress test:

1. Retryable error states (relay query failed, event not found
   on any relay) now render a "↻ Refresh" chip below the message.
   Click re-runs bootEmbedViewer(route) — same boot path,
   re-issues the relay race. Non-retryable errors (private,
   premium, malformed identifier, wrong-payload-shape) don't show
   the button because retrying wouldn't change the outcome.

   _setEmbedStatus now accepts an optional { retry } callback;
   only the two relay-failure call sites in bootEmbedViewer pass
   it.

2. Pixel art (and any other design with transparent pixels) was
   rendering on a white default background because the browser
   paints document.documentElement white when nothing overrides
   it. body.embed-mode set background:transparent, but the
   <html> root was unaffected — leaving a white layer behind the
   transparent body.

   Fixed by setting document.documentElement.style.background =
   'transparent' at the top of bootEmbedViewer. Combined with the
   already-transparent body + embedRoot + embedStage chain, the
   iframe document is now fully transparent from html down. Host
   pages can place the embed over background images / colors and
   the unfilled pixel-art cells show the host through.

Host-side defensive: dummy host CSS now explicitly sets
`.frame iframe { background: transparent }`. Modern browsers
default this for transparent-document iframes, but explicit is
safer across browser versions.
2026-06-01 09:35:56 -04:00
thePR0M3TH3AN db2f6151a9 External embed: reuse designObjectFitWrap to scale-fit cleanly
The grid swap fixed the squish but introduced the inverse: the
renderer's 720px box stayed at 720px without scaling down to the
iframe viewport. fitMiniPreview was firing, but something in the
grid layout path was preventing the transform: scale() from
shrinking the visible artwork.

Reverted the wrap to the same pattern the existing linked-design
renderer uses for in-app layer previews — flex centering + the
.designObjectFitWrap CSS class, which sets

  .designObjectFitWrap > * { flex: 0 0 auto; max-width: none;
    max-height: none; transform-origin: center center;
    margin: 0 !important; }

flex: 0 0 auto on the direct child explicitly disables both
flex-grow and flex-shrink, so the renderer's fixed 720×720 box
stays at its natural size in layout. fitMiniPreview then reads
naturalW=720, wrapW=iframe-width, and scales uniformly with
correct aspect.

This is the exact pattern that works for linked-design layers in
the editor; the embed inherits the same correctness guarantees.
2026-06-01 09:28:58 -04:00
thePR0M3TH3AN a2a6de2211 External embed: fix non-uniform squish on small iframes
The embed wrap was using display:flex with align-items+justify-content
to center the rendered design. Flex's default flex-shrink:1 on a
fixed-pixel child (the renderer creates a 720×720 box) shrinks the
box horizontally to fit the wrap width on iframes narrower than
720px.

fitMiniPreview then reads naturalW from the box's inline style
(720) — not the post-flex-shrink computed width — and applies
transform: scale(min(wrapW/720, wrapH/720)). The horizontal axis
ends up scaled twice: once by flex (down to wrap width), then
again by the transform. Result: horizontal squish equal to
(wrapW / 720), visible especially on aspect-ratio:1/1 iframes
hosting square pixel art.

Switched both the public paint wrap and the locked-premium paint
wrap to `display: grid; place-items: center`. Grid centers fixed-
size children without shrinking them, so the box stays 720×720 in
layout. fitMiniPreview's scale calc is then accurate and uniform.
2026-06-01 09:25:01 -04:00
thePR0M3TH3AN 1391e1b897 External embed: float controls as transparent pill chips
User feedback: the embed frame's white footer band clashes with
host site themes. Refactored so the frame is fully transparent
and every control floats as its own pill chip over the bottom
edge of the stage. Each chip carries a semi-opaque dark
backdrop + blur so it stays readable on any host background
(light, dark, branded).

Style changes:
- .embedFooter is now absolute-positioned, transparent, with
  pointer-events: none so the artwork beneath stays interactive
  (pixel-art animation, future deck swipe gestures).
- New .embedChip base class for every control:
  - rgba(17,24,39,0.7) bg + white text
  - 12px backdrop-filter blur for cross-theme legibility
  - rounded-pill shape, 1px white-translucent border
- Variants:
  - .embedChipUnlock — amber accent for the premium CTA
  - .embedChipCounter — slightly lighter, cursor:default
  - .embedChipBadge — for the top-right "🔒 PREMIUM" badge

JS changes:
- New _makeAuthorChip(pubkey) helper used by both the public and
  premium paint paths.
- _paintEmbed and _paintEmbedLockedPremium drop all inline
  color/background overrides — every control gets a chip class
  instead.
- The locked-premium prev/next buttons are now plain chips
  (were amber-on-amber buttons before, hard to read on dark
  artwork). The amber accent only stays on the unlock CTA where
  it's a semantic signal.

Status placeholder ("⟳ Loading…", error messages) keeps a soft
white-translucent backdrop so it's visible while there's no
artwork behind it yet.

Smoke-test target: the dummy host page (localhost:8001) now
includes a dark-themed section so chip legibility against dark
backgrounds is verifiable visually.
2026-06-01 09:21:08 -04:00
thePR0M3TH3AN 0cdb1adc5f External embed Phase 2 (premium): watermarked preview + unlock CTA
Per docs/EXTERNAL_EMBED_FEATURE.md §10 / §11.6 / §18 Phase 1+
(promoted from Phase 2 since it's natural to ship alongside the
public render path).

The envelope on a premium event ships an unencrypted `preview`
field:
- watermarked-image: single watermarked JPEG (`preview.dataUrl`).
- watermarked-deck: paginated watermarked slides
  (`preview.slides[]`, each { dataUrl, width, height }).
The embed loads the envelope from event.content, branches into a
new `_paintEmbedLockedPremium` paint path, and renders the
preview directly. No relay re-query, no decryption attempt.

UX:
- Stage gets a dark backdrop + the watermarked preview centered.
- A small "🔒 PREMIUM" badge sits top-right of the stage so the
  lock is visible without obscuring the artwork.
- Deck previews get prev/next controls in the footer with a
  "Slide N of M · K in deck" counter that surfaces the deck's
  full slide count (preview ships a capped sample).
- Footer's right side has "🔒 Open in Artstr to unlock ↗" in
  amber — the only path to unlock. The iframe never initiates a
  zap.

Phase 1's premium-refuse fallback still kicks in for envelopes
that ship without a preview field (legacy / malformed) — same
text-only placeholder as before.
2026-06-01 09:11:29 -04:00
thePR0M3TH3AN f6007876b7 External embed Phase 1: chromeless read-only viewer
Per docs/EXTERNAL_EMBED_FEATURE.md §18 Phase 1.

- parseEmbedRoute() detects three URL forms so the embed works on
  Vercel-rewrite-friendly hosts AND on plain static servers:
    /embed/<id>       (canonical; needs SPA rewrite)
    #/embed/<id>      (hash; works on any host)
    ?embed=<id>       (query; works on any host)
- Early-return at the top of init() routes embed mode to
  bootEmbedViewer; skips welcome modal, slot/disc/jewel controls,
  attachEvents, walletUI, autosave restore, share-route popstate.
  Editor JS stays loaded (single bundle), but none of its boot
  steps run.
- bootEmbedViewer() decodes the identifier via the existing
  decodeNostrId helper (note / nevent / naddr / hex), races the
  hint relays then user relays + DEFAULT_NOSTR_RELAYS for the
  event, warms linked-design sub-trees in one prefetchBatch
  round-trip, then paints via the existing
  renderDesignPayloadPreview + fitMiniPreview path.
- Premium events refuse with "🔒 Premium design — open in Artstr
  to unlock" placeholder; Phase 2 will render the watermarked
  preview JPEG with an inline CTA.
- Private (encrypted) envelopes refuse with a 🔒 placeholder; no
  decryption attempted.
- body.embed-mode CSS hides every existing top-level element
  (`body.embed-mode > :not(#embedRoot) { display: none !important }`)
  and shows a single #embedRoot with a stage + thin footer (author
  chip + "Open in Artstr ↗"). attr / open / controls query params
  toggle each piece.
- vercel.json gains /embed/:id SPA rewrites and a
  Content-Security-Policy: frame-ancestors * header so the route
  is iframe-able from any origin. Production behavior; dev (python
  http.server) needs no headers — cross-origin framing works by
  default.

Manual smoke-test paths:
  http://localhost:8000/?embed=<naddr>            (query form)
  http://localhost:8000/#/embed/<naddr>           (hash form)
  http://localhost:8001/                          (cross-origin
                                                   iframe host)
2026-06-01 09:01:35 -04:00
thePR0M3TH3AN 8439b358c3 Linked Designs P3d: batched prefetch on cold start
Last Phase 3 polish piece per LINKED_DESIGNS_FEATURE.md §14. The
cold-start path used to fire one parallel queryRelays per linked
layer — a host with 20 distinct linked sources opened 20 sockets
to each relay in the union. Now restoreAutosave issues one
batched relay round-trip via the new linkedDesigns.prefetchBatch.

Mechanics:

1. queryRelays accepts either a single filter object (existing
   callers) or an array of filters; arrays spread into the NIP-01
   REQ after the sub id (OR semantics). One-line change at the
   send site; zero impact on existing call sites.

2. prefetchBatch(specs) skips anything already in the in-memory
   cache, inflight map, or disk cache — partial cache hits only
   fetch what's missing. Distinct address-coord refs are grouped
   by (kind, pubkey) into one filter each (#d set of dTags);
   pinned event ids collapse into a single ids-filter. Events
   bucket by id (for pinned matches) and by address coord with
   latest created_at wins (for ref matches).

3. restoreAutosave's per-spec resolveLinked loop becomes a single
   prefetchBatch call. The disk-cache pre-filter inside
   prefetchBatch means a reload of a previously-resolved host
   pays zero network cost; only newly-linked refs hit the wire.

Per-layer resolveLinked calls in the renderer still work the way
they did — this only changes the bulk warm-up path. Interactive
adds (one new linked layer at a time) keep their single-filter
fast path.
2026-05-31 22:36:42 -04:00
thePR0M3TH3AN 81e33fab9f Layer extract: edit linked source + transparent group canvas
Three bugs in the freshly-shipped extract flow:

1. Group extract baked customArt.background='#ffffff' so the
   extracted design lost any transparency the group had. Now
   defaults to 'transparent' so the extracted design composites
   cleanly into any host. Users can pick a solid fill later via
   the customart background control if they want one.

2. Double-clicking a linked layer ran beginDesignObjectEdit
   which loads layer.design.payload — but linked layers have no
   local payload, just a ref. loadProjectFromText('{}') then
   fell back to the default cover/casewrap mode with empty
   layers, hence "opens the case wrap editor" and "design is
   missing completely."

   beginDesignObjectEdit now detects design.kind === 'linked',
   pulls the resolved payload from linkedDesigns.peek, and opens
   THAT for editing. The edit session is tagged with
   { isLinked, linkedContext } so finishDesignObjectEdit knows
   to take the republish path on save instead of mutating
   layer.design.payload (which doesn't exist for linked
   layers).

3. Guard rails on the linked-edit path:
   - Refuse if the resolver hasn't cached the source yet
     ("Refresh the layer and try again").
   - Refuse if the layer is pinned ("Unpin to edit — pinned
     versions are frozen").
   - Refuse if the source author isn't the current user
     ("Only the source author can edit; fork or unlink it for
     a local copy"). Soft permission — relays decide who can
     replace a kind-30078 by pubkey + d-tag, so non-authors
     couldn't republish even if we let them try.

On save, _republishLinkedDesignSource:
- Strips linked-design ephemera + builds attribution tags
  (same as host publish).
- Publishes a fresh kind-30078 under the same author + d-tag,
  which is NIP-33 in-place replace.
- clearCache(key) + seed both resolver caches from the new
  signed event so every linked layer on this canvas paints the
  updated version on next render.
- Refreshes the layer name from the (possibly edited) payload
  title with the 🔗 prefix preserved.

finishDesignObjectEdit is now async; existing call sites are
fire-and-forget which is fine — user feedback flows through
toasts, not the return value.
2026-05-31 22:31:13 -04:00
thePR0M3TH3AN d1d4118666 Layer extract: group → linked design
Extends the per-layer "🔗 Publish + link" pattern to layer groups.
A new 🔗 button sits next to the eye-toggle in each group's
header (layerGroupBlock); clicking it publishes the group as a
single customart-mode design and replaces the group with one
linked layer at the group's bounding box.

Implementation:

- layersBounds() over the group's layers gives the bbox in inches.
- customArt.width/height = bbox in pixels (×96 DPI).
- Each layer is cloned with x/y translated so the group's
  top-left becomes (0, 0); target forced to 'canvas'; groupId
  stripped. Group can contain mixed layer types (shapes, text,
  design objects, images) — customart is the right canvas type
  for any of them.
- Strip linked-design ephemera + walk attribution like the
  host-publish path so the new event ships proper p / link tags.
- d-tag = "extracted:customart:<slug>:<rand>" to avoid colliding
  with the user's regular published designs.
- After publish: remove the original group layers from
  state.layers, drop the group entry from state.layerGroups, add
  one linked layer at the bbox position with the host's original
  target (cover-front / canvas / etc.). The replacement layer's
  rotation is 0 — group rotation is intrinsically per-layer.
- linkedDesigns.seed primes both caches from the signed event
  so the replacement layer paints instantly.

Confirmation copy warns about publishing under the user's
identity (spec §10.3) and explains the bbox + relative-position
behavior so the conversion isn't surprising.

Mixed-target groups (e.g., one layer on cover-front, one on
cover-back) collapse to a single canvas; this matches user
expectation since "the group" only exists when its members are
selected together as a unit.
2026-05-31 22:24:09 -04:00
thePR0M3TH3AN 77d35e7bea Layer extract: publish embedded design layer as its own linked event
User-triggered conversion of an embedded design layer into a fresh
published kind-30078 event under the user's pubkey, replaced by a
linked layer pointing at the new event. Spec §10.3 / §11.4 — when
you decide a sub-design is reusable, one click extracts it without
re-importing by hand.

UI: new "🔗 Publish + link" button in the embedded design-layer
options panel (alongside Edit and Replace from file). Hidden for
linked layers since they're already what extract produces.

Flow:
1. Confirm with copy that warns about publishing under the user's
   identity (per spec §10.3 — only do this for designs you made).
2. Clone the layer's payload, override payload.meta.title with the
   layer's name, keep host category/language so the new event
   discovers into the same bucket.
3. Strip linked-design ephemera + walk the linked tree for
   attribution p-tags + link tags, same as the host-publish path.
4. Mint d-tag = "extracted:<mode>:<slug>:<rand>" so it can't
   collide with the user's regular published designs.
5. Sign + publish via publishCommunityEvent.
6. Mutate the layer in place: design becomes { kind: 'linked',
   ref, mode, pinnedEventId: null, slideIndex: 0 for decks }, name
   gets the 🔗 prefix, push history.
7. Seed both resolver caches from the signed event via the new
   linkedDesigns.seed(spec, sourceEvent) method — the layer paints
   from cache instantly without waiting for relay propagation.

linkedDesigns.seed reuses _entryFromEvent so vault / premium /
private detection runs identically to a fresh fetch. Disk-cache
write happens too so a reload also paints instantly.

Group extraction is a natural follow-up — a group of layers would
publish as a single customart-mode payload sized to the group's
bbox. Not in this commit; ship single-layer first.
2026-05-31 22:21:37 -04:00
thePR0M3TH3AN 5f2f057e52 Linked Designs P3c: localStorage disk cache
Mirror the in-memory resolver cache to localStorage so cold reloads
paint linked sources without a relay round-trip. 24-hour TTL keeps
return visits feeling instant while still propagating source edits
within a day; 4 MB LRU eviction stops a host with many linked
layers from blowing the quota.

Storage shape (artstr.linkedDesignCache.v1):

  { version: 1,
    entries: {
      [cacheKey]: { sourceEvent, fetchedAt }
    }
  }

The disk cache stores only the raw sourceEvent — premium / private
detection, vault lookup, and decrypt all re-run on every read via
the extracted _entryFromEvent helper. That means vault state,
sign-in changes, and unlocks since the last fetch are always
respected on disk-cache hits; we never serve stale "locked" state
once the user has unlocked, or stale "unlocked" state once they
clear the vault.

Refresh + Unlink continue to be the user's escape hatch:
clearCache(key) now wipes both the in-memory entry AND the disk
entry, so a manual refresh genuinely re-fetches from relays.

Eviction is LRU by fetchedAt when total content bytes exceed
LINKED_DESIGN_DISK_MAX_BYTES. Quota errors on save drop the whole
cache and continue — better to lose the optimization than to break
publishing for an unrelated localStorage write.
2026-05-31 22:12:59 -04:00
thePR0M3TH3AN 9152d41f4b Linked Designs P2d: deck links + slide picker
Two fixes for linking to slide decks:

1. Per-event cap was 256 KB — fine for slides/covers but legitimate
   multi-slide decks routinely run 300-800 KB. The resolver was
   returning null for those events, and the renderer fell through
   to the "Linked source not found" placeholder. Raised the cap to
   1 MB to match the per-tree cap; the tree cap still bounds the
   worst-case host render, and a single oversize event is now
   tolerated rather than dropped.

   While here, added console.warn diagnostics on the resolver's
   null paths (no events, empty content, cap exceeded, JSON parse
   fail) so future debugging is one dev-tools tab away.

2. A linked deck normally has 5-50 slides and the user almost
   always wants to embed one of them, not the multi-slide
   thumbnail. New schema field design.slideIndex picks which
   slide to render:

   - addLinkedDesignLayerFromRow / replaceLinkedDesignLayerFromRow
     default it to 0 when the source is a deck.
   - renderResolved checks for a finite slideIndex on a deck
     payload and projects to a stand-alone slide payload via
     _deckSlideToSlidePayload before painting. The picked slide
     renders the same way a normal slide-mode design would.
   - The linked-layer options panel now shows a "Slide" dropdown
     (id linkedDesignSlideRow) when the resolved source is a
     deck. The dropdown lists every slide as "N. <name>" and
     mutating it pushes history + re-renders.
   - The row hides itself for non-deck sources and for decks
     that haven't resolved yet — once the resolver fills the
     cache, the next render reveals it.

Public/embedded layers and non-deck linked layers are unaffected.
2026-05-31 22:08:49 -04:00
thePR0M3TH3AN 421104257e Linked Designs P2c: private (visibility-gated) linked designs
LINKED_DESIGNS_FEATURE.md §12.1 — private sources carry a NIP-44 /
NIP-04 envelope addressed to specific pubkeys. Phase 1 treated them
as unparseable junk; Phase 2 detects them, attempts decryption, and
either renders the decrypted payload (if the viewer is in the
envelope's recipients list) or paints a 🔒 placeholder otherwise.

Resolver changes (_fetchPayload):
- After the premium branch, detect isEncryptedEnvelope(parsed) and
  await decryptProjectEnvelope. On success return the decrypted
  payload like a public source; on any failure (not in recipients,
  no signing extension, malformed envelope) return { payload: null,
  private: { state: 'locked' } } so the renderer can paint the
  locked UI without re-fetching.

Renderer changes:
- New renderLockedPrivate helper paints a 🔒 + "Private design" +
  "Not shared with this account" on a dark gray background. No
  unlock CTA — access to a private design is granted at the
  source's publish time by adding the viewer to envelope.recipients,
  there's no buyer-side action to take.
- Sync peek hit handles cached.private.state === 'locked'; async
  path handles entry.private.state === 'locked'.

Source authors are always in their own envelope.recipients, so the
existing self-decrypt path works for them without special-casing.
2026-05-31 22:02:24 -04:00
thePR0M3TH3AN b592a69c20 Linked Designs P2: premium sources + locked render + unlock CTA
LINKED_DESIGNS_FEATURE.md §12.1 — Phase 1 silently dropped
premium / encrypted sources because the resolver's JSON.parse hit
an envelope shape and the renderer treated payload=null as
"source missing." Phase 2 detects premium events, gates rendering
on the buyer's purchase-vault entry, and surfaces an inline unlock
CTA when the viewer hasn't bought yet.

Resolver changes (_fetchPayload):
- Detect premium events via isPremiumEvent() on the source event.
- Look up findUnlockedEntry(ev.id, addressCoord); on hit, return
  the cached decrypted payload as if it were public.
- For self-authored sources, run Premium.decryptPayload locally
  (soft-gate KDF is deterministic from public material) and
  mirror the unlock into the vault via markUnlocked so My Designs
  and the feed see it too.
- Otherwise return { payload: null, premium: { state: 'locked',
  envelope, addressCoord } } so the renderer can paint the locked
  UI without re-fetching.

Renderer changes (renderDesignObjectIntoElement linked branch):
- New renderLockedPremium helper paints the watermarked preview
  JPEG (from envelope.preview.dataUrl) at .45 opacity behind a
  semi-transparent overlay with 🔒 + "Linked premium design" + an
  "⚡ Unlock for X sats" button.
- Button awaits openUnlockConfirm with a synthetic row built from
  the source event; after it resolves, checks the vault and if
  the entry landed, clears the resolver cache for this layer's
  key and re-renders. The resolver re-fetches and the next paint
  shows the decrypted preview.
- Sync peek hit handles cached.premium.state === 'locked';
  async path handles entry.premium.state === 'locked'.

Locked sources still contribute to attribution (sourceEvent.pubkey
is known regardless of payload visibility), but walkLinkedTree
descends no further into them — encrypted contents are opaque to
us, so nested credits stop at the locked boundary. Acceptable
Phase 2 limitation.

Private (visibility) linked designs are NOT in this commit;
adding on top as P2c if Adam wants.
2026-05-31 21:56:00 -04:00
thePR0M3TH3AN d13a7016a2 Linked Designs: Credits — broaden profile lookup to relay union
The hydrator was relying on populateCommunityProfiles, which only
queries state.community.relays. Linked-design contributors are
arbitrary pubkeys whose kind-0 metadata typically lives on the
popular default relays (damus, nos.lol, primal, nostr.band), not
necessarily on the host author's narrow relay list — so the lookup
silently came back empty and the UI fell back to short-pubkey
labels.

Switch to the same _vaultRelayUnion() the linked-design resolver
already uses (user relays ∪ DEFAULT_NOSTR_RELAYS). Pick the latest
kind-0 per pubkey when multiple relays return different versions.
Write into both state.community.profiles and _feedCache.profiles
so all downstream profile-display surfaces (including the existing
"by Author" link in the preview header) benefit from the fetch.
2026-05-31 21:49:08 -04:00
thePR0M3TH3AN 96ea0c0a26 Linked Designs: Credits — display names + working profile links
Two fixes to the Credits panel + preview surface:

1. Anchor click routed through openProfilePage(pk) instead of
   navigating to /u/<npub> — the latter 404s because the SPA only
   handles those routes via JS, there's no static file behind them.
   Mirrors the pattern used by the existing author link in the
   preview header (preventDefault + openProfilePage).

2. Display name pulled from _feedCache.profiles / state.community
   .profiles with shortPubkey() as fallback. For contributors who
   aren't in the cache yet, _hydrateCreditProfiles fetches kind-0
   metadata via populateCommunityProfiles in the background and
   re-renders the list when names arrive. Per-pubkey inflight
   tracking dedupes concurrent fetches across re-renders.

Editor panel re-renders the full panel on hydration. Preview
section rebuilds just its <ul> in place, guarded on
section.isConnected so a closed or swapped-out preview doesn't
mutate stale DOM.
2026-05-31 21:46:23 -04:00
thePR0M3TH3AN 9280777ce0 Linked Designs: surface Credits in the template-browser preview
Mirror the editor's Credits panel into the community/template
browser preview pane so viewers can see who contributed before
importing or unlocking. Same data, different host:

- Factored collectLinkedCredits + buildCreditsListElement out of
  renderCreditsPanel so both surfaces share the implementation.
- The preview pane passes the row's payload + author pubkey;
  self-attribution (the design author appearing in their own
  credits) is filtered out the same way the editor filters the
  current user.
- Section is appended at the bottom of the preview content; it
  only renders when the previewed design has at least one linked
  contributor.

Works for previews of cover, slide, deck, jewel, disc, pixelart,
and custom-art designs — the section appends after whichever
mode-specific preview renderer ran.
2026-05-31 21:42:17 -04:00
thePR0M3TH3AN 4e1073bd7c Linked Designs: Credits panel in metadata sidebar
Per LINKED_DESIGNS_FEATURE.md §10.1, surface each distinct
contributor reachable via the host's linked layers as a clickable
@npub entry in a "🔗 Credits" panel in the metadata sidebar.

Walks state.layers + every deck slide's layers, groups distinct
linked refs by pubkey (so 5 copies of the same source count as 1
design from that creator), excludes self, and renders one entry
per contributor sorted by descending design count. Each entry
links to the /u/<npub> profile page in a new tab.

Pubkey is parsed straight from design.ref's address coord, so the
panel populates without waiting for the resolver. Fieldset is
hidden entirely when there are no linked layers — non-collaborative
designs stay clutter-free.

Wired into render() so it stays current as the user adds, removes,
pins, replaces, or unlinks layers.
2026-05-31 21:37:10 -04:00
thePR0M3TH3AN f4a858e88f Linked Designs: attribution p-tags + link-tags at publish
Per LINKED_DESIGNS_FEATURE.md §10.2, host designs that contain
linked layers now emit:

  ['p', <pubkey>, <relay-hint>, 'design-contributor']  per distinct
                                                       contributor

  ['link', <kind:pubkey:dTag>, <relay-hint>]           per distinct
                                                       linked ref

Implementation: a new _buildLinkedAttributionTags helper drives the
existing linkedDesigns.walkLinkedTree to collect contributor pubkeys
+ refs in one pass (cycle- and depth-3-safe via the resolver). The
host author's pubkey is filtered from contributors. Walker errors
degrade to empty tag arrays without failing the publish.

Tags are spliced into the public publish path and the premium
publish path. Private publishes skip attribution entirely — tags are
unencrypted, so emitting them would leak the link graph through what
is meant to be a feed-invisible event.

Encrypted sources resolve to null payloads in Phase 1, so their
authors silently don't get p-tagged — which is the right Phase 1
behavior until Phase 2 brings encrypted-link support.

Unit tests at /tmp/test_attributionTags.js (12 cases) cover dedupe,
self-filtering, malformed-pubkey rejection, empty walks, and walker
failure modes.
2026-05-31 21:28:05 -04:00
thePR0M3TH3AN a0a7f215c2 Linked Designs P1d: autosave restore + publish strip
restoreAutosave now collects every linked layer (top-level + every
deck slide) and kicks off linkedDesigns.resolveLinked for each.
This pre-warms the cache so the first render's renderResolved fires
from a synchronous peek instead of paying the relay round-trip
serially, and duplicate refs across slides dedupe via the inflight
map.

stripLinkedDesignEphemera removes session-only fields
(lastResolvedAt, lastResolvedSize, transient fallbackPreview) from
linked layers in a published payload. Called once at the top of
publishCurrentTemplateToNostr — before the private/premium/public
branches — so every publish path is covered. fallbackPreview only
survives if design.snapshotPreview === true (a future opt-in surface
for designs that want a still preview to ship alongside the link).

Unit tests at /tmp/test_stripLinkedEphemera.js (16 cases) cover the
mutation, opt-in, deck walking, and null-tolerance paths.
2026-05-31 21:12:48 -04:00
thePR0M3TH3AN a8f7163050 Linked Designs: fix re-render wiping the resolved preview
renderResolved had an `if (!el.isConnected) return` guard meant to
catch the async path resuming after the layer was removed. But
renderLayers() creates `el` via document.createElement and only
appends it to the overlay *after* renderDesignObjectIntoElement
runs (line 14442). On the synchronous peek-hit path, that meant
the guard fired on every re-render — wiping the layer every time
the canvas re-rendered after the cache warmed.

Move the guard out of renderResolved (the sync caller doesn't
need it — fitWrap is a descendant of el either way, and the
caller will append el right after this function returns) and
keep it in the async closure (where it's actually needed,
because by the time the await resumes the old el may have been
discarded by a subsequent render).
2026-05-31 17:33:33 -04:00
thePR0M3TH3AN 7e2cd2ca0b Linked Designs P1c: layer-options panel for linked layers
Adds a dedicated layer-options panel that appears when a linked
design layer is selected, alongside the existing import flow:

- Source title + creator npub + "Last resolved <X> ago"
- 📌 Pin-to-current-version toggle (snapshots current event id)
- Refresh button: clears the layer's cache key + forces re-resolve
- Replace link… opens the community browser in 'link-replace' mode,
  swapping the source on selection while keeping position + size
- Unlink to embed snapshots the resolved payload into an embedded
  layer (requires a successful prior resolve)

The selection-sync block now hides the embedded-design panel and
shows the linked-design panel when design.kind === 'linked'.
2026-05-31 17:21:33 -04:00