Commit Graph
100 Commits
Author SHA1 Message Date
thePR0M3TH3AN f8cf8570f2 Linked Designs spec: tighten before implementation
Second-pass review surfaced eight issues worth pinning down before
we start coding. Each addressed in-place rather than as a TODO so
the implementation has a clear target:

1. §5.1 — Persisted vs in-memory fields. The host's serialized
   `content` was implicitly inflating with `fallbackPreview`,
   `lastResolvedAt`, `lastResolvedSize` from the initial schema.
   Now explicit: only `kind` / `ref` / `mode` / `pinnedEventId` /
   `pinnedSnapshot` persist; the rest are runtime-only and stripped
   at publish time. A linked layer footprint is ~110 bytes serialized.
2. §6.1 — Pinned + source deletion. New section covering the case
   where the source author kind-5-deletes a pinned event. Adds a
   `pinnedSnapshot` field that the renderer prefers-live-falls-back-
   to-snapshot, plus the existing `fallbackPreview` for visual-only
   fallback. Default = pin without snapshot, user opts in for
   permanence.
3. §8.1 — Sync vs async render decision. The renderer is synchronous
   today; this section explicitly picks "sync placeholder + async
   upgrade" over "block render" or "pre-fetch all". DOM tagging +
   abort-on-detach behaviour documented so the implementation
   doesn't end up re-deciding mid-build.
4. §8.2 — Autosave + cold start. localStorage restore loses in-memory
   resolver state, so on `restoreAutosave` we kick off the resolver
   for every linked layer in the background.
5. §10.3 — Embedded → linked attribution: corrected an inaccurate
   claim. Embedded layers don't carry source-author metadata, so
   "Publish + link" credits the CURRENT user, not whoever originally
   authored the embedded content. Action copy should warn explicitly.
6. §12.1 — Premium-link access check: clarified that the gate is the
   host viewer's purchase-vault entry (not technical decryption
   capability, since the soft-gate KDF is deterministic).
7. §13 — Added a per-tree cap (1 MB total resolved payload per host
   render) on top of the per-event cap. Depth-3 fan-out could
   theoretically multiply fetches; the tree cap stops a coordinated
   DOS at the host level.
8. §11.5 — Picking initial layer dimensions for a new linked layer
   (sync resolver lookup with a 1.5 s timeout, falls back to 16:9
   default). Plus a link-creation modal preview so the user can
   confirm what they're linking before commit.

Also tightened the Phase 1 acceptance criteria around serialized
size: explicit budgets for 10-link host events and verification that
in-memory fields are stripped at publish.
2026-05-31 16:38:26 -04:00
thePR0M3TH3AN 87dd9426c2 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.
2026-05-31 16:38:26 -04:00
thePR0M3TH3AN 59ae0e32ec TODO: refresh for Linked Designs kick-off
The list had drifted from reality across the last ~30 commits.
Cleaning up so "what's next?" decisions stand on accurate ground.

Moved out of "Drafted-but-unbuilt" / polish sections and into
"Shipped (recent)":

- Native chart import phases 1–4 (bar + pie + line + polish layer).
  The old "Native chart support" follow-up text under PPTX polish
  was outdated — chart import is now its own arc with its own
  CHART_IMPORT_FEATURE.md spec.
- PPTX Phase 7 per-run rich text spans — listed as a follow-up;
  actually shipped in commit b6cf4f3.
- Premium deck publishing + multi-slide preview pager.
- Self-author auto-unlock.
- Pixel-art layer animation when embedded.
- One-click PNG / GIF download on pixel-art preview pages.
- Auto-pick category metadata via defaultCategoryForMode.
- Locked-premium UI gating (Print / Save PDF, Start Presentation,
  Import-into-Disc-1/2).
- Vault durability fixes (receipt re-derive, synchronous upsert,
  multi-relay union).

Fixed the stale "Branch still needs a merge into `main` once we're
done testing" line under PPTX import polish — the merge happened
ages ago.

Added a new "Chart import polish" section listing the open chart
follow-ups (stacked variants, combo charts, 3D approximations).

Added Linked Designs to "Drafted-but-unbuilt features" so the new
spec at docs/LINKED_DESIGNS_FEATURE.md surfaces for future
"what should we do next" decisions.
2026-05-31 16:33:07 -04:00
thePR0M3TH3AN 3d9b012cc2 Preview pane: hide data-dependent buttons on locked premium
Three preview-pane actions consume the design payload, not just
the watermarked-preview envelope, so they shouldn't appear until
the buyer unlocks:

- Print / Save PDF — loadProjectFromText against a locked
  envelope just loads the encrypted blob, not the printable
  artboard. Hidden until unlock.
- Start Presentation (deck) — startPresenter walks payload.deck
  /slides which carry the multi-page slide data; on a locked deck
  the envelope only has watermarked slide-preview JPEGs.
- Import into Disc 1 / Import into Disc 2 (disc-design) —
  importDiscDesignFromPayload reads payload.discDesign, which
  isn't present on a locked envelope.

All three gated with `&& !lockedPremium`. The Use/Fork button
already transforms to "Unlock for N sats" for the same reason,
Save JSON is disabled, and Share / Tip / Block-report-overflow
stay available since they don't need the design data.
2026-05-31 16:08:58 -04:00
thePR0M3TH3AN 817ab051f0 Metadata: auto-pick category for slide / deck / pixel / custom art
Creating a Slide, Slide Deck, Pixel Art, or Custom Art template
already auto-set state.meta.category in applyLayout — but the
auto-fill was inconsistent (slide / deck / pixelart unconditionally
overwrote, customart only filled when empty) AND it didn't apply
when importing / forking a design whose payload arrived without
a category set.

Fix:

- defaultCategoryForMode(mode) — new shared helper that maps the
  canvas modes to their canonical mediaCategories entries:
    'slide'    → 'slide'
    'deck'     → 'slide-deck'
    'pixelart' → 'pixel-art'
    'customart'→ 'custom-art'
  Physical-media modes (cover / disc / jewel / disc-design) return
  '' so the user picks DVD / Blu-ray / PS3 / etc. — there's no one
  obvious default.
- syncInputs runs the helper at the top: if state.meta.category is
  empty, fill it from the current mode. Idempotent — leaves a
  user-picked value alone. Covers BOTH create-new and import /
  fork / loadProjectFromText paths via the one entry point all
  state changes pass through.
- applyLayout's inline mode → category switch replaced with the
  shared helper, also made consistently only-if-empty so a
  user-picked category survives a mode switch.
2026-05-31 16:06:31 -04:00
thePR0M3TH3AN fb7a00d522 Preview pane: hide Print / Save PDF on pixel-art designs
Pixel-art designs already expose their own one-click Download
PNG / GIF button (commit 95a39cc), which produces the right asset
for raster sprites at the right native scale. Print / Save PDF
on top of that is redundant + confusing — a printed PDF of a
32x32 pixel sprite isn't a useful artefact.

Gate the printBtn injection on mode !== 'pixelart'. All other
modes (cover, slide, deck, customart, jewel, disc-design, disc)
keep the button as before.
2026-05-31 13:14:24 -04:00
thePR0M3TH3AN 1c205b3ea8 Pixel-art layer: animate multi-frame designs when embedded
A multi-frame pixel-art design imported as a layer into another
canvas (a cover, slide, custom-art, etc.) was rendering only its
first frame as a static image. Now it animates at its stored fps
+ loop settings, matching what the standalone preview already
does.

renderPixelArtIntoElement now branches on frames.length > 1:

- Multi-frame: pre-render every frame to a native-size canvas
  (cheap reuse of renderPixelGridToCanvas), then a RAF loop
  blits the current frame onto a display canvas, advancing by
  ts deltas to honour the source fps regardless of monitor
  refresh rate.
- The RAF self-stops when the display canvas leaves the DOM
  (cv.isConnected === false), so a re-render of the editor /
  preview pane doesn't leak loops. Same pattern as the
  standalone-design animation.
- Single-frame: unchanged static render.

Applies everywhere pixel-art-as-layer surfaces: the live editor
free-layer overlay, the preview pane in the community browser,
and any host design that picks up the layer.
2026-05-31 09:18:41 -04:00
thePR0M3TH3AN 95a39cc077 Pixel-art preview: one-click PNG / GIF download
The community-browser preview page for pixel-art designs now
exposes a "⬇ Download PNG" (single-frame) or "⬇ Download GIF"
(multi-frame, animated) button next to Use/Fork, Share, and
Save JSON. Means viewers can grab the sprite asset without
forking it into the editor first.

Implementation reuses the existing exportPixelArtToPngBlob /
exportPixelArtToGifBlob / pixelExportFilename helpers from the
editor's full export menu, at the default 4× scale (matches
the editor's _pixelExportOpts default). GIF carries the source
design's fps + loop settings.

Gated on mode === 'pixelart' && !lockedPremium — locked premium
pixel-art designs only expose the button after unlock (the design
data isn't available before then). Self-authored designs and any
unlocked / free pixel-art design get the button regardless of
who published it.

Button shows "Preparing…" while encoding (matters for big
multi-frame GIFs where encoding can take a second or two).
Errors land as a toast.
2026-05-31 09:05:17 -04:00
thePR0M3TH3AN 4a5930fe29 Deck preview: Prev/Next pager on unlocked decks too
The previous commits added paginated previews for the LOCKED
premium state (creator-side multi-slide watermarked previews,
buyer-side Prev/Next viewer). Adam reasonably asked for the same
flippability after unlocking — there's no good reason a buyer
who's paid (or a creator looking at their own work) should
suddenly lose the ability to flip through slides.

renderDeckPreviewDOM now branches:

- compact OR <= 1 slide: keep the existing single-slide-1 +
  "N slides" badge render. This is the path feed cards, profile
  grids, and anything else passing { compact: true } take.
- non-compact AND >1 slide (i.e. the community-browser preview
  pane): emit a Prev / Next pager that re-renders the chosen
  slide via composeSlideForDeck + renderCustomArtPreviewDOM, with
  a "Slide N of M" counter between the buttons.

Same UX as the locked-premium pager so the transition pre- vs
post-unlock feels continuous. Applies to every deck — premium
or free, self-authored or someone else's — that lands in the
preview pane.
2026-05-31 00:01:16 -04:00
thePR0M3TH3AN 697dc976d0 Premium: self-author auto-unlock + paginated preview in preview pane
Two related fixes to the premium-deck experience the user just flagged:

1) Creator should never have to zap themselves to open their own
   premium design. New autoUnlockSelfAuthoredPremium walks the
   feed's gated events, picks the ones whose pubkey matches
   state.community.myPubkey, runs Premium.decryptPayload locally
   (the soft-gate KDF is deterministic — public material + the
   static client pepper — so the author needs no receipt), and
   stores the unlocked entry in the cache with source:
   'self-authored'. Runs in parallel with the existing receipt
   fast-path; they target different events.

2) The preview pane's locked-premium branch was still rendering
   only the single watermarked JPEG. The earlier multi-slide
   work only updated the mini-preview / card path — the actual
   pane shown when you click into a row from the community
   browser kept the old single-image render. Now it detects
   row.premium.previewSlides.length > 1 and emits a Prev / Next
   pager with a "Slide N of M · K in deck" counter, identical UX
   to the mini-preview viewer. Single-image case unchanged.
2026-05-30 23:58:17 -04:00
thePR0M3TH3AN 527f53b930 Premium decks: per-slide previews with paginated buyer viewer
Premium decks were publishing with only slide 1 visible to buyers
before purchase. Now the publish flow renders up to 6 watermarked
slide thumbnails into the envelope so buyers can flip through what
they'd unlock — a meaningful UX upgrade for paid decks.

Publish side:

- _encodeWatermarkedUnderBudget(src, maxBytes, startLong)
  extracted as a shared helper for the existing
  renderPremiumPreviewDataUrl + the new deck path.
- renderDeckPremiumPreviewSlides() walks state.deck.slides[0..N-1]
  with N = min(slides.length, 6), temporarily flipping
  deck.editingIndex so the existing renderDeckToExportCanvas
  picks each slide in turn, then watermarks + JPEG-encodes under
  a per-slide budget of 40 KB. Slides that can't shrink under
  the budget get skipped (no whole-publish failure). Saves /
  restores deck.editingIndex around the loop.
- PREMIUM_PREVIEW_DECK = { MAX_SLIDES: 6, PER_SLIDE_BYTES: 40K }.
  6 × 40 = 240 KB total preview budget — comfortable under the
  256 KB per-event limit common to popular relays even after
  base64 + envelope wrapping.
- _publishPremiumDesign now dispatches: decks go through
  renderDeckPremiumPreviewSlides → envelope.preview shape
  { kind: 'watermarked-deck', slides: [...], totalSlides,
    dataUrl, width, height }. dataUrl/width/height mirror
  slide 1 so existing single-image card / vault consumers stay
  intact. Non-decks keep the existing 'watermarked-image' shape.

Buyer side:

- row.premium gains previewSlides (array) + previewTotalSlides
  (count, possibly > previewSlides.length when the cap clipped
  some). previewJpegDataUrl still resolves to slide 1 so feed
  cards stay single-image.
- The preview pane's locked-premium branch now detects
  previewSlides.length > 1 and renders a paginated viewer: the
  current slide's watermarked JPEG, plus a control bar with
  prev / next arrow buttons and a "Slide N of M" counter (with
  "· K in deck" suffix when the deck has more slides than the
  preview cap shows). Single-image case (everything else)
  unchanged.

Backwards-compatible: an envelope without the slides[] array
falls through to the existing dataUrl-only path. Existing
premium events on relays continue to render as before.
2026-05-30 23:51:16 -04:00
thePR0M3TH3AN 8579789ddb Premium publishing: support 'deck' template mode
A user trying to publish a premium slide deck hit
"Premium preview not supported for mode 'deck'." — the publish
flow couldn't render the watermarked preview because the
mode-dispatch in _renderModeToExportCanvas had cases for
cover / disc / jewel / customart / slide / pixelart /
disc-design but not deck.

Fix:

- Add a deck branch to _premiumPreviewLongAxisInches that pulls
  width / height from state.deck.slides[0].slide (matches the
  customart / slide path: pixels → inches at 96 dpi).
- Add a deck branch to _renderModeToExportCanvas that dispatches
  to a new renderDeckToExportCanvas.
- renderDeckToExportCanvas mirrors renderCustomArtToExportCanvas
  but pulls the slide payload from the deck. Uses the currently-
  edited slide when deck.editingIndex is set (matches what the
  user sees while composing); otherwise falls back to slide 1
  (the deck-thumbnail convention). composeSlideForDeck applies
  the deck theme so the preview shows the buyer what they'll
  actually unlock. Empty decks emit a blank canvas rather than
  throwing — the publish modal already gates on having content,
  so this is just a defensive fallback.

With this commit every template mode supports premium
publishing — including decks — so a creator can charge sats for
a slide deck the same way they can for a cover or custom art.

Decryption side is mode-agnostic (Premium.decryptPayload just
returns the JSON, which loadProjectFromText already routes to
the deck builder), so no buyer-side change needed.
2026-05-30 23:45:09 -04:00
thePR0M3TH3AN f957adb99a PPTX chart import Phase 4c: value-axis ticks + gridlines
Bar / column / line charts now show the value-axis tick labels
that turn the chart from "abstract shapes" into "readable
data" — plus the horizontal / vertical gridlines that PowerPoint
draws when c:majorGridlines is present on either axis.

Helpers:

- _niceStep(range, targetTicks=5): pick an interval that's 1, 2,
  or 5 times a power of 10 — matches PowerPoint's auto-axis math.
  E.g. range 57 → step 10 (gives 0/10/20/30/40/50/60 ticks);
  range 1000 → step 200; range 0.5 → step 0.1.
- _ticksUpTo(maxV, step): array of evenly-spaced tick values from
  0 up through maxV.
- _chartHasMajorGridlines(chartDoc): does either c:catAx or
  c:valAx in the plotArea have a c:majorGridlines child?
- _buildGridlineLayers(chartDoc, pa, maxV, step, direction, ...):
  emit thin rect layers (#e0e0e0) across the plot area, one per
  tick. For 'col' direction (vertical value axis) the gridlines
  are horizontal; for 'bar' direction they're vertical. Emitted
  BEFORE the data shapes so bars / lines render on top via z-
  ordering.
- _buildAxisTickLayers(...): emit value-axis tick-label text
  layers — left side for 'col', bottom for 'bar'. 10pt, grey.

Per chart type:

- Bar: round maxV up to a tickStep multiple so the topmost bar
  aligns exactly with the top gridline + tick label. Gridlines
  before bars, ticks after data labels.
- Line: same; value axis is always vertical.

User-visible: gallery_template_layout_set.pptx slide 10's chart
now shows light grey horizontal gridlines at 10, 20, 30, 40, 50,
60 across the plot area, with matching numeric tick labels on
the left edge.

Pure-Node unit test for _niceStep + _ticksUpTo covers 12 cases
(integer-like, sub-1 fractions, zero / negative defensive). All
passing.
2026-05-30 23:38:57 -04:00
thePR0M3TH3AN aec0a51b1f PPTX chart import Phase 4b: data labels
Native rendering of c:dLbls — showVal / showCatName / showPercent /
showSerName flags. Series-level dLbls overrides chart-type-level
when both are present (matches PPTX inheritance rules).

Shared helpers:

- _readDataLabelOpts(dLblsNode): parse c:showVal / c:showCatName /
  c:showPercent / c:showSerName / c:dLblPos into a plain object.
  Returns null when the node is missing.
- _resolveDataLabelOpts(serNode, chartTypeNode): merge series-level
  over chart-type-level; returns null when no show* flag is set.
- _formatDataLabelText(opts, value, cat, total, name): join the
  enabled fields with \n (becomes <br> in the emitted HTML).
  showPercent uses Math.round(value/total*100).

Per-chart-type emission:

- Bar / column: text layer above each column ("outEnd"-like
  default position), or to the right of each horizontal bar.
  Reuses the bar-coord math; clamps to plot area so labels above
  the topmost bar don't escape upward.
- Pie / doughnut: text layer at the slice's angular midpoint,
  positioned 65 % of the way from centre to rim. Single-series
  c:ser so the merged opts apply to every slice.
- Line: text layer above each data point. Emitted in the marker
  loop so it's positioned even when markers themselves are
  disabled.

User-visible: gallery_template_layout_set.pptx slide 10's chart
has three c:dLbls blocks (one chart-level, two series-level) all
with showVal=0 by default — labels stay hidden unless a series
explicitly enables a flag. Decks that ship showVal=1 (the more
common configuration) now get readable numeric labels above each
bar / pie slice / line point.

Regression: polyline path test still passes.
2026-05-30 23:35:31 -04:00
thePR0M3TH3AN 1bfae87022 PPTX chart import Phase 4a: title + legend across all chart types
The "chart polish" layer that surrounds every chart type — title
text and series legend with colour swatches — now lands natively
instead of being silently dropped.

Shared helpers (used by all three builders):

- _readChartTitle(chartDoc): walks c:chart/c:title/c:tx/c:rich and
  joins a:p paragraphs + a:r runs. Returns '' when no title or
  when c:autoTitleDeleted="1" (the common "auto title suppressed"
  case for templates).
- _readChartLegend(chartDoc): reads c:chart/c:legend/c:legendPos@val
  (PPTX values 'r' / 'l' / 't' / 'b' / 'tr'; default 'r' when
  legend is present without an explicit position).
- _computePlotInsets(chartDoc, hasAxes): returns inset fractions
  for the plot area, growing the right (or left / top / bottom)
  inset to make room for the legend, and the top inset to make
  room for a title. hasAxes adds a 15 % bottom strip + 8 % left
  strip for category labels / value-axis tick labels (used by
  bar / line, not pie).
- _plotAreaFromInsets(gfBounds, insets): apply fractions.
- _buildTitleLayer: emit one centred-top text layer.
- _buildLegendLayers: emit (colour-rect + name-text) pair per
  series. Right / top-right stack vertically along the right
  edge; left mirrors; top / bottom flow horizontally.

Each chart builder now:
- Takes chartDoc as a parameter (dispatch in convertChart passes it
  through).
- Uses the shared inset / plot-area helpers (replaces the inline
  hand-rolled 5 % / 15 % math).
- Appends title + legend layers after its own slices / bars / lines.

Pie chart legend lists slice CATEGORIES (each in its own slice
colour) rather than series names — a single c:ser carries N
visual categories that the user thinks of as "series" in
PowerPoint.

Verified against the user's gallery_template_layout_set.pptx
slide 10: the chart has a c:legend block (and no title — flagged
autoTitleDeleted=1). After this commit the legend swatches +
"Baseline" / "Target" labels appear on the right side of the
chart in the imported deck.

Pure-Node polyline test still passes (no regression on Phase 3).
2026-05-30 23:32:29 -04:00
thePR0M3TH3AN 5360186384 PPTX chart import Phase 3: line charts with optional markers
c:lineChart now renders as native Artstr layers — one
kind:'path' polyline layer per series stroked in the series
colour, plus optional circle marker layers at each data point,
plus category-label text layers below the plot area.

Mechanics:

- _polylinePathD(xs, ys) builds an SVG "M x0 y0 L x1 y1 …" string
  in 0..100 viewBox coords with 3-decimal rounding. Single point
  emits a degenerate M+L for visibility; empty arrays return ''.

- buildLineChartLayers:
    - Collects categories from the first series, walks all series
      for { name, values, color, lineWidth, marker }.
    - Series colour comes from c:ser/c:spPr/a:ln/a:solidFill (the
      line stroke, NOT the spPr.solidFill that bar charts use).
      Falls back to CHART_DEFAULT_PALETTE by series index.
    - Line width from a:ln@w in EMU, converted via EMU_PER_IN
      directly (stroke widths are absolute in PPTX, not scaled
      with the slide). Default ~2 CSS px when unset.
    - Markers from c:marker. symbol != 'none' enables them;
      size in points from c:marker/c:size (default 5pt); marker
      fill from c:marker/c:spPr/a:solidFill overriding the line
      colour when present. All marker symbols approximate as
      circles for now (square / triangle / diamond / star is a
      Phase 4 polish).
    - Plot area uses the same 5 % / 15 % inset model as the bar
      chart so axes line up visually if both chart types appear
      on the same slide.
    - One path layer per series covers the whole plot area; the
      polyline d uses (i / (N-1)) · 100 for x and
      100 · (1 - v / maxV) for y. Single-category charts collapse
      to centre.
    - Marker layers are square-bounded circles centred on each
      data point.
    - Category labels emit centred under each data-point x, like
      the bar-chart category labels.

- convertChart dispatch grew a fourth branch (lineChart). The
  "common three" chart types are now all native.

Pure-Node unit test for _polylinePathD covers empty / single /
two / four-point / zigzag / fractional-rounding cases. All
passing.
2026-05-30 23:24:37 -04:00
thePR0M3TH3AN 21a5668e45 PPTX chart import Phase 2: pie + doughnut slice paths
c:pieChart and c:doughnutChart now emit native Artstr path-shape
layers — one per slice — instead of the placeholder image.

Mechanics:

- _sliceArcPath(cx, cy, outerR, startAngle, endAngle, innerR) builds
  the SVG d-attribute for a single slice. For pies it emits
  M cx cy L start A r r 0 large 1 end Z. For doughnuts (innerR>0)
  it emits M outerStart A outerArc L innerEnd A innerArc-back Z —
  a proper ring sector. Full-circle case (sweep ≈ 2π) emits two
  half-arcs so SVG can render it; full-ring case adds the inner
  pair.
- _readPieSliceColor honours PPTX precedence:
    1. c:ser/c:dPt[idx=N]/c:spPr/a:solidFill (per-slice override)
    2. c:varyColors val="1" (default for pies) → palette rotation
       indexed by sliceIdx using the existing CHART_DEFAULT_PALETTE
    3. c:ser/c:spPr/a:solidFill (uniform pie)
    4. Palette fallback as last resort.
- buildPieChartLayers picks the first c:ser, sums values for the
  total, reads holeSize (for doughnuts, default 50 %), reads
  firstSliceAng (default 0 = 12 o'clock), centres a square
  sub-bounds inside the chart's graphicFrame so the pie renders
  circular even on rectangular chart areas, then walks slices
  clockwise emitting one path-shape layer per non-zero value.
- convertChart dispatch grew two branches (pieChart, doughnutChart)
  that route into buildPieChartLayers with the isDoughnut flag.

Layer naming: "Chart 1 / Slice [Sales] (37)" so each slice in the
layer panel surfaces its category + value.

Pure-Node unit test for _sliceArcPath covers 14 cases — quarter
slice, major arc, doughnut ring sector, zero / negative / full
circle, full ring. All passing. Browser smoke test deferred until
CDP is available.
2026-05-30 09:25:11 -04:00
thePR0M3TH3AN 5588020812 PPTX chart import Phase 1: native bar / column rendering
Replaces the chart placeholder image with native Artstr layers for
the most common chart type — c:barChart in clustered grouping,
either col (vertical) or bar (horizontal) direction. The user's
gallery_template_layout_set.pptx slide 10 chart now produces 8
rect shape layers in #5E735D + #B05A44 plus 4 Q1-Q4 category-
label text layers instead of a single placeholder image.

What's in this commit (CHART_IMPORT_FEATURE.md §11 Phase 1):

- Cache-extraction helpers, no DOM-walking surprises:
    readCategoryLabels(catNode)  — c:strRef / c:multiLvlStrRef →
       string[] (collapses multi-level to the deepest level).
    readSeriesValues(valNode)    — c:val/c:numRef/c:numCache →
       number[], sparse indices filled with 0.
    readSeriesName(txNode)       — c:tx/c:strRef/c:strCache → string.
    readSeriesColor(serNode, theme, fallbackIdx) — explicit
       c:ser/c:spPr/a:solidFill (srgbClr or schemeClr via theme),
       falling back to a built-in 6-colour Office palette.

- buildBarChartLayers(barChartNode, gfBounds, chartLabel, ctx):
    - Reads series + categories + values from caches.
    - Plot area = chart bounds minus 5 % left/right inset and 15 %
      bottom inset for category labels (manualLayout in Phase 4).
    - For c:barDir val="col": catW = pa.w / N, S bars per category,
      each rect height = pa.h · value / maxValue.
    - For c:barDir val="bar": axes swapped.
    - Layer names: "Chart 1 / Bar [Q1, Baseline]" so the user can
      trace each rect back to its source data.
    - Category labels emitted below (col) or left of (bar) the plot
      area as text layers, 12pt centred / right-aligned.
    - Stacked / percentStacked groupings return null → fall through
      to placeholder (Phase 5).

- convertChart(chartDoc, gfBounds, chartLabel, ctx) dispatch reads
  the first chart-type child of c:plotArea and routes:
    barChart → buildBarChartLayers
    others   → null (placeholder fallback)

- convertGraphicFrame wired to try native chart conversion before
  emitting the placeholder. On success returns the layer array
  and bumps report.imported.{shapes,text}; on null returns the
  placeholder + UNSUPPORTED_CHART_PLACEHOLDER warning (existing
  behaviour) OR if the chart XML was readable but type wasn't
  supported, emits the new CHART_TYPE_UNSUPPORTED warning.

- ctx now carries files + slidePath so
  _readChartDocFromGraphicFrame can chase the c:chart r:id through
  slideRels to the chart XML in ppt/charts/chartN.xml and parse it.

- convertGraphicFrame now always returns an array (single-element
  for the placeholder path, N-element for native charts). walkSpTree
  updated to spread the result.

- New internals exposed for future tests: readCategoryLabels,
  readSeriesValues, readSeriesName, readSeriesColor,
  buildBarChartLayers, convertChart.

Browser smoke test still needed — pure-Node testing was blocked by
no @xmldom/xmldom on the system and the existing CDP-driven
harness needs Chrome with --remote-debugging-port=9222. Syntax
check + rich-text unit test both still pass.
2026-05-30 09:05:43 -04:00
thePR0M3TH3AN 5422f2c3e8 Draft PPTX chart-import spec
Spec for the next big arc: replacing the current chart-placeholder
behaviour (single image layer at PPTX_IMAGE_PLACEHOLDER_URL) with
native rendering — bars / wedges / lines as Artstr shape layers,
labels as text layers. Branch pptx-chart-spec.

Scope decisions baked in:

- Phase 1: clustered column + bar charts only. Bars as rect shape
  layers, category labels as text. Plot area derived via a fixed
  inset on the chart's graphicFrame bounding box (no
  c:manualLayout reading until Phase 4).
- Phase 2: pie / doughnut. Slice geometry emitted as kind:'path'
  with SVG arc d-attributes.
- Phase 3: line charts with optional markers.
- Phase 4: title / legend / data labels / value-axis ticks /
  gridlines.
- Phase 5: stacked + percent-stacked variants.
- Deferred to placeholder: scatter, bubble, area, radar, 3D,
  combo charts, trendlines, error bars, per-point overrides.

Architecture:

- New src/pptx-importer-charts.js with the per-type builders,
  loaded as an addendum to pptx-importer.js (or inlined — TBD
  during implementation; the dependency budget is small enough
  that a single file might be cleaner).
- convertGraphicFrame in pptx-importer.js gets a "try native
  before placeholder" branch: parse chart XML, dispatch on type,
  fall through to the existing placeholder + warning when the
  builder returns null.
- All chart data read from c:strCache / c:numCache. The embedded
  ppt/embeddings/*.xlsx workbook is ignored — caches are reliable
  in OOXML output and reading XLSX is its own rabbit hole.

Acceptance criteria for Phase 1: gallery_template_layout_set.pptx
slide 10 imports with 8 native bar layers (#5E735D + #B05A44
clustered across Q1-Q4) plus 4 category-label text layers,
matching what PowerPoint shows. Scatter / 3D / etc. still
placeholder cleanly without crash.
2026-05-30 08:57:12 -04:00
thePR0M3TH3AN 3af3064013 Editor: lower .freeLayer min size so thin imported lines render true
A long-standing .freeLayer { min-width: .25in; min-height: .18in }
rule (probably set in the early days for usability — a 0px-tall
layer is unclickable) was silently enforcing a 17 CSS-pixel floor
on every layer in the editor. The thumbnail / preview renderer
uses a different class so it wasn't affected — that's why deck
thumbnails looked correct but the live editor inflated PPTX
hairlines from ~1.6 CSS pixels to ~17.

Drop the floor to 1px on each axis. Still enough to prevent a
zero-area layer collapse, but small enough that imported
thin-rect lines (and any other genuinely-thin shape) render at
their actual dimensions.

Native-tool layers won't go below the user's drag tolerance
anyway, so this change has no UX effect on hand-drawn shapes.
2026-05-30 08:52:34 -04:00
thePR0M3TH3AN 4608f8b190 PPTX import: render straight lines as thin rects for true thickness
The Artstr line renderer floors stroke-width at 0.5 viewBox units,
which on a typical 5"-wide slide line bottoms out at ~2.5px — way
too thick for a real hairline (PPTX line widths are often 0.7–1pt
≈ 1–1.4px at 96dpi). Even with my dynamic strokeWidth fix the
floor was the binding constraint and lines came out 50–150% thicker
than the PowerPoint source.

Fix: for horizontal (cy=0) and vertical (cx=0) lines, switch the
layer representation from kind:'line' to kind:'rect' with a solid
fill of the source stroke colour and a bounding box sized to
exactly the source a:ln@w in inches. The rect's height (for
horizontal lines) or width (for vertical) IS the line thickness,
so what you see is pixel-accurate to PowerPoint.

The rect is centred on the original source line position
(bounds.y - strokeWidth/2 for horizontal, bounds.x - strokeWidth/2
for vertical) so it doesn't visually drift.

Diagonal lines keep kind:'line' — they can't be expressed as an
axis-aligned rect — and continue to use the fill-as-stroke-colour
quirk + (stroke.width / bounds.w) · 100 strokeWidth derivation,
subject to the renderer's 0.5-unit floor.

Editor implication: imported straight lines surface as
"Rectangle (<source name>)" layers in the panel rather than
"Line (<source name>)". Functionally identical for repositioning
+ recolouring; users who want a true line layer for further
editing (dash style, arrow heads, etc.) can re-draw with the
line tool. Future enhancement could attach a hint flag so the
editor offers "convert back to line".

Affects the 16 horizontal lines (slides 1–14, 16) and 4 vertical
lines (slide 15) in gallery_template_layout_set.pptx. They now
render at the exact PowerPoint thickness.
2026-05-30 08:48:30 -04:00
thePR0M3TH3AN 4f2ec01016 PPTX import: flat horizontal / vertical lines, no more slight tilt
PPTX expresses a horizontal line as cy=0 in the bounding box (the
line has no vertical extent) and a vertical line as cx=0. My
previous default of corner-to-corner (0,0)→(100,100) viewBox
endpoints turned that degenerate axis — after clamping to 2 pixels
of layer thickness — into a 0.26° slope across the line length,
which read on screen as a slight tilt + anti-aliasing fuzz that
also made the stroke look thicker than it really was.

Fix: detect the source extents and route endpoints by orientation:
  horizontal (cy=0, cx>0): x1=0, y1=50, x2=100, y2=50 (flat;
    flipH swaps x).
  vertical   (cx=0, cy>0): x1=50, y1=0, x2=50, y2=100 (flat;
    flipV swaps y).
  diagonal   (both > 0):   corner-to-corner with flipH/flipV
    swapping as before.

Also:
- Drop the min line thickness from 2px to 1px now that we don't
  need extra room for an angled diagonal to live inside.
- Centre the degenerate-axis clamp on the source position so the
  line stays exactly where PowerPoint put it (bounds.y -=
  MIN/2 + bounds.h = MIN, instead of just extending in +y).

Affects the 16 horizontal lines (slides 1-14, 16) and 4 vertical
lines (slide 15) in gallery_template_layout_set.pptx. The
remaining thickness overhead vs source (~30-40%) is the
renderer's hard 0.5-viewBox-unit stroke-width floor; truly exact
hairlines would need a different shape representation (e.g. thin
filled rect) which is a future enhancement.
2026-05-30 08:44:41 -04:00
thePR0M3TH3AN 9a7a0841df PPTX import: derive line stroke width from a:ln@w
Imported lines were rendering ~25x thicker than their PowerPoint
source because shape.strokeWidth was left at the _LINE_DEFAULTS
value of 6 viewBox units. The Artstr line renderer treats that
viewBox stroke as 6% of the layer's width, so a 4.58"-wide line
shape produced a 0.27" tall "bar" instead of a hairline. The
shapes were correctly positioned — they just looked like big
filled bars because the strokes were huge.

Fix: derive shape.strokeWidth from the source a:ln@w. Math:
the renderer sets vbH = 100·lh/lw so both viewBox axes share a
uniform scale of (lw_inches / 100) inches per viewBox unit. So
to target an on-screen stroke equal to the source thickness in
inches, set:
    shape.strokeWidth = (stroke.width_in_inches / lw_inches) · 100

The renderer's internal clamp to [0.5, 50] viewBox units acts as
a soft floor / ceiling for very wide or narrow lines (a 0.011"
hairline on a 5"-wide horizontal line bottoms out at ~0.5px
thick — visibly a line, just slightly thicker than source).

User-visible: re-importing gallery_template_layout_set.pptx now
yields lines at roughly their source thickness — the bottom-of-
slide #69746C hairlines look like hairlines, not bars.
2026-05-30 08:32:13 -04:00
thePR0M3TH3AN 29e731075e PPTX import: fix missing lines + add bent / curved connectors
Lines were getting silently dropped because their PPTX bounding
boxes are degenerate (cy=0 for horizontal lines, cx=0 for vertical),
and convertPresetShape's zero-area filter treated that as "skip".
Lines need the filter to be kind-aware: a 0-thickness layer for a
line is legitimate; for a rect it isn't.

Three fixes shipped together:

1. Move the zero-area filter to after the shape-kind lookup, then
   exempt shape.kind === 'line' from it. The bounding box is then
   clamped to a 2-pixel minimum thickness (~2/96 in) on the
   degenerate axis so the Artstr renderer has a non-zero pixel
   container to draw the SVG into (a 0-height layer renders into
   a 0-height DOM box regardless of overflow:visible inside).

2. Quirk of Artstr's line renderer: the inner <line> uses the
   layer's `fill` paint as its stroke colour, not the layer's
   stroke field (see buildShapeSvgInner in index.html). PPTX
   puts the line colour on a:ln, so for shape.kind === 'line'
   the imported stroke colour is now copied into the layer fill.
   Lines previously came in with a transparent fill + a real
   stroke that the renderer ignored — i.e. invisible. Now they
   render the right colour.

3. Add bent / curved connectors (bentConnector2..5, curvedConnector
   2..5) to PPTX_PRST_TO_ARTSTR as approximations of a straight
   line, with an UNSUPPORTED_CONNECTOR_APPROXIMATED warning so the
   user knows the joints / curves weren't preserved. The deck the
   user reported doesn't use these but other decks do.

Pure-Node unit test for _runToHtml (added in the prior commit)
still passes — the changes are confined to convertPresetShape.

User-visible: the 16 lines in gallery_template_layout_set.pptx
that were missing should now appear at the right positions with
their actual colour (e.g. the bottom-of-slide hairline at
#69746C).
2026-05-30 08:16:12 -04:00
thePR0M3TH3AN b6cf4f38eb PPTX import Phase 7: per-run rich text spans
Mid-paragraph styling now survives the import. Before this commit,
_readTxBody collapsed every paragraph to its first run's style; a
title like "Gallery Deck System" where one word was bold would
lose the bold. Now each run is emitted as HTML with inline
<b> / <i> / <span style="..."> wrappers, but only where it
disagrees with the layer-level dominant style — so plain runs
stay as plain text and the HTML stays clean.

Mechanics:

- _readParagraphRuns now returns an array of { text, style }
  per a:r / a:fld (text-bearing) or a:br (synthetic '\n' run with
  null style). null style means "inherit layer defaults" — the
  HTML builder emits unwrapped escaped text for these.
- _runsToPlainText is the new helper for callers that only want
  text (the notes-slide reader is the only one today).
- _runToHtml(run, dominant) builds the per-run HTML:
    - inline span for font-size / color / font-family overrides
      that disagree with the dominant; inner-most wrap.
    - <i> / <span style="font-style:normal"> for italic deltas.
    - <b> / <span style="font-weight:normal"> for bold deltas;
      outermost wrap.
  font-family values with spaces are escaped through &quot; so
  inner double-quotes can't terminate the style="..." attribute.
- _readTxBody picks the first explicit-rPr run anywhere in the
  body as the dominant style, then emits per-paragraph segments
  joined contiguously and paragraphs joined with <br>.
- The notes-slide reader uses _runsToPlainText to keep its plain-
  text concatenation behaviour.

Verified with a pure-Node unit test of _runToHtml covering 10
cases: plain run, mid-paragraph bold, color override, font-family
with spaces (HTML-entity quoted), size + family + italic combo,
HTML escaping, embedded \n → <br>, null-style inheritance, the
reverse-override "not-bold inside bold dominant", and an empty
dominant. All passing.

Browser CDP integration test isn't in this commit (the dev
server / Chrome session was offline at test time). The refactor
is otherwise structural: _readTxBody returns the same shape, and
the layer-level dominantStyle still drives the layer's fontSize /
color / family / align / bold / italic fields, so existing decks
should produce the same layer-level output with strictly richer
per-run HTML.
2026-05-30 08:06:57 -04:00
thePR0M3TH3AN 24338c4f12 TODO: move PPTX import out of unbuilt, capture remaining follow-ups
Phases 0–6 of the PPTX import arc shipped on pptx-import (deck
shell + slide backgrounds + text + notes + image / chart / table /
SmartArt placeholders + preset shapes with fills/strokes + group
flattening + theme color resolution + master/layout bg inheritance
+ line flip handling). Move the entry out of "Drafted-but-unbuilt
features" and add a "PPTX import polish" section listing what's
still open:

- Phase 7 per-run rich text spans (today the first run's style
  applies to the whole text box; mid-paragraph styling is lost).
- Richer import report modal (per-slide warning summary, group
  counts).
- Native chart support — render OOXML chart XML into Artstr layers
  rather than placeholder images. Its own multi-phase arc.
- Phase 6+ inheritance gaps: gradient / picture backgrounds, font
  scheme (+mj-lt → real font), placeholder geometry inheritance.
- a:custGeom → custom SVG-path conversion for arbitrary shapes
  (currently fall back to rect + warning).
- Test deck library under test/pptx/ to catch future-phase
  regressions automatically.
- Eventual merge of pptx-import → main.
2026-05-30 07:56:56 -04:00
thePR0M3TH3AN 38894031bf Mark Phase 6 done in PPTX spec 2026-05-29 15:37:52 -04:00
thePR0M3TH3AN a448afc077 PPTX import Phase 6: theme colors + bg inheritance + line flips
Three related upgrades to fidelity:

Theme color resolution:
- loadThemeForSlide(files, slidePath) chases the OOXML inheritance
  chain (slide → slideLayout → slideMaster → theme) and parses the
  a:clrScheme into a name → hex map for accent1..6, dk1/2, lt1/2,
  hlink, folHlink. sysClr entries fall back to @lastClr. The map
  also honours the bg/tx → lt/dk aliases that PPTX uses for light
  themes. Themes are cached by zip path so a 50-slide deck sharing
  one theme reparses zero times after the first slide; the cache
  is cleared at the start of each importPptxFile.
- convertColor(node, theme) now accepts a:srgbClr, a:schemeClr, and
  a:sysClr. schemeClr@val is resolved through the theme map.
- After a base hex is chosen, _readColorModifiers + _applyColorModifiers
  apply lumMod / lumOff / tint / shade in HSL (PPTX values are in
  1/1000 of a percent). Verified: accent1 (#ff0000) with lumMod
  60000 → #990000 — matches PowerPoint's render.

Background inheritance:
- readSlideBackground now falls back through the slide → layout →
  master chain when the slide has no direct p:bg or only a p:bgRef.
  Theme is passed through so schemeClr-based backgrounds also
  resolve. Verified: a slide with no own bg correctly inherits the
  master's #abcdef.

Line flip handling:
- a:prstGeom prst="line" / "straightConnector1" go corner-to-corner
  of the bounding box. PowerPoint flips them via xfrm@flipH /
  @flipV; the Artstr line shape stores its segment endpoints in a
  0-100 viewBox, so we set x1/y1/x2/y2 to the right diagonal at
  conversion time:
    no flip: (0,0)→(100,100)
    flipH:   (100,0)→(0,100)
    flipV:   (0,100)→(100,0)
    both:    (100,100)→(0,0)
  Previously every line came in as a flat horizontal at y=50; now
  diagonals show up correctly.

Plumbing: theme + groupXfrm + slideRels all live on ctx and are
threaded through walkSpTree, the converters, readFill, readStroke,
_readTxBody, _readParagraphRuns, _readRunStyle.

Real-deck regression (gallery_template_layout_set.pptx): 16
slides, 156 text, 64 shapes, 1 chart placeholder, 0 groups — all
counters identical to Phase 5. Synthetic theme-inheritance deck
passes every check (bg inheritance, scheme color, lumMod modifier,
line flip).
2026-05-29 15:37:40 -04:00
thePR0M3TH3AN 1d0239f022 Mark Phase 5 done in PPTX spec 2026-05-29 15:26:55 -04:00
thePR0M3TH3AN 6a731d5d01 PPTX import Phase 5: flatten p:grpSp into slide-relative layers
Shapes nested inside p:grpSp were silently dropped in Phase 4 —
walkSpTree only knew about top-level sp / pic / graphicFrame
children. Phase 5 recurses into groups with a composed coordinate
transform so every child shape lands at its correct slide
position regardless of nesting depth.

Mechanics:

- IDENTITY_GROUP_XFRM { offX:0, offY:0, sX:1, sY:1 } is the
  starting transform for shapes at the top of a slide.
- enterGroup(parent, groupXfrmNode) composes a new transform when
  walking into a p:grpSp. PPTX semantics:
    - a:off + a:ext  give the group's position + size in the PARENT
      child-coord space.
    - a:chOff + a:chExt define the child-coord space INSIDE this
      group (the coord system the group's direct children live in).
  Composition first lifts the group itself into slide-EMU through
  the parent transform, then derives scale factors so a child at
  childExt = chExt maps onto groupSlideSize.
- readXfrm now accepts an optional groupXfrm (defaults to identity)
  and converts child-EMU → slide-EMU → inches in one shot. All four
  converters (preset / text / picture / graphicFrame) pass ctx.groupXfrm
  through unchanged.
- walkSpTree dispatches p:grpSp by reading grpSpPr/a:xfrm, composing
  the new transform via enterGroup, recursing into the group node
  itself (groups host their children directly), then restoring the
  prior transform on the way out. Sibling groups + top-level shapes
  see the original transform — no leakage.
- report.imported.groups counts each p:grpSp entered (useful for
  the import-report modal once we wire it).

Synthetic smoke test verifies the math exactly:
- Group at slide-EMU (1M, 1M) sized 4M x 2M with child-coord space
  0..2000 x 0..1000.
- Child A at child-coord (0, 0) ext (1000, 500)
    → expected (1.64, 1.64) at 3.28 x 1.64 inches  ✓
- Child B at child-coord (1000, 500) ext (1000, 500)
    → expected (4.92, 3.28) at 3.28 x 1.64 inches  ✓
- Outside sibling at slide-EMU (8M, 5M) ext (1M, 500K)
    → expected (13.12, 8.20) at 1.64 x 0.82 inches ✓
    (proves transform doesn't leak past the group)

Real-deck regression check (gallery_template_layout_set.pptx):
16 slides, 156 text layers, 64 shapes, 1 chart placeholder, 0
groups (this deck doesn't use groups) — counts identical to
Phase 3 output. No regression.
2026-05-29 15:26:44 -04:00
thePR0M3TH3AN d355c21792 Mark Phase 3 done in PPTX spec 2026-05-29 15:20:38 -04:00
thePR0M3TH3AN 3237a69247 PPTX import Phase 3: image + chart / table / SmartArt placeholders
Every p:pic and every p:graphicFrame on a slide now becomes an
Artstr image layer pointing at the shared placeholder URL
(https://i.postimg.cc/9fY3n60q/example-image-replace-me.png). No
binary media is ever decoded from the .pptx package — the user
replaces each placeholder with their own URL after import, which
keeps imported decks Nostr-publishable out of the box and honours
Artstr's "remote URLs only" image model.

Pictures (p:pic):
- convertPicture reads spPr/xfrm for geometry, resolves the
  a:blip@r:embed relationship through the slide's _rels file, and
  uses the original media filename (basename of the relationship
  Target, URL-decoded) in the layer name: "Image: image3.png".
- Missing embed relationship → MISSING_IMAGE_RELATIONSHIP warning;
  the placeholder still lands.

GraphicFrames (p:graphicFrame):
- convertGraphicFrame reads xfrm directly off graphicFrame (not
  spPr — that's a graphicFrame quirk), then dispatches on
  a:graphic/a:graphicData@uri:
    • chart   → "Chart placeholder: <name>"      + UNSUPPORTED_CHART_PLACEHOLDER     + placeholders.charts++
    • table   → "Table placeholder: <name>"      + UNSUPPORTED_TABLE_PLACEHOLDER     + placeholders.tables++
    • diagram → "SmartArt placeholder: <name>"   + UNSUPPORTED_SMART_ART_PLACEHOLDER + placeholders.smartArt++
    • else    → "Unknown object placeholder"     + UNSUPPORTED_UNKNOWN_PLACEHOLDER   + placeholders.unknown++

walkSpTree now dispatches sp / pic / graphicFrame. readSlideRels
loads the per-slide _rels file into a Map<rId, {target,type}> once
per slide and passes it through ctx.slideRels.

Smoke-tested:
- gallery_template_layout_set.pptx: slide 10's chart now appears
  as 'Chart placeholder: Chart 0' (image layer, postimg URL, 9.3 x
  5.85 inches at 1.2, 3.08) with an UNSUPPORTED_CHART_PLACEHOLDER
  warning in the report.
- Synthetic multi-placeholder deck (1 picture + chart + table +
  SmartArt) exercises every branch: Image: image1.png from rels
  basename; chart / table / SmartArt placeholders with the right
  names and counters; positions and dimensions all correct in
  inches.
2026-05-29 15:20:28 -04:00
thePR0M3TH3AN 8cf79f0fe0 Mark Phase 2 done in PPTX spec 2026-05-29 15:13:27 -04:00
thePR0M3TH3AN 49d6697660 PPTX import Phase 2: text shapes + speaker notes
Two paths land together because they share paragraph/run parsing.

Text shapes (p:sp with p:txBody):
- _readTxBody walks a:p paragraphs and a:r / a:fld runs, joining
  paragraph text with \n internally then escaping + converting to
  <br>-joined HTML on the way out.
- _readRunStyle pulls the first run's a:rPr → fontSize (sz is
  hundredths-of-pt → pt), bold (b), italic (i), color
  (a:solidFill/a:srgbClr), and explicit font family
  (a:latin@typeface, but skip theme placeholders like '+mj-lt').
- a:pPr@algn → Artstr alignment via PPTX_ALIGN_TO_ARTSTR map
  (l/ctr/r/just). First paragraph's alignment wins for the whole
  box.
- convertTextShape emits an Artstr text layer with the dominant
  style applied to the whole box; xfrm provides bounds.
- Shapes without explicit xfrm (text inheriting placeholder
  geometry from a layout / master) warn TEXT_STYLE_APPROXIMATED
  and skip — proper placeholder resolution is Phase 6.
- walkSpTree dispatches p:sp to convertTextShape if a p:txBody
  child is present, otherwise to convertPresetShape. Bumps
  report.imported.text or .shapes accordingly.

Speaker notes:
- readSlideNotes(files, slidePath, slideIndex, report) reads the
  slide's _rels/<name>.xml.rels, finds the .../notesSlide
  relationship, resolves the path, and walks the notesSlide's
  spTree concatenating every p:sp/p:txBody's plain text.
- Filters out the slide-number stamp (PowerPoint embeds the
  current slide number as a "1" / "2" text on the notes-slide
  image placeholder; matching ^\d+$ drops it).
- Result goes into slide.notes, which Presenter Mode already
  renders. report.imported.notes counts non-empty slides.

Smoke-tested against gallery_template_layout_set.pptx (16 slides):
- 156 text layers imported, zero warnings.
- Slide 1 title 'Gallery\\nDeck System' lands as
  'Gallery<br>Deck System' at 49pt Aptos Display bold #17202a.
- Subtitle EDITORIAL TEMPLATE KIT lands at 8.5pt Aptos bold
  #b05a44 — exact source color.
- 0 notes (this template-only deck doesn't ship with notes).
Synthetic deck still produces an empty deck shell with no
regressions.
2026-05-29 15:13:16 -04:00
thePR0M3TH3AN feace5e78c PPTX import: fix unit conversion — layer coords go to inches, not pixels
Phase 4 was emitting layer x/y/w/h in pixel units, but Artstr's
canvas system (slide / customart / deck) treats layer coordinates
as *inches* — it derives the on-screen canvas size by dividing
slide.width by 96 (customArtCanvasInches() at line 6813). So a
shape at x=1000, w=500 was being rendered as "1000 inches from the
left, 500 inches wide" — about 96x bigger than intended.

Fix: introduce TARGET_W_IN / TARGET_H_IN (20 / 11.25, i.e. 1920/96
and 1080/96) and rescale EMU through those instead of TARGET_W /
TARGET_H. slide.width / slide.height stay at 1920 / 1080 because
that's what the engine expects in slide.width.

readStroke also assumed pixel units — Math.max(1, ...) clamped
strokes to "at least 1 inch" after the scale flip, which would
have produced impossibly thick lines once the rest of the fix
landed. Now clamps to 1/96 inch (~1 pixel) minimum.

After fix, the gallery_template_layout_set.pptx full-slide backing
rectangle imports at exactly 20.00 x 11.25 inches with a 0.02
inch (~2 pt) stroke, which matches the source slide dimensions.
2026-05-29 15:06:02 -04:00
thePR0M3TH3AN 5d86d9ced7 Mark Phase 4 done in PPTX spec 2026-05-29 15:01:13 -04:00
thePR0M3TH3AN 3def41b184 PPTX import Phase 4: basic preset shapes with fills + strokes
Walks each slide's p:cSld/p:spTree and turns every non-text-bodied
p:sp with a:prstGeom into an Artstr shape layer. After Phase 1+1.5
slides imported as colored backgrounds with no layers; after this
they come in with most of their visible design.

What landed in src/pptx-importer.js:

- PPTX_PRST_TO_ARTSTR table mapping rect, roundRect, ellipse,
  triangle, line, straightConnector1, star5, hexagon, pentagon
  to Artstr shape kinds. Unknown prst values fall back to rect
  with an UNSUPPORTED_SHAPE_APPROXIMATED warning.
- convertColor: a:srgbClr → '#rrggbb'. Scheme / preset / system
  colors return null and trigger theme-resolution warnings later.
- readFill: walks spPr for a:solidFill / a:noFill, returns an
  Artstr fill descriptor. Direct-child-only lookups so a fill
  nested in a:ln doesn't leak through.
- readStroke: walks spPr/a:ln for solid color + width (EMU→px via
  the slide scale) + dash style. Maps the OOXML dash family to
  Artstr's three states (solid / dashed / dotted).
- readXfrm: a:xfrm/a:off + a:xfrm/a:ext → {x,y,w,h} in slide-pixel
  coords; a:xfrm@rot (1/60000 deg) → degrees. Skips zero-area
  shapes.
- convertPresetShape: pulls the above together, names the layer
  with the source p:cNvPr@name for traceability, skips shapes that
  carry a p:txBody (Phase 2 will handle text shapes).
- walkSpTree: top-level walk of spTree children. p:grpSp is Phase
  5; p:pic / p:graphicFrame are Phase 3.
- importPptxFile wires the walk into the per-slide loop; layer ids
  use a per-import counter prefix so two imports can't collide.

Smoke-tested against the user's gallery_template_layout_set.pptx
(16 design-template slides):
- 16 slides, 16 backgrounds, 64 shape layers, 0 warnings.
- Slide 1 imports the cream backing rect + green rect + gold and
  terracotta ellipses + two cream rounded rectangles, with their
  exact source colors (#5e735d, #c8a658, #b05a44, etc.).
- Editor loads in deck mode with the layers visible.
Synthetic 3-slide pptx still produces white slides + 0 layers, so
no regression on the Phase 1 baseline.
2026-05-29 15:00:57 -04:00
thePR0M3TH3AN 32af7f0e96 PPTX import P1.5: slide backgrounds (direct solid fills)
Quick win on top of Phase 1: pull each slide's direct background
color out of p:bg/p:bgPr/a:solidFill/a:srgbClr and set it on the
Artstr slide instead of always leaving the default white.

Scoped narrowly:

- Direct slide backgrounds only. p:bgRef (layout/master inheritance)
  emits LAYOUT_INHERITANCE_PARTIAL and stays white — Phase 6.
- solidFill + srgbClr only. Gradient, picture, and scheme-color
  fills emit THEME_COLOR_UNRESOLVED and stay white — Phase 6.
- Parse failures on a per-slide basis emit SLIDE_PARSE_FAILED and
  fall through to white; the deck shell still imports.

Counts successful backgrounds in report.imported.backgrounds so the
import report can show "16 backgrounds imported".

readSlideBackground is exposed via _internals for future-phase tests.

Verified end-to-end against the user's gallery_template_layout_set
deck (16 slides, design-template set heavy on background colors):
all 16 slides now import with their actual cream / themed colors
(e.g. slide 1 = #fbf8f0) instead of plain white, with zero warnings.
Synthetic-pptx smoke test (no p:bg) still produces white slides.
2026-05-29 14:55:21 -04:00
thePR0M3TH3AN 71e5370e36 Mark Phase 1 done in PPTX spec 2026-05-29 14:33:29 -04:00
thePR0M3TH3AN 2cc2b85883 PPTX import Phase 1: deck-shell parser
Replace the Phase 0 "not implemented" stub with a working deck-shell
import. A .pptx file in becomes a templateMode:'deck' payload out,
one empty Artstr slide per PowerPoint slide, with no layer extraction
yet (Phase 2+ adds text, notes, images, shapes).

What landed in src/pptx-importer.js:

- readPptxPackage(file): file -> ArrayBuffer -> fflate.unzipSync ->
  { [path]: Uint8Array }. Throws on missing fflate or invalid zip.
- parseXml(xmlText): DOMParser wrapper that detects <parsererror>
  and re-throws so silent malformed-doc cases don't slip through.
- pptxTargetToZipPath(basePath, target): resolves OOXML relationship
  Target values against the owning part's path. Handles relative,
  absolute-in-package, and ./.. segments.
- readSlideSize(presDoc): reads p:sldSz cx/cy (EMU). Falls back to
  16:9 widescreen default (12192000 x 6858000) when absent.
- readSlideRefsInOrder(presDoc, presRelsDoc): walks p:sldIdLst for
  canonical order, resolves each r:id through the rels file to the
  actual zip path of the slide XML. Handles both getAttributeNS for
  the relationships namespace and the plain "r:id" attribute form.
- readPresentationInfo(files, report): bundles size + slide refs.
  Adds a LARGE_DECK warning at >100 slides.
- importPptxFile(file, report): reads the package, parses
  presentation info, emits one empty deck slide per ref at 1920x1080
  with #ffffff bg + ignoreDeckTheme:true. Yields to RAF every 10
  slides so a 50+ slide deck doesn't lock the UI. Returns a
  templateMode:'deck' payload ready for loadProjectFromText.

Internals are exposed via window.ArtstrPptxImporter._internals for
future-phase tests and harnesses.

Smoke-tested via CDP against a synthetic .pptx built in-browser with
fflate.zipSync:
- 3-slide deck imports with correct count, dims, bg, ignoreDeckTheme.
- 5-slide deck round-trips through loadProjectFromText: editor
  comes up with templateMode='deck', 5 slides, body has template-deck
  class (Deck Builder live).
- Garbage bytes throw a clean "invalid zip data" error instead of
  silently producing a zero-slide deck.
2026-05-29 14:32:56 -04:00
thePR0M3TH3AN e8e96e2a3d Drop orphan vault-fix scaffolding left over from Phase 0 commit
The pptx-import branch's Phase 0 commit (d710b84) accidentally
included an earlier in-flight vault-fix attempt that introduced:

- restorePremiumUnlockFromReceipts() — a wrapper around the
  receipt-recovery decrypt path
- _pendingVaultItems / _writePendingVaultItems /
  _rememberPendingVaultItem / _clearPendingVaultItem — a retry
  outbox keyed by VAULT_PENDING_KEY ('casewrap-pending-purchase-
  vault-items')
- getPremiumEnvelope() — a parsed-envelope helper

The vault fix that actually shipped to main (99d83e2) solved the
same problem more directly: receipt fast-path inlines the
decrypt-and-back-fill, and upsertPurchaseVaultItem now propagates
publish errors instead of silently returning null. After the
merge of main into pptx-import resolved in favour of main, none
of these helpers had callers — they were pure dead code.

Removing them keeps the file honest. The retry outbox can come
back later if (1) + (2) + (3) on the spec roadmap aren't enough,
but it should land as a deliberate feature with its own tests
rather than as orphan scaffolding nobody is calling.

Smoke-tested via CDP: orphan helpers are now `undefined` at
runtime, _vaultRelayUnion (from main's vault fix) is live, and
Phase 0 PPTX wiring still loads cleanly (button visible, fflate
+ ArtstrPptxImporter lazy-load on first click, file picker fires,
placeholder URL surface intact).
2026-05-29 14:28:54 -04:00
thePR0M3TH3AN 8f500f50af Merge branch 'main' into pptx-import 2026-05-29 14:26:20 -04:00
thePR0M3TH3AN 99d83e291a Make purchase vault durable across devices / domains
Fix three load-bearing gaps in the premium-templates vault that
together made paid unlocks invisible on any session other than the
one where the purchase happened (e.g. localhost vs. artstr.org with
the same login).

1) Receipt fast-path now re-derives and decrypts the payload.
   The soft-gate KDF is deterministic from public material + the
   static client pepper, so a valid kind-9735 receipt is sufficient
   to recover the full decrypted payload locally — no surviving
   vault required. Previously the fast-path only set unlocked:true
   without the payload, so a card could appear unlocked but Use /
   Fork had nothing to fork. Now it runs Premium.decryptPayload on
   the envelope when a receipt matches by address (or event id),
   stores the full payload + preview in the local cache, AND
   opportunistically back-fills the vault so the next device
   doesn't have to redo the receipt scan.

2) Vault upsert is synchronous-to-success with visible failures.
   The unlock success path used to fire-and-forget upsert with
   .catch(console.warn). If the vault publish silently failed —
   relay rejection, narrowed relay list, etc. — the user still saw
   "Unlocked ✓" while no other device would ever see the purchase.
   Now the unlock awaits upsert, shows a "Syncing to your purchase
   vault…" interim toast, and on failure replaces the success toast
   with a clear error explaining the unlock is device-local only.
   upsertPurchaseVaultItem also stops swallowing publish errors so
   callers see them.

3) Vault traffic uses a wider relay union.
   New _vaultRelayUnion() returns the user's configured relays
   unioned with DEFAULT_NOSTR_RELAYS (deduped). _publishVaultEvent,
   loadPurchaseVault, and relayFastPathUnlockCheck all use it, so
   vault events stay reachable when a user narrows their relay
   list or runs the app on a different domain. NIP-65 write relays
   can be folded in later by editing one helper.

Smoke-tested via CDP:
- _vaultRelayUnion correctly merges + dedupes user + default relays.
- Receipt fast-path now finds a synthetic kind-9735 receipt by `a`,
  decrypts the matching premium event end-to-end, and writes a
  cache entry with the full original payload (title, layers, etc).
- upsertPurchaseVaultItem propagates publish failures instead of
  returning null — callers can now react to vault sync errors.

Together these mean: an existing purchase will auto-recover the
next time the Premium tab loads on any browser / domain that
shares the same Nostr login, just from the receipts already on
relays. No re-payment, no manual vault recovery.
2026-05-29 14:20:15 -04:00
thePR0M3TH3AN 24fb160e99 Mark Phase 0 done in PPTX spec; pausing for vault-fix work on main 2026-05-29 13:54:10 -04:00
thePR0M3TH3AN d710b84d19 PPTX import Phase 0: plumbing + lazy-load shell
Sets up the import-pipeline plumbing without doing any actual PPTX
parsing yet. Acceptance criteria from the spec's Phase 0:

- Vendor fflate (0.8.2 UMD) at src/vendor/fflate.min.js so the
  importer can unzip .pptx packages without a build step. fflate
  exposes window.fflate via the UMD shim.
- Add src/pptx-importer.js exposing window.ArtstrPptxImporter with
  PPTX_IMAGE_PLACEHOLDER_URL, makePptxImportReport, and a stub
  importPptxFile that throws "not implemented yet — Phase 1 lands
  the deck-shell parser." Phase 1 will fill this out.
- Wire loadScriptOnce + ensurePptxImporterLoaded in index.html.
  Both fflate and pptx-importer.js are lazy-loaded on the first
  click of Import PPTX so the main bundle stays lean for users
  who never use this path. Subsequent clicks are no-ops.
- Add an Import PowerPoint (.pptx) button + hidden file input to
  the Deck Builder's Slides fieldset, next to the existing
  Import slide buttons. Click flow:
    button click -> ensurePptxImporterLoaded (with a "Loading…"
    label on the button while scripts arrive) -> hidden file
    input click -> change -> onPptxFileChosen -> import + report.
- Add the pptxImportReportModal shell with file name, summary
  counts, placeholder note, and a collapsible warnings list.
  Render is driven by the report data shape defined in spec
  section 13. Close on background click or Close button.

Smoke-tested via CDP: first click lazy-loads fflate +
pptx-importer; subsequent calls reuse cached promises; file
picker triggers; Phase 0 stub returns the expected
not-implemented error which surfaces as a toast.
2026-05-29 13:51:09 -04:00
thePR0M3TH3AN 467fbcf5e4 Draft PPTX import spec
Adds docs/PPTX_IMPORT_FEATURE.md as the canonical plan for the next
big arc: a phased editable .pptx importer that lands PowerPoint decks
as editable Artstr deck JSON.

Key decisions baked in:

- Images and unsupported objects (charts / SmartArt / tables / video)
  import as placeholder image layers pointing at a hardcoded URL
  (postimg.cc for now). Original PowerPoint media filenames carry
  over into the layer name so the user can correlate placeholders
  with their source deck.
- fflate (~10 KB) vendored as src/vendor/fflate.min.js. JSZip is too
  heavy for the single-file ethos.
- Importer code lives in src/pptx-importer.js as an IIFE that
  exposes window.ArtstrPptxImporter. The Import PPTX button
  dynamically injects both scripts on first click, so the main
  index.html bundle does not grow for the 90 % of users who never
  import a PPTX.
- Notes folded into Phase 2 alongside text (single extra relationship
  lookup, Presenter Mode already renders slide.notes).
- No Blossom / NIP-96 prereq, no base64 bloat, no publish-size guard.
  Imported decks are normal Artstr deck payloads with remote URLs.
- Import-only for this arc. PPTX export (Artstr -> .pptx) is a
  separate later arc with its own complexity.

TODO.md gets a Drafted-but-unbuilt entry pointing at the new spec.
2026-05-29 11:21:54 -04:00
thePR0M3TH3AN 3b47a314c0 Catch up TODO + README + spec banner on the premium-templates arc
Pre-merge docs sweep before zap-gated lands on main:

- TODO.md: move Premium out of "Drafted-but-unbuilt" into "Shipped
  (recent)" with a full summary of what landed (soft-gate, watermarked
  previews, NWC split-zap with 30% platform fee, purchase vault with
  auto-split, profile menu, login picker, Settings, mobile polish).
  Leave Phase 4 E2E + a few confirm()-modal replacements as a new
  "Premium-templates polish" section.
- docs/PREMIUM_DESIGNS_FEATURE.md: flip the status banner to
  "Shipped on zap-gated, merged into main 2026-05-29" and map each
  spec step (0-4) to where it lives in src/.
- README.md: add a Premium encrypted designs section; mention NWC,
  vault, login picker, profile dropdown, and Settings modal in the
  Nostr / Lightning sections; drop "NWC zaps / zap-gated templates"
  from the unbuilt list in the Files section.
2026-05-29 10:37:27 -04:00
thePR0M3TH3ANandClaude Opus 4.7 1c3160ca74 Mobile polish for the community browser, cards, and preview pane
Adam: "Take a pass at the site and improve the overall mobile
look. Focus on the template browser and the cards and the design
previews."

Mobile-before screenshots (iPhone-14 viewport) showed three real
problems:

  1. Modal-tabs row overflowed the screen edge — Search and My
     Designs cut off; users couldn't reach them.
  2. Type-filter chips + category + language + trust selects all
     wrapped to 3-4 rows, eating ~150px of vertical space before
     a single card showed.
  3. Preview-pane header layout squeezed the title into a tall
     narrow column on the left because the action row insisted on
     filling the right.

All of these stay desktop-first; the editor itself isn't optimised
for phones (Adam: "I don't expect users to use mobile to create
designs as much as just viewing what's been created"). The new
@media (max-width: 600px) block tightens only the community-
browsing surfaces:

Modal sizing
  - Cards / inner content now use min(98vw, 640px) — 2vh margins
    instead of 4vh, so the modal uses the screen rather than
    floating in dead space.
  - Padding reduced. Close button moves a few px tighter to the
    corner.
  - #nostrModal .modalHeader switches to flex-direction:column so
    the title sits on its own row above the actions. Fixes the
    "title squished into a narrow column" symptom.

Horizontal scroll instead of wrap
  - .modalTabs and .feedTypeFilter both go flex-wrap:nowrap with
    overflow-x:auto. Users swipe through the tabs / chips
    horizontally, which is the standard mobile gesture for crowded
    nav. Thin custom scrollbar tracks.

Cards
  - One column. Tighter gaps (10px).
  - Thumbs cap at height:260px / max-height:60vw — without this,
    a 360px-wide phone gives a 360px-tall square thumb that fills
    the entire viewport with one card.
  - Card-actions row gets smaller font + tighter padding so the
    four buttons (▲ Preview Use/Fork ⋯) stay on one row at common
    titles.

Preview pane
  - Tighter padding inside the gradient stroke (10px instead of
    14px) so the preview content has more room.
  - Ribbon slightly smaller font on mobile to match the new
    proportions.

Verified visually at 390x844 (iPhone-14): tabs scroll cleanly,
chips scroll cleanly, the Premium card fills the column with its
watermarked JPEG + corner ribbon + Unlock CTA in the right
proportions, and the preview pane stacks title-above-actions
without crushing either. Regression smoke (existing editor) still
passes at desktop widths.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-29 10:14:30 -04:00
thePR0M3TH3ANandClaude Opus 4.7 92661a11a9 Preview pane: gradient stroke + PREMIUM ribbon to match the feed card
The preview pane (the larger view that opens when you click a feed
card's preview) already shows the watermarked JPEG + the Unlock
CTA for premium designs, but it lacked the visual cue that made
premium cards stand out on the wall.

CSS
  - #nostrPreviewPanel.premiumPreviewPane reuses the same 2px
    gradient-stroke trick as .feedCardPremium: two layered
    background images (solid panel + 135° yellow→magenta→cyan)
    with background-clip:padding-box,border-box on a transparent
    2px border. Plus the same soft outer glow.
  - Dark-mode variant swaps the inner panel colour.
  - New .premiumPreviewRibbon — same gradient pill as
    .feedCardPremiumRibbon but slightly larger to suit the bigger
    surface; positioned top:10 left:10 inside the pane.

JS
  - openDesignPreview adds class premiumPreviewPane and appends
    the ribbon span when row.premium is set. Removes the class
    on non-premium rows so styling doesn't stick from a previous
    preview.
  - Ribbon text mirrors the feed card: "⚡ PREMIUM" when locked,
    "⚡ UNLOCKED" when the design has been paid for / decrypted.

Tested via CDP: preview pane opened on a synthetic premium event
has classes "premiumPreviewPane", ribbon text "⚡ PREMIUM", and a
linear-gradient in the computed background-image. Screenshot
confirms the same visual treatment as the feed card.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-29 10:04:57 -04:00
thePR0M3TH3ANandClaude Opus 4.7 1ea611b44e Feed cards: rebuild when the author's profile arrives (so the Tip button shows)
Adam reported the ⚡ Tip button was missing from cards in the
template browser even though the author had a Lightning address.

Root cause: diffWallCards reuses an existing card by event id and
never rebuilds it. The author's profile is fetched AFTER the
template events in fetchFeedAndReactions (the enrichment step), so
the first card render runs when _feedCache.profiles[author] is
still undefined. makeFeedCardTipButton returns null in that case
(no lud16 → no tip button). When the profile arrived later,
notifyFeedProgress kicked off a rerender, but diffWallCards reused
the tip-less DOM card unchanged, and the button never appeared.

Fix
  - buildFeedCardForRow now stamps each card with a small string
    encoding the row's mutable view-state: profile-has-LN flag,
    reaction count, zap-total bucket, premium decrypted/locked
    state, broken-preview flag.
  - diffWallCards reads the stamp on the existing card and the
    fresh stamp on the new row. If they differ, it rebuilds the
    card and replaceWith() into place. Cards built through paths
    that don't set a stamp (legacy code paths) are reused as
    before — backwards-compatible.

Same mechanism naturally handles other "card refreshes when…"
cases that previously didn't: when zap receipts stream in (zap
bucket changes), when a reaction count ticks up (rx changes),
when a premium row gets unlocked (pd/pg flip), when a previously-
broken image gets retried and works (b flips).

Tested via CDP: card built with empty profiles cache → no tip
button, stamp ln=0. Profile pushed into the cache with lud16 →
rerender → stamp ln=1, card rebuilt, "⚡ Tip" button now present.

The same root cause was theoretically present in the preview pane
and profile page, but in practice those open AFTER the user has
been on the feed for a few seconds (profiles already loaded), so
this card-side fix is the user-visible one. If the preview pane
ever lands first, the openDesignPreview path already reads the
latest _feedCache.profiles every time, so it picks up the LN
address from the next render.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-29 09:53:52 -04:00
thePR0M3TH3ANandClaude Opus 4.7 34d5918002 Profile menu + Settings modal + Logout
Restructures the post-login surface so Relays / Trust / Blocked
authors / Lightning wallet live in their own modal instead of
buried at the bottom of the Community Templates browser, and adds
a Logout action that was previously missing.

Profile button → dropdown menu
  - Clicking the avatar pill toggles #nostrProfileMenu with three
    items: 👤 View profile, ⚙ Settings, ↩ Logout. Menu is
    position:absolute under the .nostrWidget (which is already
    position:fixed, so it serves as the positioning context — no
    new wrappers needed).
  - openProfileMenu installs a document-level click-away handler
    (deferred via setTimeout(0) so the opening click doesn't
    immediately close it) and an Escape handler. closeProfileMenu
    tears both down.
  - 👤 View profile → existing openProfilePage(myPubkey).
  - ⚙ Settings → opens the new Settings modal.
  - ↩ Logout → clears local Nostr identity (see below).

Settings modal (#settingsModal)
  - Standard modal shell with a close × button. Four <details>
    sections moved verbatim from the Community-templates browser:
    Relay settings, Web-of-trust soft moderation, Blocked authors,
    ⚡ Lightning wallet. Same IDs, so every existing handler (the
    softModEnabledToggle, walletConnectBtn, walletSyncToggle,
    relayList input, the whole walletUI module) keeps working
    without changes.
  - Backdrop click + Escape close the modal.

Logout (nostrLogout)
  - Clears state.community.myPubkey + myProfile + myFollows +
    myExtendedFollows + muteListSyncedAt.
  - Drops the localStorage premium-unlock cache (it's per-identity)
    and resets _vaultCache so the next sign-in pulls a fresh copy.
  - persistAutosave + updateNostrWidget so the UI flips back to
    "Sign in with Nostr."
  - Does NOT disconnect the wallet (deliberate: the wallet is
    reusable across identities, and Settings → Disconnect handles
    that explicitly).
  - Does NOT delete the synced-wallet record on relays (deliberate:
    the whole point of NWC sync is to make the next sign-in pick
    it back up). User can untick "Sync this wallet" in Settings
    if they want a clean slate.
  - Toasts "Signed out of Nostr."

Side fixes from the move
  - Removed two dead nostrRelayDetails style.display toggles in
    openDesignPreview / closeNostrPreview — that element used to
    live inside the Community modal, so hiding it kept the layout
    tight. Now it's in Settings; toggling its display from those
    spots was a bug-in-waiting (the Settings modal would render
    its relay section invisible the next time someone opened it
    after closing a preview).
  - openUnlockConfirm's "no wallet connected" path now opens the
    Settings modal + expands the wallet panel, instead of the old
    "open community modal" trick.

Tested via CDP:
  - Signed-in: profile pill visible, login button hidden, menu
    starts closed.
  - Click pill → menu opens with three items in order.
  - Click Settings → modal opens, menu closes; modal contains
    nostrRelayDetails, blockListDetails, walletDetails sections.
  - Close × → modal hides.
  - Click pill → menu opens → Logout → state.community.myPubkey
    cleared, profile pill hidden, login button visible.
  - Regression smoke still passes.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-29 09:47:27 -04:00
thePR0M3TH3ANandClaude Opus 4.7 8c77e547dc Login picker modal — NIP-07 active, NIP-46 and nsec stubbed for later
Clicking the "Sign in with Nostr" button used to call window.nostr
directly. Now it opens a small picker so users see the three signer
options Artstr plans to support, with only NIP-07 actually wired up
today.

Picker modal (#loginPickerModal)
  - Three option cards stacked: Browser extension (NIP-07), Remote
    signer (NIP-46), Paste private key (nsec). Each card shows icon
    + name + a one-line description of the security model.
  - NIP-46 + nsec cards rendered at .55 opacity with a "Coming soon"
    pill, but still clickable so the toast fires and the user knows
    we noticed they tried.
  - "New to Nostr? Get started at nostr.how" link at the bottom for
    onboarding.

JS wiring
  - openLoginPicker / closeLoginPicker — standard show/hide. Backdrop
    click closes (matches the publish-confirm modal pattern).
  - loginOptNip07 → closes picker + runs the existing nostrLogin().
  - loginOptNip46 / loginOptNsec → toast "Coming soon" explainer.
  - $('nostrLoginBtn').addEventListener('click', ...) re-routes to
    openLoginPicker; old direct-nostrLogin wiring removed.

When NIP-46 and nsec ship later, the picker options just need their
disabled styling dropped and new handlers wired — the modal markup
+ open/close plumbing stay.

Tested via CDP: picker hidden by default, opens on login-button
click, NIP-07 option closes picker and calls nostrLogin, NIP-46
option fires the "Coming soon" toast and leaves nostrLogin alone.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-29 09:25:11 -04:00
thePR0M3TH3ANandClaude Opus 4.7 be8c55afc8 NWC sync: store wallet credential as a NIP-44 encrypted Nostr event
Adam asked: "can we make the NWC link sync across logins via an
encrypted note?" Yes — common pattern in the Nostr ecosystem
(Alby's QR-shared wallet connect, Mutiny's wallet-pubkey export,
etc.) Mirrors the same NIP-44-self-encrypted kind-30078 shape we
already use for the purchase vault.

New module
  - NWC_SYNC_D_TAG = 'artstr:nwc-connection:v1'
  - NWC_SYNC_PREF_KEY = 'casewrap-nwc-sync-pref' — local preference
    for whether to push/pull (default ON).
  - publishNwcSync(uri) — NIP-44/04-encrypts the connection string
    via the existing _selfEncrypt helper, publishes kind-30078 with
    d=artstr:nwc-connection:v1 + encrypted tag. Fire-and-forget;
    failures don't block the local connection.
  - loadSyncedNwcUri() — queries the user's relays for their own
    sync event, decrypts via _selfDecrypt, returns the URI string
    or null. Validates the URI prefix before returning.
  - deleteSyncedNwcUri() — publishes a replacement event with no
    `uri` field so subsequent loads return null. Cleaner than a
    kind-5 deletion for a replaceable parameterized event.
  - hydrateNwcFromSync() — runs on Nostr login; restores the
    connection only if the sync pref is on AND there's no local
    connection AND a synced URI exists. Toasts "Lightning wallet
    restored from your Nostr account ✓" on success.

UI
  - Wallet connected-state panel gains a "Sync this wallet to my
    Nostr account…" checkbox with an inline disclosure of the
    security model: NIP-44 encrypted to your own pubkey, only
    decryptable with your nsec. Hidden when not signed in to
    Nostr / no encryption support.
  - Toggle ON → re-publishes the current URI to the user's
    identity. Toggle OFF → deletes the synced record and clears
    the pref so the next sign-in elsewhere won't auto-restore.
  - handleConnect: after a successful nwc.connect, if the sync
    pref is on, publishes the URI in the background; toasts on
    success.
  - handleDisconnect: also clears the synced copy so a sign-in on
    another device doesn't immediately reconnect a wallet the
    user just intentionally disconnected.
  - render() reflects the toggle's current state from the pref.

Hook into login
  - loadCommunityIdentity now ALSO fires hydrateNwcFromSync after
    the existing vault hydrate (both background, both
    non-blocking). New device → sign in → wallet restored without
    pasting the NWC URI again.

Security caveats surfaced in the UI copy:
  - The connection string is a wallet-spending credential. Anyone
    with the user's nsec can decrypt + spend up to the wallet's
    NWC budget. Same trust model as the rest of the user's Nostr
    identity, but worth being explicit since this is a hot wallet.

Tested via CDP with stubbed window.nostr.nip44 (base64 round-trip)
and stubbed publishCommunityEvent / queryRelays:
  - Publish creates kind-30078 with the right d-tag + encrypted
    marker.
  - URI round-trips through encrypt → decrypt.
  - hydrateNwcFromSync calls nwc.connect with the exact URI.
  - Toggle off → delete event published, subsequent load returns
    null.
  - Pref defaults on, persists across get/set.
  - Regression smoke still passes.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-29 09:18:03 -04:00
thePR0M3TH3ANandClaude Opus 4.7 39dee5157a New tab: sort by recency, not by trust tier
Adam reported the latest design (a premium one) was showing up
about halfway down the New tab. The New-tab sort key was:

  (broken)  ||  (trust tier weight)  ||  (createdAt desc)

So a recent post from someone you don't follow ranked BELOW older
posts from people you do follow. That's the right behaviour for a
"Following" tab, not a "New" tab — users expect newest-first when
they click New.

Trust tier removed from the New-tab sort. Recency wins, with
broken-preview still as the only soft demotion so a wall of
missing images doesn't lead. The existing Trust dropdown on the
filter row is already there for users who want to scope to
follows / extended-follows; this change just stops it from being
silently the dominant sort.

Other tabs are unchanged: Popular still uses zap-total → reactions
→ trust → createdAt, Zapped uses zap-total → trust → createdAt,
Premium uses createdAt → unlock-min.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-29 09:09:23 -04:00
thePR0M3TH3ANandClaude Opus 4.7 e6588017ed Premium: fix three issues from Adam's testing
1) Premium designs missing from the "New" tab
   isPreviewBroken was flagging every premium cover / disc / jewel /
   disc-design row as broken: those modes are in MODES_REQUIRE_IMAGE,
   the stub payload has no slot/disc art, so row.preview was '' →
   "broken". The New tab's sort puts broken rows last, so premium
   rows sank past the 24-card pagination cutoff and disappeared.
   Premium rows carry their own watermarked-JPEG preview inside the
   envelope and always show it on the card, so they should never
   count as "broken." isPreviewBroken now short-circuits on
   row.premium and returns false unconditionally.

2) Upvote button felt unresponsive
   The reaction handler awaited publishToRelays before changing the
   button text — on slow relays that's a few seconds of no visible
   feedback, so the click looked like it did nothing. Now it
   optimistically swaps to "⋯" + disables the button as soon as you
   click, then either commits to "▲ N" on success or rolls back to
   the original text on failure. No more "did it work?"

3) My Designs → Purchased tab loaded inconsistently
   Previously force-refetched the vault from relays every time the
   tab opened, blocking the render on the slowest relay in the
   pool. If the fetch failed, the whole wall went to an error
   placeholder. Now:
     - If we have an in-memory _vaultCache from the current
       session, render those cards IMMEDIATELY (sync, before the
       await), then re-fetch in the background and re-render with
       the latest. Feels instant in the common case where the user
       just unlocked something.
     - Top-of-list refresh button so the user can force a relay
       re-query on demand (useful after unlocking on another
       device).
     - Items missing their per-item event (the split-vault
       _missing flag) render at .55 opacity with a click-to-toast
       explanation instead of silently failing.
     - On refresh failure, keep showing the cached cards — don't
       blow them away just because relays timed out — and surface
       a toastError with the actual reason.

Tested via CDP:
  - Premium cover row → isPreviewBroken = false ✓
  - Reaction button: click → text becomes "⋯", disabled = true.
    On simulated failure response → rolls back to "▲", re-enabled.
  - Existing vault round-trip + hydrate + Purchased-tab regression
    still passes.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-29 08:53:15 -04:00
thePR0M3TH3ANandClaude Opus 4.7 b6b189848a Premium: parallel pay legs + address-tolerant unlock lookup + hydrate toast
Two fixes for issues Adam hit while testing.

Speed (~20s unlock → ~10s)
  - payZapSplit was paying creator first, then platform, sequentially.
    Each NWC pay_invoice round-trip is 3–8 s (LNURL fetch + wallet
    settle + RPC response), so the two pays dominated wall time.
  - Switched to Promise.allSettled — both legs fire concurrently
    over the same NWC channel. The per-leg lookup_invoice + cached
    preimage logic already protects against double-pay on retry,
    so the original "creator first if half fails" ordering wasn't
    actually load-bearing for correctness.
  - On failure: surface the right `err.stage` so the UI's
    targeted-retry button still works. If both legs fail we bubble
    the creator error.
  - Status line says "Paying creator + platform fee…" so the user
    sees one combined step instead of two.

Cache-clear unlocks restored from vault (address-tolerant matching)
  - Adam reported: "I clear cache and reload, the premium design I
    just paid for becomes locked again." Two causes:
      1. The hydrate runs silently — no visible feedback that
         anything happened, so it's invisible whether it's broken
         or working.
      2. The lookup was by event-id only. If the creator edit-in-
         places the design, the new event id won't match the
         stored unlock entry → re-locked.
  - isUnlockedLocally(eventId, addressCoord) now checks the event-
    id first (fast path), then scans for an entry whose `address`
    matches. New helper findUnlockedEntry returns the full entry
    the same way.
  - buildRowsFromEvents passes premiumAddressCoord(e) so an old
    vault entry under the OLD event id still unlocks a NEW event
    at the same address.
  - hydrateUnlockedFromVault now skip-checks BOTH event id AND
    address, returns { restored, total }, and surfaces a toast on
    success: "Restored N premium unlock(s) from your vault ✓".
    Failures surface a toastError so the user sees what went
    wrong instead of guessing.

Tested via CDP:
  - Address-tolerant lookup: pre-stamp an unlock for event v1 with
    address coord A. Build rows from event v2 (same A, different
    id) → v2 rendered as unlocked. Build rows from event v3 with
    a different address → still locked. Lookup by event id vs by
    address vs neither all return the right answers.
  - Existing preview-refresh E2E (which exercises the full payZap
    Split + unlock + decrypt path through the parallel-pay branch)
    still passes — lock CTA → Use/Fork, watermark → real preview,
    reveal flash applied.
  - Regression smoke still green.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-29 08:40:56 -04:00
thePR0M3TH3ANandClaude Opus 4.7 f1a147baf9 Premium preview now supports pixel-art mode
Adam asked: "why is 'Premium preview not supported for mode pixelart'?"
— it's because I excluded pixelart and deck from the supported-modes
dispatcher when shipping the watermarked-preview pipeline. Pixel art
has its own renderer that operates on a tiny native canvas (32–128
px on a side), so the default JPEG-export path didn't apply.

renderPixelArtToExportCanvas()
  - Renders the standalone pixel-art design (state.pixelArt frame 0)
    through the existing renderPixelGridToCanvas helper.
  - Scales up by the largest integer multiple that keeps the long
    axis ≤ PREMIUM_PREVIEW.MAX_LONG_AXIS_PX (720 px). Integer scale
    + imageSmoothingEnabled = false → pixels stay crisp instead of
    blurring; 32 px → 22× → 704 px, 64 px → 11× → 704 px, etc.
  - Wired into _renderModeToExportCanvas so renderPremiumPreviewDataUrl
    accepts pixelart alongside cover / disc / jewel / disc-design /
    customart / slide.

Tested via CDP on a synthetic 32×32 frame:
  - returns ok: true, 562×562 px, 45 KB. Reads as crisp pixel art
    under the diagonal "ARTSTR PREMIUM · UNLOCK TO USE" watermark
    in the saved JPEG.

Deck mode still throws ("Premium preview not supported for mode
'deck'") — the multi-page deck export is a different beast. A
deck-cover thumbnail would need the deck-builder code path; left
for a future commit if creators ask.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 22:31:15 -04:00
thePR0M3TH3ANandClaude Opus 4.7 1bb4471cc8 Fix: feed rerender silently dies when autosave restores myExtendedFollows as {}
Adam reported the "All" tab in the template browser was empty even
after the fingerprint dedup fix. Root cause: JSON cannot represent
a Set, so when autosave serialises state and the user reloads,
state.community.myExtendedFollows comes back as an empty object {}.
buildRowsFromEvents calls trustTierForPubkey on every event, which
calls state.community.myExtendedFollows?.has(...) — the ?. chain
DOES NOT short-circuit on a non-null object that just doesn't have
a `.has` method, so it throws TypeError. Inside the row builder
loop the throw bubbles up through dedupByFingerprint and the
rerender exits before any cards are drawn, leaving the wall empty
with no console error the user would notice. Compounded by my
recent additions writing autosave more often (after login,
hydrate, etc.), which made the bad shape much more likely to be
present on subsequent loads.

Three defensive fixes (any one would have sufficed; all three are
cheap):

  1. restoreAutosave() re-initialises state.community.myExtendedFollows
     to a real Set after the autosave merge if the restored value
     isn't a Set. Same for myFollows → []. fetchHop2Follows refills
     myExtendedFollows on next login.

  2. trustTierForPubkey() now checks `ext instanceof Set` before
     calling .has, and guards myFollows behind Array.isArray. Same
     idea — refuse to throw on a shape that wasn't a Set / Array.

  3. The feed-tab rerender wrapper try/catches the inner render
     and console.warns instead of bubbling. Without this guard, a
     single throw anywhere in the row builder / dedup / sort /
     diff path silently kills the whole feed and the user has no
     way to see what went wrong.

Tested via CDP: stuff autosave with state.community.myExtendedFollows: {},
reload, confirm state.community.myExtendedFollows is back to a Set
and buildRowsFromEvents on a synthetic event succeeds (trust
returned as 'unknown' since the test pubkey isn't followed). Pre-
fix this test threw the TypeError shown above.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 22:21:00 -04:00
thePR0M3TH3ANandClaude Opus 4.7 95a7130255 Fix: premium events were all collapsing into one row via fingerprint dedup
Adam reported the "All" filter in the template browser was empty. The
root cause: when buildRowsFromEvents synthesises a stub payload for a
still-locked premium row (we can't see the encrypted content), the
stub has no layers / slots / canvas data — so designFingerprint
returns essentially the same string for every premium row of the
same template mode. dedupByFingerprint then collapses them all into
a single row whose title happens to come from the earliest createdAt
event. With a feed full of premium designs and no plain ones, the
"All" view looked nearly empty.

Fix: designFingerprint short-circuits and returns '' (treated by
dedupByFingerprint as "skip dedup, always keep this row") for any
payload that has `premium: true` AND no content-bearing fields
(layers / coverTemplate / discTemplate / customArt / etc). That
matches exactly the stub shape produced by
_stubPayloadFromPremiumEvent — once the user unlocks and the cached
decrypted payload replaces the stub, the row has its real fields
back and normal content-based dedup resumes.

Tested via CDP:
  - 5 premium events from different creators with different d-tags
    and titles → all 5 rows survive the rerender → all 5 cards
    visible. Previously this would have been 1 card.
  - The earlier mixed test (1 plain + 1 premium + 1 legacy) keeps
    the same 2-card result (plain + legacy DO have matching
    fingerprints because their plaintext is identical) — exactly
    what dedup is supposed to do for non-premium events.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 22:12:58 -04:00
thePR0M3TH3ANandClaude Opus 4.7 c87ec907ef Premium: honest soft-gate copy in unlock confirm + publish ack
PREMIUM_DESIGNS_FEATURE.md §9.1 + §13.5. Two surfaces where the
honest framing was missing:

Unlock confirm dialog (buyer side)
  - Added a trailing line under the price breakdown:
    "Soft-gate, not strong DRM — once unlocked you can save /
     export / share the design like any other Artstr file."
  - The buyer sees the limitation BEFORE paying so the
    expectation is set up-front: the design will be theirs to
    use, and there's no client-side trick that prevents them
    from re-sharing afterwards.

Publish modal ack checkbox (creator side)
  - New #publishConfirmSoftGateAck checkbox inside the premium-
    details panel: "I understand this is a static soft-gate,
    not strong DRM. A determined buyer can extract / re-share
    the design after unlocking. Artstr protects against
    drive-by scraping, not piracy."
  - handlePublishConfirmPublish hard-blocks the publish when
    state.publish.zapGate is on and the ack isn't ticked,
    routing through the existing publishConfirmError surface.
  - updatePublishConfirmZapGate resets the ack to unchecked on
    every modal open so creators have to re-acknowledge each
    time. Inheriting a tick across publishes would defeat the
    point.

Tested via CDP: ack present, initial state unchecked, Publish
without tick produces the corrective error and does NOT call the
onConfirm callback. Tick + Publish succeeds (onConfirm fires).
Regression smoke still passes.

The remaining honest-copy surfaces (publish modal toggle label,
preview-pane locked note, Premium tab hint) were already in
place from earlier steps. The wallet panel itself doesn't get
the soft-gate copy — NWC is general-purpose for tips + zaps,
and the soft-gate framing belongs where the creator is
publishing or the buyer is paying.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 22:04:22 -04:00
thePR0M3TH3ANandClaude Opus 4.7 68b696459e Vault: auto-split into per-item events when over 40 KB
PREMIUM_DESIGNS_FEATURE.md §8.6 future-mitigation. Closes the
size cap on the single-event vault: once the encrypted blob would
exceed 40 KB (chosen to give comfortable margin under the ~64 KB
content limit on the most-common relays), publish each purchase
as its own kind-30078 event and reduce the canonical vault event
to a small manifest of stubs.

Implementation
  - _vaultItemDTag(addr) — sha256(addr) → base64url, prefixed
    `artstr:purchase:`. Stable per address coordinate, so a
    re-publish of the same vault item over-writes its event in
    place. Uses window.ArtstrNoble.sha256 (already bundled).
  - _vaultItemStub(item) — drops payload + previewJpegDataUrl +
    receipts, keeps only address coord + title/mode/amount/
    timestamp so the index stays cheap. Stubs are enough to
    populate the "My Purchases" tab even before the loader has
    fetched per-item events.
  - _publishVaultEvent(plaintext, dTag, extraTags) — extracted
    helper that NIP-44-encrypts + publishes any vault-like event
    (the canonical index OR a per-item entry). Returns
    { signed, encryptedSize } so the size probe doesn't require a
    second encrypt.
  - _publishVault now probes the single-event size FIRST. If
    under threshold it publishes as before. Otherwise it loops
    over items, publishing one per-item event each
    (d=artstr:purchase:<sha>, t=casewrap-purchase tag for
    discovery), then publishes a small canonical index event
    with `split: true` + stubs only. Console.info logs the trip.
  - loadPurchaseVault detects `parsed.split === true`, computes
    the expected per-item d-tag for each stub, runs one bulk
    queryRelays with #d=[...] to fetch them all in one shot, and
    merges each per-item payload onto its stub. Stubs whose
    per-item event can't be found come back with _missing: true
    so the UI can render them as unavailable without crashing
    the whole list.

  - VAULT_SIZE_THRESHOLD constant: 40 * 1024. One place to tune.

Tested via CDP with a stubbed window.nostr.nip44 (predictable
base64 round-trip) and publishCommunityEvent / queryRelays:
  - 1 small item (28 KB encrypted) → 1 event with the canonical
    d-tag, content still inline. Reload restores the item.
  - Add items until total would exceed threshold → console logs
    "[vault] single-vault size ... > threshold ...". Subsequent
    publishes emit per-item events (3 in this fixture) PLUS the
    new index (1 KB, split=true, stubs only).
  - Stubs in the index contain only a / eventId / creatorPubkey /
    title / mode / unlockedAt / amountSats — no payload, no
    preview.
  - Loader reconstructs the full items list with payload +
    receipts.
  - Per-item event removed → reload still returns the stub but
    flagged _missing: true.
  - Regression: existing purchase-vault test (single-vault path
    with NIP-44 round-trip + hydrate + Purchased tab render)
    still passes unchanged.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 22:00:59 -04:00
thePR0M3TH3ANandClaude Opus 4.7 99b386db54 Premium unlock: refresh preview pane in place with a reveal flash
Previously, after clicking Unlock from the preview pane, the pane
kept showing the watermarked locked version until the user closed
and re-opened it. That made the "I just bought this" moment feel
inert, and could read as "did the payment work?" friction.

Now, on a successful unlock from openUnlockConfirm:
  - Check if #nostrPreviewPanel is currently showing the row we
    just unlocked (it stashes the row reference on the panel).
  - If so, rebuild the row from _feedCache.events so it reflects
    the freshly-decrypted payload (passes skipVersionFilter so the
    cache stays fresh even for legacy version fields), and call
    openDesignPreview(freshRow). The pane re-paints: the action
    rail's "⚡ Unlock for N sats" swaps to "Use/Fork", the
    watermarked JPEG body swaps to the real renderTemplate /
    renderCustomArt etc. preview DOM, and Save JSON re-enables.
  - Apply a new .premiumRevealFlash class to the panel right
    after the swap. Defined as a 0.55 s premiumRevealFade
    keyframe that fades from opacity 0 + 0.985 scale + slight
    brightness boost to fully visible — reads as "the content
    just appeared" without being garish.

Failure cases stay non-blocking: a try/catch around the preview
refresh logs to console without affecting the rest of the unlock
toast or the feed rerender.

Tested via CDP: synthetic premium event in the cache, open
preview pane (locked: watermarked image + Unlock CTA),
stub payZapSplit + upsertPurchaseVaultItem + native confirm so
openUnlockConfirm runs through. After the call returns the
preview pane has Use/Fork button, no JPEG image in the body, and
the premiumRevealFlash class set.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 21:53:31 -04:00
thePR0M3TH3ANandClaude Opus 4.7 191d216084 Premium tab in template browser + gradient stroke on premium cards
UI work to make premium designs more discoverable and visually
distinct from free designs.

New "⚡ Premium" tab in the Browse Community Templates modal
  - Sits between Zapped and Search in the tab row.
  - Reuses the existing feedTypeFilter / category / language /
    trust filter row shape.
  - panelPremium also carries an honest hint line under the
    filter row: "Premium encrypted designs — pay to unlock the
    full editable design. Soft-gate (not strong DRM); creators
    get 70%, Artstr gets 30%."
  - _feedState + _feedPagination extended with Premium.
  - switchModalTab + refreshNostrBrowser both recognise the new
    tab and route to loadFeedTab('Premium').
  - loadFeedTab rerender filters out non-premium rows on this
    tab and sorts newest-first (cheap unlock-min as tie-break),
    so the tab reads like a marketplace listing rather than a
    chronology of mixed events.

Gradient stroke + corner ribbon on premium cards
  - New .feedCardPremium CSS class. Uses the background-image
    double-layer trick (one solid bg + one gradient on
    border-box) to render a 2 px gradient ring through the
    standard border, plus a subtle outer glow. Dark-mode variant
    swaps the inner panel colour.
  - Gradient: 135° yellow (#ffd166) → magenta (#ef476f) → cyan
    (#06d6a0). Reads as Lightning-themed without screaming.
  - New .feedCardPremiumRibbon — small pill in the thumb's top
    -left corner. Shows "⚡ PREMIUM" when locked, "⚡ UNLOCKED"
    when the row's decrypted (purchase vault / fresh unlock).
  - buildFeedCard applies both class and ribbon whenever
    row.premium is set, so the visual treatment shows up on
    every tab (New, Popular, Zapped, Premium, Mine) — premium
    designs are easy to spot in the mixed feed too.

Tested via CDP with one synthetic premium event + one plain event
in the feed cache:
  - tabPremium / panelPremium / feedWallPremium all present.
  - Premium tab shows only the premium event (premiumOnly check
    on the synthesised event id).
  - Card class includes feedCardPremium; ribbon reads "⚡ PREMIUM".
  - On the New tab BOTH cards render, premium card has the
    styling + ribbon, plain card doesn't.
  - Pagination state.Premium populated correctly.
  - Regression smoke (existing editor) still works.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 21:48:49 -04:00
thePR0M3TH3ANandClaude Opus 4.7 3346b06b57 Premium step 4 — NIP-44 purchase vault + Purchased tab
PREMIUM_DESIGNS_FEATURE.md §8 + §9.4. Adds a Nostr-synced encrypted
vault of every premium design the user has unlocked, so purchases
survive localStorage.clear() and sync to any other browser / device
where the same Nostr identity signs in.

Purchase vault (`nwc`-independent — only needs NIP-07 encryption):
  - VAULT_D_TAG = 'artstr:purchase-vault:v1'
  - _selfEncrypt / _selfDecrypt — wrappers over window.nostr.nip44
    (preferred) / nip04 (fallback), using the user's own pubkey as
    both sender and recipient. Mirrors the existing private-publish
    capability detection so anything that publishes private also
    supports the vault.
  - loadPurchaseVault({ force }) — queries the user's relays for
    their own kind-30078 with #d=artstr:purchase-vault:v1, picks
    the most-recent event, NIP-44 decrypts, JSON parses, caches in
    memory. Empty result for users with no vault yet.
  - upsertPurchaseVaultItem(item) — fetches the latest vault first
    (merge-by-address coord, last-write-wins for unlockedAt, set
    union for receipts so we never drop a payment proof), then
    re-publishes the encrypted vault as a single kind-30078 event.
    Runs in the background after unlock; failures log and don't
    block the toast.
  - hydrateUnlockedFromVault() — on every Nostr login, pulls the
    vault and writes each item into the local UNLOCK_CACHE_KEY so
    the feed-card gate just works without a separate vault-vs-
    cache check.

Wired into the unlock flow
  - openUnlockConfirm: after a successful payZapSplit + decrypt,
    calls upsertPurchaseVaultItem with a fully-populated item:
    address coord, event id, creator pubkey, title, mode, unlock
    timestamp, total + per-leg sat amounts, both preimages, the
    cached watermarked preview JPEG, AND the decrypted payload.
    Stored payload means the buyer can re-open the design even if
    the original kind-30078 event later disappears from relays.
  - Wired into loadCommunityIdentity (the post-login fan-out) so
    a fresh browser instantly populates from the vault before the
    user goes near the feed.

"⚡ Purchased" sub-filter in My Designs
  - New feedTypeBtn with data-type='purchased' added to the Mine
    panel's filter row. The existing click handler that calls
    loadMyDesigns() picks it up.
  - loadMyDesigns: branches to loadPurchasedDesignsIntoWall when
    the filter is 'purchased' — skips the user-published-events
    query entirely.
  - loadPurchasedDesignsIntoWall: refreshes the vault, builds a
    synthetic row per item via _rowFromVaultItem, renders through
    the existing buildFeedCard pipeline so the cards look exactly
    like the rest of the feed. Use/Fork on a vault row imports the
    cached decrypted payload directly (no re-fetch, no re-derive).
  - Friendly empty-state for users with no purchases; honest
    error if the user's extension has no encryption support.

Tested via CDP with a stubbed window.nostr.nip44 (base64 sham for
predictable round-trip) and stubbed queryRelays / publishCommunityEvent:
  - Empty initial vault returns { v:1, updatedAt:0, items:[] }.
  - upsertPurchaseVaultItem publishes a kind-30078 with the right
    d-tag and `encrypted` marker; the cipher round-trips back to
    an item list with the title, payload, and BOTH receipts.
  - Re-loading the vault from the cached published event
    rebuilds the same item.
  - Hydration writes the item's event id into
    UNLOCK_CACHE_KEY so isUnlockedLocally returns true for it.
  - loadPurchasedDesignsIntoWall renders one feed card from the
    vault item.
  - Regression: existing editor + premium publish + premium
    consume E2E tests still pass.

Trade-off notes for future work
  - The whole vault lives in one event today, so size scales
    linearly with purchases. Each item includes the watermarked
    preview JPEG (~20-30 KB) + the decrypted payload (typically
    small). Once a vault approaches relay limits we'll switch to
    per-purchase entries with d=artstr:purchase:<sha256(a)> per
    §8.6 future-mitigation.
  - The soft-gate key is derivable from public material, so we
    don't bother storing the wrapped key per item — storing the
    payload directly is both simpler and survives event deletion
    on relays. Spec §8.3 calls for wrapped-key storage; that
    becomes meaningful only when v2 secure-gate (creator-issued
    keys) lands.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 21:37:21 -04:00
thePR0M3TH3ANandClaude Opus 4.7 2aed156912 Premium step 3 — encrypted publish + premium consume
PREMIUM_DESIGNS_FEATURE.md §19 item 3 + the consumer changes needed
to actually display, gate, and decrypt the new event shape. Closes
the architectural gap that Adam called out in testing: premium
templates were being published with the design payload in plaintext
on relays, with only a client-side tag asking nicely not to import.

Publish side
  - _publishPremiumDesign(payload, mode, typeTag) — new helper that
    runs when state.publish.zapGate && visibility === 'public'.
    Steps: render the watermarked preview (bails with a precise
    error if the design is too large or has CORS images), pick a
    `premium:<mode>:<base>` d-tag (or reuse state.editingDTag on
    edit-in-place), encrypt the payload via Premium.encryptPayload
    with the event coordinate as AAD, embed the preview JPEG in
    envelope.preview, then publish a single kind-30078 event with
    the §5.1 tag set (premium / premium-mode=softgate-v1 /
    softgate-epoch / encrypted / zap-gate / zap-min / zap-split /
    two `zap` tags with weights from PLATFORM_FEE_BPS / discovery
    tags / etc.).
  - publishCurrentTemplateToNostr branches into the premium path
    before the existing public path, after the private path. The
    label in the publish toast says "Premium" so the user knows
    which path ran.
  - getTemplateTags no longer emits zap-gate / zap-min from
    state.publish — that tag set is owned by the premium publish
    helper now, so non-premium publishes don't accidentally tag.

Consume side
  - isPremiumEvent(event), getPremiumEnvelope(event),
    premiumEventDTag, premiumAddressCoord, premiumEventMode,
    parsePremiumZapSplits — pure tag/content readers.
  - _stubPayloadFromPremiumEvent — synthesises a payload shape from
    public tags (title / category / language / mode) so the existing
    feed-card readers don't choke on a locked row whose real
    payload is encrypted.
  - buildRowsFromEvents: detects premium events; if the user has
    already unlocked (cached payload in UNLOCK_CACHE_KEY) we use
    the real decrypted payload, otherwise the stub. Annotates the
    row with row.premium = { envelope, previewJpegDataUrl,
    decrypted, address, dTag } AND row.zapGate so the existing
    lock-CTA logic keeps working.
  - Older tag-only events (zap-gate=true with no `premium` tag) are
    still surfaced as locked, but they're now a "delete + republish"
    nag — their plaintext payload is already on relays.
  - buildFeedCard miniPreviewWrap: for locked premium rows, renders
    the watermarked JPEG from envelope.preview.dataUrl as an <img>
    instead of trying to renderTemplatePreviewDOM on the stub
    payload. fitMiniPreview is skipped (image fits via CSS).
  - openDesignPreview (the preview pane): same gate. The preview
    body shows the watermarked JPEG + an honest "Soft-gate, not
    strong DRM" note. The Use/Fork button is replaced with a
    "⚡ Unlock for N sats" CTA that runs the existing
    openUnlockConfirm flow. The Save JSON button is disabled with a
    "Locked — pay to unlock the design data" tooltip. (Previously
    Save JSON downloaded the full payload — a flat bypass.)

Unlock decryption
  - openUnlockConfirm: after payZapSplit succeeds, if the row is
    premium, call Premium.decryptPayload with the row's envelope +
    coord. Cache the decrypted payload in markUnlocked alongside
    creator/platform preimages + the address coord. If decrypt
    fails after a successful payment (epoch mismatch, corrupted
    envelope), the payment proof is still cached so the user
    doesn't re-pay; the message asks them to refresh.
  - Once decrypted is in the cache, buildRowsFromEvents picks it
    up on the next rerender and the row's Use/Fork / Save JSON
    work normally (the existing import path reads row.payload).

Receipt fast-path by address
  - relayFastPathUnlockCheck now takes full events (not just ids).
    It runs two relay queries in parallel: kind-9735 with #a for
    premium events (address-coord scope so edit-in-place doesn't
    invalidate prior purchases — §13.2), and kind-9735 with #e for
    legacy tag-only events. Marks unlocked off whichever matches.
  - The call site in fetchFeedAndReactions now filters the
    template events to (premium || zap-gated) and passes them in
    full.

Copy
  - Publish modal toggle: "Premium encrypted — viewers see a
    watermarked preview and must zap to unlock the full design.
    Soft-gate, not strong DRM — once unlocked, buyers can still
    export or share." Reflects the spec's §13.5 honesty rule.

Tested via CDP with a stubbed publishCommunityEvent:
  - All 17 spec §5.1 tags emitted in the right shapes including
    the two `zap` split tags with weights pulled from
    PLATFORM_FEE_BPS (70 / 30).
  - Envelope has aes-256-gcm + IV + salt + ciphertext + softgate
    metadata + embedded preview (~18 KB for a 1920x1080
    customart).
  - Direct string search against the serialized envelope for the
    layer text "Locked!" and color hex "7c3aed" → not present.
    Plaintext does NOT leak through the envelope.
  - buildRowsFromEvents on the captured event produces a row with
    row.premium.decrypted = false, full address coord, mode and
    title derived from public tags.
  - Premium.decryptPayload(envelope, coord) returns the exact
    original payload via deep-equal JSON comparison.
  - Regression smoke (existing editor + soft-gate self-test) still
    passes.

Next step is the buyer-side purchase vault (§8) — NIP-44 self-
encrypted kind-30078 vault so unlocked keys sync across browsers /
devices, and the "My Purchases" tab in the Nostr modal.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 21:26:09 -04:00
thePR0M3TH3ANandClaude Opus 4.7 713ffefe83 Premium step 2 — watermarked preview pipeline
PREMIUM_DESIGNS_FEATURE.md §5.4 + §19 item 2. Renders the current
artboard as a low-res, watermarked JPEG suitable for embedding in
the premium publish event so buyers can see what they'd be paying
for. Budget-bounded so the event still fits in standard relay
limits after the encrypted blob is also added.

renderPremiumPreviewDataUrl()
  - Picks an export DPI from the artboard's long-axis inches so the
    initial render lands close to the 720 px cap (no wasted huge
    rasterize for a 20-inch custom art canvas, no upscaling-from-
    tiny for a 4.7-inch disc design).
  - Re-uses the existing render*ToExportCanvas() helpers under a
    new _renderModeToExportCanvas dispatcher so we don't duplicate
    the per-mode draw logic.
  - Downscales the result into a target-size canvas (max 720 px long
    axis), draws the watermark, JPEG-encodes at quality 0.65.
  - Budget loop: if the data URL exceeds 50 KB, shrinks long-axis by
    22% and re-tiles + re-encodes (the same source canvas is reused;
    no extra rasterize). Caps at 4 attempts down to a 360 px floor;
    throws a precise "preview is too large" error otherwise so the
    publish modal can tell the creator to simplify.
  - Pre-checks _exportFailedImages so a CORS-tainted artboard fails
    fast with a clear message instead of producing a broken preview.

_drawPremiumWatermark()
  - Tiles "ARTSTR PREMIUM · UNLOCK TO USE" across the entire image
    at -18°. Uses ctx.measureText so the horizontal step is the
    actual rendered text width + a comfortable gap — adjacent
    copies never overlap and the text is always readable. Rows
    stagger by half a step so the pattern doesn't read as a strict
    grid. White stroke at 45% opacity under a slate fill at 26%
    so it reads on both light and dark designs.

PREMIUM_PREVIEW constants
  - MAX_LONG_AXIS_PX = 720, QUALITY = 0.65, MAX_BYTES = 50*1024,
    MIN_LONG_AXIS_PX = 360. Tunable in one place.

Restricted to modes the JPEG export already supports (cover, disc
sheet, jewel, disc design, customart, slide). Deck + standalone
pixel art remain out-of-scope for v1 premium publishing — they can
be added later when their own export paths grow a watermark hook.

Tested via CDP on three real artboards:
  - Custom art 1920×1080 with color + rich text → 720×405 px,
    20 KB. Watermark reads cleanly across the whole image.
  - Cover (DVD presets, three colored slots + a title) → 718×488
    px, 25 KB. Title still visible under the watermark.
  - Disc design (small circular artboard) → 301×301 px, 12 KB.
    Centre label readable.
  - Stress: 4096×4096 custom art with 40 random color rects →
    720×720 px, 40 KB. Under budget without needing a retry.
  - Regression: soft-gate self-test and existing editor still work.

No publish/feed code touches this yet. Next step (§19 item 3) is
the premium publish event — encrypted envelope + new tag set +
embedded watermarked preview.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 20:59:07 -04:00
thePR0M3TH3ANandClaude Opus 4.7 a8c855fc5c Premium step 1 — soft-gate crypto primitive
First piece of PREMIUM_DESIGNS_FEATURE.md §6. Adds the static
client-side decryption layer for premium designs: HKDF-SHA256 over
an obfuscated per-epoch pepper + a per-design public salt, then
AES-256-GCM with the event coordinate as additional-authenticated-
data so ciphertext can't be copy-pasted to a different event.

noble bundle
  - Add @noble/hashes/hkdf to the bundle entry; expose as
    window.ArtstrNoble.hkdf and .hmac. Bundle size 14.0 KB → 16.5 KB.

Premium IIFE (new section in index.html)
  - _softGatePepperParts — per-epoch lookup of 4x8-byte fragments
    ("palette seed I..IV"). For epoch 2026-05. Sub-strings don't
    look like a "premium key"; reconstruction concats at use time.
    Comment explicitly calls this obfuscation, not security, per
    PREMIUM_DESIGNS_FEATURE.md §2 / §6.3.
  - SUPPORTED_EPOCHS / CURRENT_EPOCH — adding a future epoch is
    one entry in the parts table.
  - pepperBytesForEpoch(epoch) — reconstructs the 32-byte pepper.
  - deriveKey({ epoch, creatorPubkey, premiumAddress, schemaVersion,
    publicDesignSalt, puzzleNonce }) -> 32 bytes via HKDF-SHA256
    with the pepper as IKM, salt = publicDesignSalt, info =
    "artstr-softgate-v1|<epoch>|<creator>|<addr>|v<schema>" (and an
    optional "|p<nonce>" for the §6.7 CPU-puzzle hook).
  - encodeAad(creatorPubkey, dTag) -> UTF-8 of
    "30078:<creator>:<dTag>" so AES-GCM authenticates the
    coordinate.
  - encryptPayload(payloadObj, coord, { epoch? }) — random
    32-byte salt + 12-byte IV; returns the spec §5.2 envelope
    (base64 fields, softgate metadata, etc.).
  - decryptPayload(envelope, coord) — reverses the above.
  - Self-test runs on every load: round-trip a known payload AND
    verify AAD/creator/schema mismatches all reject the decrypt
    (event-coordinate binding). Single console.warn on failure.

Tested via CDP — all green:
  - Round-trip with random keypair: ok
  - AAD mismatch (different dTag) -> AES-GCM rejects: ok
  - Creator pubkey mismatch -> KDF info changes -> rejects: ok
  - Schema version mismatch -> KDF info changes -> rejects: ok
  - KDF deterministic with identical inputs: ok
  - Output length = 32 bytes (AES-256): ok
  - Pepper reconstruction = 32 bytes: ok
  - Envelope size for tiny payload = 577 bytes; ~63 KB headroom
    under typical relay limits, plenty for an embedded preview
    JPEG in step 2.
  - Regression: NWC crypto self-test still passes; existing editor
    still loads and renders layers.

Code only; nothing in the publish/feed flow uses this yet. Next
step (PREMIUM_DESIGNS_FEATURE.md §19 item 2) is the watermarked
preview pipeline.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 20:54:24 -04:00
thePR0M3TH3ANandClaude Opus 4.7 7da0c9d97c Adopt static soft-gate + buyer purchase vault spec as canonical premium model
In live testing, the tag-only gate shipped in Phases 0–3 turned out
to be too leaky: the design payload was published in plaintext, three
separate import paths (feed-card Use/Fork, preview-pane Use/Fork,
preview-pane Save JSON) bypassed the lock, and any non-Artstr client
could ignore the tag entirely. Adam pulled back the original detailed
spec he wrote earlier — the static soft-gate + buyer purchase vault
model — which solves the protection gap WITHOUT requiring a creator-
online bot (the route I was about to build).

Key idea: the AES key derives deterministically from
HKDF-SHA256(softGatePepperForEpoch(epoch), publicDesignSalt, info =
"artstr-softgate-v1" + epoch + creatorPubkey + premiumAddress +
schemaVersion). The pepper lives in the static client (obfuscated,
not secret). The client refuses to derive the key until valid kind-
9735 receipts confirm the split zap. Same key for every buyer; no
creator-online requirement; cross-device unlock via a NIP-44 self-
encrypted purchase vault (kind-30078, d=artstr:purchase-vault:v1).

Files:

  - docs/PREMIUM_DESIGNS_FEATURE.md (new, canonical) — the full
    soft-gate + split-zap + purchase-vault spec. Sections 1–17 are
    the original spec verbatim. Section 18 maps the shipped Phases
    0–3 to the spec's implementation-plan items (10.0–10.6) so the
    next contributor sees what's done. Section 19 lists the
    remaining work in build order: soft-gate crypto primitive →
    watermarked preview → premium publish event → premium consume
    → receipt fast-path by address → purchase vault → My Purchases
    UI → hardening + copy.

  - docs/ZAP_GATED_PREMIUM_FEATURE.md — banner now points at
    PREMIUM_DESIGNS_FEATURE.md as the canonical going-forward doc.
    The obsolete Phases 5/6 (DM-delivery key flow) I sketched
    earlier today are removed; the "Content model (CORRECTED)"
    section is trimmed and points at the new spec. The file is
    kept for Phases 0–3 history, the NWC/tip integration notes,
    and the threat model that still applies.

  - docs/ZAP_GATED_TEMPLATES_FEATURE.md and ZAPS_NWC_FEATURE.md
    keep their "superseded" banners pointing at
    ZAP_GATED_PREMIUM_FEATURE.md (which itself now points at
    PREMIUM_DESIGNS_FEATURE.md) — chain of provenance preserved.

  - TODO.md: drafted-but-unbuilt entry rewritten to describe the
    final shape (encrypted + vault + honest soft-gate framing) and
    point at the new spec; references the older docs for the
    shipped foundation.

No code changes yet — the spec adoption is the gate. Next commits
implement the soft-gate crypto primitive, the watermarked preview,
and the premium publish path.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 15:20:31 -04:00
thePR0M3TH3ANandClaude Opus 4.7 4e7ec76817 Lightning tips: pay in-app via NWC when a wallet is connected
Adam pointed out the tip flow still showed the bolt11 / QR view even
when the user had a NIP-47 wallet set up. That re-introduces the
exact wallet-handoff friction the NWC integration is meant to solve.

Changes to the existing LNURL tip flow:

  - openTipModal sets the action button to "⚡ Send tip" when
    nwc.isConnected(), and to the original "Get invoice" otherwise.
  - getTipInvoiceFlow, after fetching the bolt11, tries
    nwc.payInvoice() FIRST when a wallet is connected. On success:
    toast "Sent N sats ⚡" + close modal. On failure (preimage
    missing, wallet error, budget exhausted, etc.): falls through
    to the existing showTipInvoiceView so the user can still pay
    the bolt11 externally with the visible error inline.
  - Button label restoration uses the same dynamic source so post-
    error retry shows the correct (state-appropriate) label.

Pre-flight gates (amount validation, LNURL meta fetch, zap path
construction) are unchanged — NIP-57 zap tagging still wraps the
LNURL request so the recipient still sees a tracked kind-9735
receipt; the only difference is who pays the bolt11.

Verified via CDP: button label flips between "Get invoice" and
"⚡ Send tip" with nwc.isConnected() state.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 15:06:06 -04:00
thePR0M3TH3ANandClaude Opus 4.7 c6955cd2ad Phase 3 — zap-gated templates UI
The publish flow now lets a creator tag a kind-30078 event as premium
with a sat minimum, and the feed swaps the Use/Fork button for a
"⚡ Unlock for N sats" CTA that drives the dual-zap split orchestrator
from Phase 2.

Publish side:
  - state.publish = { zapGate, zapMin } added to the editor state so
    autosave persists the user's last choice.
  - getTemplateTags() emits `zap-gate=true` + `zap-min=<sats>` tags
    when state.publish.zapGate is on. Encrypted (private) designs
    cannot be gated — they're not feed-listed, so the tags are
    deliberately suppressed.
  - publish-confirm modal grows a "Premium template" checkbox + a
    minimum-sats input. An inline note recomputes the creator/
    platform split from the current input and the PLATFORM_FEE_BPS
    constant on every keystroke. Toggling Private hides the whole
    premium section.
  - Two blocking pre-publish checks: (a) creator's kind-0 must carry
    a lud16 or lud06 — corrective inline error + a hard stop in
    handlePublishConfirmPublish; (b) the chosen zap-min must split
    to ≥1 sat creator share.

Consumer side:
  - isZapGated(event) / getZapMin(event) — pure tag readers.
  - buildRowsFromEvents annotates rows with
    `row.zapGate = { gated, min, unlocked }` so downstream code can
    branch cheaply.
  - buildFeedCard swaps "Use/Fork" for "⚡ Unlock for N sats" when
    `row.zapGate.gated && !row.zapGate.unlocked`, with a tooltip
    that names the platform-fee share. Preview button + whole-card
    click still open the design preview (the gate is on importing,
    not on looking).
  - openUnlockConfirm(row) — wallet/login checks, confirm() with
    the split shown, runs payZapSplit with a live status toast,
    marks unlocked, refreshes the feed.
  - localStorage cache UNLOCK_CACHE_KEY ('casewrap-unlocked-templates')
    with markUnlocked / isUnlockedLocally / getAllUnlocked.
    markUnlocked also drops any matching card DOM so diffWallCards
    rebuilds it on next rerender — without this, the optimistic
    same-id reuse kept the lock button on screen after payment.

Relay fast-path:
  - relayFastPathUnlockCheck(gatedEventIds) — kind-9735 query with
    `#e=[gatedIds]` + `#P=[myPubkey]`. Any matching receipt = the
    user already paid, so we mark unlocked locally without a re-pay.
    Wired into fetchFeedAndReactions right after template fetch so a
    cache wipe / new device picks up existing unlocks for free.

Tested via CDP (synthetic events; live e2e is Phase 4):
  - isZapGated tag detection (true/false/missing).
  - getZapMin parses the value.
  - markUnlocked / isUnlockedLocally round-trip.
  - getTemplateTags emits zap-gate + zap-min when on/public, omits
    when private.
  - Feed renders the gated event with "⚡ Unlock for 150 sats"
    button when locked; after markUnlocked + rerender, switches back
    to "Use/Fork".
  - Publish modal: Premium toggle expands the details panel; split
    note matches platform.splitZapAmount math; lud16-missing surfaces
    the corrective inline error.
  - Regression smoke: custom-art editor + render still work.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 14:28:33 -04:00
thePR0M3TH3ANandClaude Opus 4.7 d22089c3e5 Phase 2 — platform-fee plumbing + dual-zap orchestrator
Adds the platform constants, the npub → lud16 lookup, the split math,
the partial-unlock localStorage cache, and payZapSplit — the
orchestrator that drives a creator/platform dual-zap through the NWC
client added in Phase 1.

`platform` IIFE:
  - PLATFORM_NPUB hard-coded; PLATFORM_FEE_BPS = 3000 (30%). Single
    constants so the fee is a one-line change later.
  - getPubkeyHex() — decodes the npub once, caches the hex result.
  - resolveLud16() — lazily fetches the platform's kind-0 from the
    user's relays (with a 4.5s timeout), picks the most-recent profile
    that has a Lightning address, caches for the session. Falls back
    to lud06 → bech32-decoded lud16 string if lud16 is missing.
  - resolveLnurlMeta() — pulls the LNURL payRequest metadata so we
    can validate minSendable before fetching a real invoice.
  - splitZapAmount(total, bps) — platform takes floor((total*bps)/
    10000); creator gets the remainder so the legs always sum to
    `total`. Defends against tiny amounts (negative, zero, 1-3 sats
    where the platform share floors to zero).

Partial-unlock cache (PARTIAL_UNLOCK_KEY = 'casewrap-partial-unlocks'):
  - Shape: { [eventId]: { creator?: { signedReq, bolt11, preimage? },
    platform?: { ... }, startedAt } }
  - Tiny helpers (getAll / get / save / clear) — Phase 4 will wire the
    cross-session resume banner on top of this.

payZapSplit({ creatorPubkey, creatorMetadata, eventId, totalSats,
              onStatus }) — the core orchestrator:
  1. Resolve creator + platform LNURL metadata in parallel.
  2. Validate minSendable on BOTH sides; refuse with a precise error
     if either LSP won't accept the split share.
  3. Build + sign both kind-9734 zap requests via Promise.all on
     window.nostr.signEvent — the user sees two prompts back-to-back
     rather than minutes apart.
  4. Fetch both bolt11s in parallel.
  5. Pay creator FIRST, then platform (sequential, so a half-failure
     at least pays the creator and the user only retries the platform
     leg).
  6. Before each pay, run nwc.lookupInvoice(bolt11) — if the wallet
     already settled it (idempotent retry across sessions), reuse the
     preimage instead of double-paying.
  7. Persist progress to PARTIAL_UNLOCK_KEY after every step; clear
     only when both preimages are in hand.
  8. Mark unlocked on preimage return — the preimage IS proof of
     payment (it hashes to the bolt11's payment hash). Kind-9735
     receipt arrival is async verification picked up via the existing
     feed-load zap fetcher.
  9. Throws with `err.stage = 'creator' | 'platform'` so the UI can
     show a targeted retry button.

Tested via CDP:
  - Constants + module shape ✓
  - npub → hex pubkey: a4a6b5849b... (correct decode of PLATFORM_NPUB)
  - Split math: 9 cases, including 100→70/30, 101→71/30 (rounding),
    1-3 sats (creator wins ambiguity), and negative/zero edge cases.
  - Partial-unlock cache round-trip ✓
  - Live resolve against real relays: platform npub → adammalin@
    strike.me → Strike LNURL endpoint, minSendable=1000 msats,
    allowsNostr=true. So zaps to the platform will actually settle
    AND issue kind-9735 receipts. (Receipt subscription continues to
    happen via the existing feed-load fetchZapsForEvents path; no
    new background sub needed for v1 auditability.)

The live `payZapSplit` end-to-end with a real CoinOS source wallet
runs in Phase 4 E2E.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 14:15:58 -04:00
thePR0M3TH3ANandClaude Opus 4.7 c3d280c0be Phase 1 — NWC client + wallet settings UI
The NIP-47 client (RPC over a relay-backed WebSocket) plus the
Settings-panel UX for pasting / managing a Lightning wallet
connection. Both required for the zap-gated unlock flow that lands
later in this arc.

NWC client (`nwc` IIFE):
  - parseNwcUri: handles `nostr+walletconnect://` and the
    `nostrwalletconnect://` alt scheme; multiple `relay` params;
    URL-encoded secrets; optional `lud16` param. Returns null on
    any malformed input.
  - connect: opens a WS to the first relay, subscribes to
    `{kinds:[23195], '#p':[clientPubkey]}` for responses and
    `{kinds:[13194], authors:[servicePubkey]}` for the wallet's
    capability info, then fires a `get_info` request to fetch the
    alias / node pubkey. Persists creds + capabilities + last-test
    timestamp under `casewrap-nwc-connection`.
  - payInvoice / lookupInvoice / getInfoFresh: RPC helpers built on
    a generic `_request(method, params, opts)` that NIP-04-encrypts
    the JSON payload to the wallet service pubkey, signs the kind-
    23194 event with the NWC secret (NOT the user's identity key),
    publishes it, and resolves with the decrypted response via the
    `e`-tag correlation. Default 25s timeout, configurable per call.
  - Persistent listener registry (`nwc.on(fn)`) for UI re-renders on
    any state transition (connect, disconnect, response decrypted,
    capability list arrived, WS closed).
  - Lazy reconnect: WS closed → next `_request` reopens; in-flight
    promises are rejected so the caller can surface a real error.

Wallet settings UI (`walletUI` IIFE) — new <details> panel inside
the Nostr modal:
  - Disconnected: CoinOS-led setup with three numbered steps + a
    deep link to coinos.io/settings/connect; a generic paste field
    that also accepts Alby Hub / Mutiny / Phoenix / Cashu.me URIs;
    Connect + Paste-from-clipboard buttons; per-action status line.
  - Connected: alias (from get_info, falling back to "Connected
    wallet"), lud16 or short pubkey, relative "Tested X ago"; Test
    and Disconnect actions; a capability warning that surfaces only
    when the wallet doesn't advertise `pay_invoice`.
  - Summary badge ("— PersistedWallet" / "— not connected") so the
    state is visible without expanding the panel.
  - Subscribes to `nwc.on()` so background reconnects and capability
    discovery update the UI without manual refresh.

Tested via CDP (everything that doesn't need a real wallet):
  - URI parser: real-shape (Alby-style URL-encoded relay + lud16),
    multiple-relay, alt scheme, malformed scheme, short secret,
    empty string. All produce the expected pass / null outcomes.
  - Bad URI on Connect → parser error in status line; UI stays in
    disconnected view.
  - Valid-shape URI to a fake wallet → connects, switches to
    connected view, persists to localStorage.
  - localStorage round-trip: page reload restores the connected UI
    with alias, lud16, relative timestamp, capability set.
  - Disconnect: clears storage + returns to disconnected view.
  - Regression check: existing custom-art editor still works.

The ship gate ("CoinOS-issued URI: get_info returns alias;
pay_invoice 10 sats settles") needs a real wallet, so the live
test moves to Phase 4 E2E.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 13:35:52 -04:00
thePR0M3TH3ANandClaude Opus 4.7 7eae78e909 Phase 0 — crypto bundle for NWC
Bundle @noble/secp256k1 v3.1.0 + @noble/hashes v2.2.0 as an IIFE
(noble-bundle.min.js, 15.8 KB) and load it before the main script.
The bundle exposes window.ArtstrNoble with schnorr, getPublicKey,
getSharedSecret, sha256, hex/randomBytes utils, plus secpUtils.
(noble v3 keeps hash functions external; the bundle wires
hashes.sha256 + hashes.hmacSha256 so schnorr.sign / verify work.)

On top of that, add an `NWC` IIFE in the main script with:
  - sharedSecret(privHex, peerPubHex) — NIP-04 32-byte ECDH x-coord
    (handles x-only 32-byte and full 33-byte pubkey hex).
  - encrypt04 / decrypt04 — AES-256-CBC via crypto.subtle, 16-byte
    random IV, `<base64ct>?iv=<base64iv>` wire format.
  - getEventHash + signEventWithKey — NIP-01 event hashing + schnorr
    signing using a *passed-in* hex privkey (the NWC connection's
    secret, not the user's identity).
  - verifyEvent — for kind-9735 receipt verification later.
  - getPubkeyHex — schnorr x-only pubkey from a hex privkey.

Phase-0 self-test runs once at load: BIP-340 test vector #1 derives
the expected x-only pubkey AND verifies the published signature
through window.ArtstrNoble.schnorr; a NIP-04 round-trip encrypts
+ decrypts a known plaintext between two random keypairs; a
synthetic Nostr event is signed and verified through our helpers.
All checks pass silently; any failure logs a single console.warn
so the user sees "Crypto self-test failed — NWC features will not
work" if a bad bundle ever ships.

Verified via CDP:
  - derivedPub matches BIP-340 vector 1: dff1d77f...502ba659
  - sigVerify on the vector signature: true
  - nip04Roundtrip: true
  - eventSigned + eventVerify: true
  - No console errors loading the app; existing custom-art editor
    smoke test (add a color layer, render) still works.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 13:23:08 -04:00
thePR0M3TH3ANandClaude Opus 4.7 3e75b562a0 Tighten zap-gated spec after a review pass — 8 substantive gaps + polish
Critical additions:

  1. Relay fast-path. Zap receipts live on relays forever — querying
     for matching receipts on feed load means a cache-wiped user (or
     a new device) restores their unlocks for free. Also auto-detects
     a half-failed unlock that eventually settled.

  2. NIP-47 capability discovery via kind-13194. We subscribe to the
     wallet's info event on connect and verify pay_invoice is
     supported before allowing the user to proceed.

  3. Wallet-budget-exhausted handling. Special-case QUOTA_EXCEEDED /
     BUDGET_EXCEEDED so we say "raise your wallet's NWC budget" not
     a generic failure. Also surface this guidance in the CoinOS
     setup flow itself ("set a budget that comfortably covers the
     unlocks you expect").

  4. Preimage = proof of payment. Decouple unlock UX from kind-9735
     receipt arrival. The preimage hashes to the bolt11's payment
     hash; that's all we need to mark unlocked locally. Receipts
     become async verification.

  5. nwcLookupInvoice for double-spend protection. Before paying any
     leg on retry, check whether the wallet already settled it.
     Combined with PARTIAL_UNLOCK_KEY persistence (now storing bolt11
     + paymentHash, not just receipt id), this means cross-session
     retries never double-charge.

  6. LNURL minSendable checks. Block at publish time if the creator's
     LNURL won't accept their split share (with a corrective hint
     showing the minimum viable zap-min). Same defensive check on
     the platform side.

  7. Parallelize the dual-zap. Sign both zap requests in Promise.all,
     fetch both bolt11s in parallel, then pay sequentially with
     creator first. Roughly halves the user-facing latency.

  8. Partial-unlock persistence + banner. If the user closes the tab
     mid-unlock, on next visit a top-bar banner offers Resume or
     Discard for unfinished unlocks.

Polish:

  - Phase 0 bumped 2-3h → 3-5h (noble subset inlining is genuinely
    more work than that estimate).
  - NWC URI parser now handles multiple relay params, URL-encoded
    secrets, optional lud16 param.
  - "Wallet alias from get_info" instead of misleading "walletDomain".
  - CoinOS deep link to coinos.io/settings/connect.
  - Mobile copy-paste note.
  - Three new threat-model rows: platform-pubkey exposure (intentional),
    budget-exhausted-mid-flow (precise retry prompt), and forged-
    receipt fast-path (signature verification).
  - Two new open-questions: pay-above-min unlocks (creator-only
    bonus), currency display.
  - Phase 4 ship gate: "documented in commit message" since we push
    straight to main, no PR workflow.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 13:11:34 -04:00
thePR0M3TH3ANandClaude Opus 4.7 419d816212 Draft zap-gated + NWC + platform-split merged spec; supersede the two old drafts
The original ZAP_GATED_TEMPLATES_FEATURE.md said "no platform fee, no
backend" — that's exactly the opposite of what we actually want to
ship. ZAPS_NWC_FEATURE.md was scoped as a general tip upgrade,
listing splits as a "future enhancement." For our purposes the two
are a single arc, with NWC being the only thing that gives the
mandatory creator/platform split a tolerable UX.

ZAP_GATED_PREMIUM_FEATURE.md is the new canonical plan:
  - 5 phases (0 crypto bundle, 1 NWC, 2 platform-fee plumbing,
    3 zap-gated UI, 4 E2E + edge cases). ~16-23h total.
  - 30% platform fee, single-constant for easy adjustment later.
  - PLATFORM_NPUB hard-coded; lud16 resolved dynamically from the
    npub's kind-0 each session (so the lightning address can change
    later without a code update).
  - CoinOS recommended via a guided "Set up with CoinOS" path in
    the wallet-connection modal; generic "paste any NIP-47 URI"
    fallback for Alby Hub, Mutiny, Phoenix, Cashu.me, etc.
  - Idempotent retry of partial unlocks via localStorage receipt
    tracking — creator-leg pays first so a half-failed unlock at
    least pays the creator.
  - Explicit threat model + non-goals (no creator-only fallback,
    no refunds, no bolt11-QR fallback path).

The two old specs are marked superseded with a banner at the top so
historical context is preserved without future contributors getting
crossed wires. TODO.md drops them from the drafted-but-unbuilt list
and points to the merged doc; it also fixes the stale Private
Publishing entry — Phases 1 & 2 already shipped in 3e6e89e, only
Phases 3 & 4 remain.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 12:49:52 -04:00
thePR0M3TH3ANandClaude Opus 4.7 809e0f8fae Pixel editor: fix dropdown clipping when toolbar wraps near the sidebar
The pixel-art canvas-size dropdown (and the PNG-export dropdown that
shares the same class) used `position: absolute; right: 0` relative to
its toolbar wrap. The wrap lives inside `.pixelEditorBar`, which has
`flex-wrap: wrap`. When the bar wrapped and the trigger button landed
on the LEFT of its row, the menu's right-anchor pushed it 260px to the
LEFT — straight under the main sidebar.

Fix:
  - Switched .pixelExportMenu to `position: fixed` (only when open).
  - Added a `placeFlexMenu(menu, btn)` helper that anchors the menu's
    right edge to the button's right edge by default, flips to the
    button's left edge if anchoring right would push the menu past the
    sidebar (using the aside's actual right edge as the safe-left
    boundary, not just the viewport edge), and finally clamps to the
    viewport on both sides.
  - Added a fixed width (260px) + box-sizing border-box to keep the
    menu from expanding to its natural content width once it's freed
    from the previous absolute-positioning constraint.
  - Window-resize handler auto-closes any open menu — the fixed coords
    were computed against the previous layout, so resizing would
    otherwise strand the menu away from its button.

Verified via CDP across 920px / 1000px / 1100px / 1440px viewports and
in both sidebar-open and sidebar-collapsed states. Also exercised the
specific failure scenario (button moved to the leftmost slot of the
bar, simulating the worst-case wrap layout) — menu now opens to the
right of the button, fully inside the canvas area.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 09:44:29 -04:00
thePR0M3TH3ANandClaude Opus 4.7 733d123edc Top-right action strip: pin undo/redo + canvas-size readouts under Nostr widget
The undo/redo pair and the trim/sheet canvas-size pills lived inside
.topbar's flex row, which wraps at narrow widths. Adam wanted them in a
permanent, predictable place so muscle memory works regardless of
editor mode or sidebar state.

Changes:
  - .status is now `position: fixed; top: var(--top-strip-height);
    right: 0; flex-wrap: nowrap` with the same panel/border styling as
    the Nostr widget — a second strip directly below it.
  - Reordered the markup so the canvas-size pills come first and the
    undo/redo group last, so undo/redo end up hugging the viewport edge
    and the pills sit to their left (matching the requested layout).
  - Dropped the `body.app-designer .status` override (full-width
    left-aligned), since the new positioning is universal across modes.
  - updateNostrWidgetSpace() now measures the wider of the Nostr widget
    or the new strip and reserves topbar right padding accordingly, so
    the project title can't slide under the strip.

Verified via CDP at 1440px (cover, customart, pixelart modes) and at
900px narrow — strip stays pinned top-right with undo/redo on the
outside edge in every case.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 08:59:40 -04:00
thePR0M3TH3ANandClaude Opus 4.7 3c40951a8a Template browser: stop pinning rendered count to the first stream batch
Reported issues that all turn out to share one root cause:
  1. "Open browser, keep it on All — nothing loads. Switch to Case Covers
     or Disc Designs — they're all there."
  2. "Still need to scroll down to see them all."
  3. "Up-vote sorting and zap sorting seems not to work."

Symptom CDP confirmed: after opening Browse, `_feedPagination.New` ends
up with `rows: 72, rendered: 7`. Only 7 cards in the wall on the All
filter; 7 on Case Covers (out of 26); 7 again on All. So the wall is
clamped to the first batch size the streaming fetch happens to deliver.

Root cause: in loadFeedTab's rerender(), the reset for rendered was
`!rendered || rendered > rows.length`. As templates stream in via
notifyFeedProgress, the first rerender sees ~7 events and sets
rendered=7. Later rerenders with rows growing to 72 leave rendered
pinned: 7 is neither 0 nor `> 72`, so neither reset clause fires. The
auto-fill RAF check then short-circuits because 7 cards already
overflow the (short, modal) viewport, so it never tops up. And the
Popular / Zapped sorts re-sort all rows but only the first 7 ever
reach the DOM — so the visible order looks like the wrong top results.

Fix: change the reset to `rendered < chunkTarget`, where chunkTarget =
min(rows.length, chunk). That grows rendered up to a full chunk on
every rerender as long as enough rows exist, while still preserving
the user's scroll-loaded extra pages (rendered > chunk stays put).
Verified via CDP: All shows 24/72, Case Covers 24/26, All again 24/72.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 19:56:00 -04:00
thePR0M3TH3ANandClaude Opus 4.7 15ad4e93c3 Nostr login: persist to autosave on completion, not just on next render
Reported: "the nostr login doesnt persist after a refresh now."

Root cause: state.community.myPubkey is written by loadCommunityIdentity,
but the localStorage write only fires from inside render(). nostrLogin
calls loadCommunityIdentity → syncCommunityInputs + updateNostrWidget,
none of which call render(). A user who signs in and refreshes
immediately (without making any other interaction that triggers a
render — adding a layer, toggling a setting, etc.) loses the login,
because the autosave still reflects the pre-login state captured by an
earlier init-time render().

Fix: extract the autosave write into a `persistAutosave()` helper and
call it from both early-return paths of loadCommunityIdentity (logout /
no extension and successful sign-in). render() now calls the same
helper, so the on-disk format is identical to before.

Verified end-to-end via CDP:
  - Stub window.nostr → call loadCommunityIdentity → no render → refresh
    → state.community.myPubkey restored, profile widget shows.
  - Same flow but window.nostr throws (logout) → cleared pubkey
    persisted as ''.
  - render() autosave still captures layers correctly (regression check
    on the helper extraction).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 19:46:20 -04:00
thePR0M3TH3ANandClaude Opus 4.7 d6dcdfba11 Community browser: don't trash the welcome modal's pre-warmed cache
The previous "auto-refresh on Browse click" commit unconditionally
called invalidateFeedCache() before opening the modal. That set
_feedCache.events = null and forced a 6-second re-fetch on every
Browse click — wiping the cache the welcome modal had populated
seconds earlier. Symptom: cold reload → welcome loads instantly →
exit → Browse shows "Loading..." for ~6s with nothing rendered,
while changing the type filter (which only re-filters the cache)
appears to "work" once the fetch finally lands.

Smarter behaviour:

- Add a `force` option to fetchFeedAndReactions that bypasses its
  5-minute cache check. Use this from the Browse buttons.
- Don't blow away the cache. Open the modal, switch to the New
  tab, which renders cached cards immediately via the existing
  `if (_feedCache.events && _feedCache.events.length) rerender()`
  path in loadFeedTab. Then trigger a force refresh only if the
  cache is staleish (>30s old).

So: welcome just-loaded cache (age=2-5s) → Browse opens instantly
with those cards, no extra fetch. Older cache (age=1m, 4m, etc.) →
Browse renders cached cards instantly AND kicks off a background
refresh whose progress streams in via notifyFeedProgress. The
explicit Refresh button in the modal is still available for
on-demand forced pulls.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 19:28:38 -04:00
thePR0M3TH3ANandClaude Opus 4.7 e8c3115236 Community browser: auto-fill viewport on initial render
Symptom: after opening the browser, users had to scroll down a few
pixels to see the rest of the initial card chunk. Cause: loadFeedTab's
rerender() draws the first chunk (24 cards) via diffWallCards but
skips the scroll-triggered auto-fill chain. appendFeedRows DOES
have that chain (chained-RAF that keeps adding chunks until the
modal scroller becomes scrollable), but it only runs when the user
scrolls.

Fix: after the initial diffWallCards, schedule a RAF that runs the
same viewport-fullness check appendFeedRows uses — if the modal
isn't yet scrollable AND there are unrendered rows, call
appendFeedRows. The recursive RAF inside appendFeedRows then keeps
adding chunks until the viewport is full or rows are exhausted.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 19:23:28 -04:00
thePR0M3TH3ANandClaude Opus 4.7 fc8a89d2fa Community browser: auto-refresh on open + longer relay timeouts
Three reported issues:

1. "When I click the button it should auto-refresh." The two Browse-
   Community buttons opened the modal and called switchModalTab('New'),
   which routes to loadFeedTab. fetchFeedAndReactions honours a 5-minute
   cache (line 16310), so a user reopening the browser within that
   window saw stale results — including not seeing their own design
   they just published. Add invalidateFeedCache() before opening the
   modal so the 5-minute cache is bypassed on every Browse click.
   Applied to both #browseCommunityBtn and #designerBrowseCommunityBtn.

2. "Sometimes it does not load everything in correctly each time."
   The relay fetch timeouts were tight enough that slow relays would
   miss reply windows. Bump:
     - templates: 4500ms → 6000ms
     - deletions: 3500ms → 5000ms
     - reactions: 2500ms → 4000ms
     - zap receipts: 4500ms → 6000ms
   Streaming via onEvent is unaffected, so the first event still
   appears the moment any relay responds; the longer ceiling just
   means more events make the cut before the await resolves.

3. "Zap and up-vote ranking is not working reliably." The sort logic
   in loadFeedTab is correct — Popular sorts by zap total, then
   reactions, then trust, then date; Zapped sorts purely by zap
   total. Reactions are fully fetched before the initial rerender;
   zap receipts stream in asynchronously and trigger debounced
   rerenders via scheduleZapStreamRerender → notifyFeedProgress,
   which re-sorts using the now-current _zapCache. So unreliable
   perception was mostly "I see initial sort, then it reorders."
   The fix here is indirect — longer zap timeout + auto-refresh on
   open mean more zaps arrive within the first rerender window, so
   the user sees the correct order sooner instead of watching it
   shuffle.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 19:20:07 -04:00
thePR0M3TH3ANandClaude Opus 4.7 0688fc1e93 TODO: clean Adam's list — all 7 items shipped this session
Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 19:10:46 -04:00
thePR0M3TH3ANandClaude Opus 4.7 88bd59bf12 JPEG export: DPI / quality selector
Small dropdown next to Export JPEG with three presets:

- 96 dpi  / quality 0.85  — fast, screen-only
- 150 dpi / quality 0.92  — standard (default)
- 300 dpi / quality 0.95  — print-grade

The picker lives next to the existing Print/Save PDF + Export JPEG
buttons in the Print & Save panel. The click handler reads the
selected option and passes the right { dpi, quality } pair to
exportArtboardAsJpegBlob. No persisted state — defaults to standard
on each page load.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 19:10:03 -04:00
thePR0M3TH3ANandClaude Opus 4.7 0cf8d05c1e Resize: corner handles default to independent axes (Shift = lock aspect)
Corner handles used to lock the aspect ratio whenever the sidebar 🔒
toggle was on (the default). Result: dragging the corner horizontally
a lot and vertically a little still scaled both axes by the
horizontal-driven factor — width and height grew at the SAME rate
regardless of the drag vector.

Switch to the Figma / Photoshop / Keynote convention:

- Corner handle drag → axes are independent. Each axis follows its
  own drag delta. Drag right-and-a-bit-down → width grows a lot,
  height grows a little, matching the cursor exactly.
- Hold Shift while dragging the corner → lock the aspect ratio. Same
  math as before (the larger-magnitude axis sets the scale, both
  axes get it).

The sidebar 🔒 toggle still governs typed W / H input cascades — so
typing Width:5 with the lock on still updates Height proportionally.
Only the visual corner-drag behaviour changed.

Same fix applies to:
- startLayerResize (single-layer corner handles).
- startLayerSelectionResize (multi-select bounding-box corner
  handles).

Edge handles (n/s/e/w) are unchanged — they're always single-axis.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 19:09:03 -04:00
thePR0M3TH3ANandClaude Opus 4.7 54cf915b4f JPEG export: rich-text rendering (P2) via canvas run-layout
The plain-text fillText path (drawTextLayerPlainToCanvas) strips inline
marks via layerHtmlToPlainText. Add a rich-text path that preserves
inline bold / italic / underline / lists / links in the JPEG output.

First attempt was an SVG <foreignObject> wrapping the layer's HTML,
loaded via a Blob URL and drawImage'd onto the canvas — clean and
short. Chromium taints the canvas on every <foreignObject> SVG
regardless of origin, so toBlob('image/jpeg') threw SecurityError.
Confirmed via CDP: `kind:'taint', err:"Tainted canvases may not be
exported."`

Switched to a pure-canvas rich-text engine:

- _parseTextRunsFromHtml walks the layer's HTML into a flat list of
  styled runs ({text, bold, italic, underline} or {br}). Handles
  <b> / <strong>, <i> / <em>, <u>, <a> (visual underline only,
  links don't survive into the canvas image), <br>, <p> / <div> /
  <h1>-<h3> (block break before+after), <ul>/<ol>/<li> (bullets
  for unordered, "1.", "2.", … prefixes for ordered, properly
  nested via a list stack).
- _layoutTextRuns walks the runs with ctx.measureText, builds a line
  list with word-wrap at the layer's inner width. Trailing whitespace
  at a wrap point is discarded.
- drawTextLayerRichToCanvas applies the layer's clip, walks the lines
  with proper left / center / right alignment using each line's total
  width, fillText per item, and a tiny fillRect under each underlined
  run (sat just below the baseline at ~95% line-height, ~6% thick).

The dispatcher drawTextLayerToCanvas calls the rich path when
_textHasInlineMarks(layer.html) — i.e. layer.html contains any of
b/strong/i/em/u/a/br/ul/ol/li/span/font — and the plain path
otherwise. drawLayerToCanvas now awaits drawTextLayerToCanvas (it
became async).

End-to-end verified via CDP on a Custom Art canvas:
  layer 1: "<b>Bold</b>, <i>italic</i>, <u>underlined</u>,
           <a href=…>link</a>."
  layer 2: "<ul><li>Item one</li>… </ul>"
Resulting JPEG renders Bold as bold, italic as italic, both
underlined and link as underlined, and the list as bulleted lines.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 19:07:24 -04:00
thePR0M3TH3ANandClaude Opus 4.7 2d1ed1124d Custom Art: match canvas to imported image's natural dimensions
New "Match canvas to image size" button in the selected image
layer's panel. Only visible in Custom Art mode (Slide is 16:9-
locked; Cover/Disc/Jewel use sheet dimensions tied to physical
media).

Behaviour:
- Loads the image via loadImageWithCors with a short timeout so a
  CORS-hostile or unreachable URL errors cleanly instead of hanging.
- Updates state.customArt.width / height to the image's
  naturalWidth / naturalHeight, clamped to [CUSTOM_ART_MIN,
  CUSTOM_ART_MAX]. Marks preset as 'custom'.
- Pushes one history entry so the resize is undoable.
- Toasts the resulting canvas size (and notes if the image was
  clamped down because it exceeded CUSTOM_ART_MAX).

The button visibility is computed in syncLayerInputs: image type
+ has src + templateMode === 'customart'. Hidden otherwise.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 13:55:30 -04:00
thePR0M3TH3ANandClaude Opus 4.7 902befa189 Fix blank PDF print regression — split exporting / export-print classes
Recent commit 119b14e added body.classList.add('export-print') to
the regular print flow (printWithCurrentFilename) to strip selection
chrome from the output. That broke PDF print entirely because an
existing @media print rule —

  body.export-print > *:not(#exportPrintContainer):not(#globalClipDefs) {
    display: none !important;
  }

— hides every direct body child when body.export-print is set. The
JPEG-PDF flow first reparents the sheet into #exportPrintContainer
(enterExportPrintMode), so that rule isolates the sheet correctly.
The regular print flow doesn't reparent anything, so the rule just
hid the entire .app and printed a blank page.

Fix: two classes with separate responsibilities.

- `body.exporting` — chrome stripping only (selection outlines,
  resize handles, layer selection box, canvasVeil, color-adjust
  hints). Safe to apply during any print/export, because nothing
  hides the document.
- `body.export-print` — also reparents the sheet into
  #exportPrintContainer. Only the JPEG-PDF flow does this setup.

Wiring:

- printWithCurrentFilename adds/removes body.exporting (no
  export-print) — PDF print path now renders the live DOM with
  only chrome stripped.
- enterExportPrintMode adds both classes; exitExportPrintMode
  removes both.
- CSS chrome-stripping rules renamed from body.export-print →
  body.exporting (the existing body.export-print container-isolation
  rules at line 2588+ stay as they were).

Verified via CDP Page.printToPDF on customart mode: PDF is 1 page
at 960×540 pts (= 1280×720 px at 72 pt/in), text content "Print
Test" extracts, color block + crop marks render, and the
selected-layer outline is no longer visible (was visible before
my chrome-stripping rules existed; still working now).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 13:53:36 -04:00
thePR0M3TH3ANandClaude Opus 4.7 29dcdeea61 Canvas crop marks for Custom Art + Slide modes (toggleable)
Crop marks were a cover-only feature; Custom Art / Slide canvases
exported with no corner registration marks at all. Add a corner
crop-mark set for canvas modes with the existing showCrops /
printCrops toggles wired up.

Changes:

- New <div class="cropMarks canvasCrops"> sits inside .sheet
  alongside the existing cover cropMarks. Eight corner marks,
  positioned outside the canvas edges via new .cc-* classes that
  are scoped under .canvasCrops so they don't collide with the
  bleed-anchored cover .cm-* positions.
- CSS shows .canvasCrops only in body.template-customart and
  body.template-slide. It reuses the existing `--crop-display` /
  body.print-crops gating, so state.showCrops drives on-screen
  visibility and state.printCrops drives print visibility — the
  same toggles the cover mode has used all along.
- Move showCrops and printCrops <label>s from `cover-toggle` to a
  new `crops-toggle` class. Cover-toggle still hides showGuides /
  showBleed / printGuides in non-cover modes (those are bleed/safe
  concepts that don't apply to canvas modes); crops-toggle shows
  in cover / customart / slide, hidden only in disc + jewel sheets.

JPEG export was already unaffected — the direct canvas rasterizer
doesn't touch the DOM, so canvasCrops never appears there. The PDF
Print route does render the DOM, where printCrops now controls
inclusion.

Verified via CDP: in customart mode, both toggle labels render,
.canvasCrops is visible by default with a valid corner-span
bounding box, and `state.showCrops = false; render()` hides it.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 10:17:12 -04:00
thePR0M3TH3ANandClaude Opus 4.7 119b14e1d3 Editor UX: click background to deselect + strip selection chrome in print
Three small editor-UX fixes:

1. Click in the canvas gutter → deselect. The existing #sheet click
   handler only fired when the click landed inside .sheet; clicks in
   the area between the sheet edge and the previewShell edge
   ("sheetWrap" parent) hit no handler and the selection stuck.
   Added a #previewShell click handler that deselects when the
   click isn't on a .freeLayer and isn't on #sheet (which the
   existing handler still covers). Layer .freeLayer clicks already
   stopPropagation, so they don't bubble through.

2. Selection chrome no longer bleeds into PDF / JPEG-fallback
   output. The codebase had `body.exporting .freeLayer.selected
   { outline: none }` rules sitting dead — JS only ever toggles
   `body.export-print`. Renamed the CSS to match. Also added rules
   to hide .layerHandle (resize squares) and .layerSelectionBox
   (multi-select dashed box) under the same class.

3. The regular Print / Save PDF button (printWithCurrentFilename)
   wasn't adding any export class around window.print(), so
   selection chrome bled into single-sheet prints too. Added
   body.classList.add('export-print') before the print + the
   matching remove on afterprint. The JPEG-PDF-fallback flow
   (enterExportPrintMode) already added this class; now both flows
   benefit from the same CSS.

Verified via CDP: clicking in the gutter outside the sheet now
deselects ({selId:'', isSel:false}).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 10:10:02 -04:00
thePR0M3TH3ANandClaude Opus 4.7 cfd42dd92a TODO: mark JPEG P1 shipped; refine P2/P4 + selection-chrome scope
Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 10:04:36 -04:00
thePR0M3TH3ANandClaude Opus 4.7 b890f78f1f JPEG export P1: one-click direct rasterizer, PDF fallback on CORS
Replace the click-Export-JPEG → print-to-PDF → save-to-disk → re-pick
flow with a single click. The canvas-rasterizer infrastructure was
already 90% built in this codebase — drawLayerToCanvas,
loadImageWithCors, setupExportCanvas, and rasterizers for cover /
disc-sheet / jewel / disc-design all existed but were never wired
into any user-facing entry point. This commit ties them together.

What changed:

- New renderCustomArtToExportCanvas(dpi) — the one missing rasterizer.
  Handles customart / slide canvases: background colour fill + every
  canvas-target layer drawn in z-order via the existing per-type
  drawers.
- New exportArtboardAsJpegBlob({dpi, quality}) orchestrator. Branches
  by current templateMode (and appMode for the disc-design designer):
    cover  → renderCoverToExportCanvas
    disc   → renderDiscSheetToExportCanvas
    jewel  → renderJewelSheetToExportCanvas
    designer-disc → renderDiscDesignToExportCanvas
    customart / slide → renderCustomArtToExportCanvas
    pixelart / deck → {ok:false, kind:'unsupported'} (caller falls back)
  Clears _exportFailedImages, renders, then:
    - if any image failed to load (CORS-blocked, 404, timeout) →
      {ok:false, kind:'cors', urls: [...]}
    - if toBlob throws (canvas tainted, shouldn't happen but defensive) →
      {ok:false, kind:'taint'}
    - else {ok:true, blob}.
- exportArtboardAsJpeg(btn) rewritten as an async click handler:
  disables the button + sets a "Rendering…" label, calls the
  orchestrator, downloads the blob on success, or routes to the
  existing openPdfJpegModal() with a toast on cors/unsupported/taint.

End-to-end verified via CDP against the running app:

- 4-layer Custom Art (color + shape circle + plain text + QR code) →
  direct JPEG download, 1280×720 px, ~43 KB at dpi=96 quality=0.92.
  All four layers render in the right positions (PNG preview attached
  to the verification — color block top-left, red circle adjacent,
  bold text below, scannable QR overlap with the text bounds).
- Single image layer with an unresolvable URL → {ok:false, kind:'cors',
  urls:[...]}, which the click handler converts to a toast + the
  existing print-to-PDF modal.

P1 scope ships customart / slide / cover / disc-sheet / jewel /
disc-design. Pixel-art standalone keeps its own PNG/GIF export, and
slide-deck keeps the multi-page PDF flow (both intentionally not
covered here).

Rich-text inline marks (bold / italic / underline / links / lists
applied to sub-spans) still render as plain text in the JPEG —
existing drawTextLayerToCanvas uses layerHtmlToPlainText. P2 adds
SVG-foreignObject rich-text rendering.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 09:34:39 -04:00
thePR0M3TH3ANandClaude Opus 4.7 d95acfaf17 TODO: queue one-click JPEG export via direct canvas rasterizer
Current JPEG flow makes the user save a PDF, then re-pick it from
disk for PDF.js to convert. We can't cache the PDF in between
because window.print() doesn't expose the generated bytes to JS.

Log the proper fix: write a direct canvas rasterizer that walks
the layer model (image / text / shape / color / qr / pixelart),
draws each layer onto an off-screen canvas at the artboard's design
dims, then toBlob('image/jpeg'). Same approach the pixel-art PNG
export already uses, generalised across all layer types. CORS-
tainted remote images fall back to the existing PDF round-trip,
with a toast explaining why. ~300–500 LoC, no new dep.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 09:14:08 -04:00
thePR0M3TH3ANandClaude Opus 4.7 1c6a239f5a TODO: queue 5 new editor-UX items + CORS quick-reply
Five items added to Adam's working list:

- Click canvas background → deselect everything in vector editor.
- Toggle crop marks for export previews (so they stay out of PDF /
  JPEG output).
- Auto-size Custom Art canvas to match an imported background image's
  natural dimensions (clamped to CUSTOM_ART_MAX, with the Slide
  16:9-locked case skipped).
- JPEG export: hide layer-selection outlines before the raster snapshot.
- Independent-axis corner-node resize so a mostly-horizontal drag
  scales width a lot and height a little — applies to all layer
  resize handles, not just text.

Plus a "CORS for remote-image raster export" subsection noting that
the PDF and JPEG paths both go through window.print() / PDF.js (so
CORS doesn't actually bite today) and listing what to consider if it
ever does — Blossom / CORS-friendly hosts, an optional same-origin
proxy if we ever stand up a backend, or a direct canvas-taint
workaround.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 09:10:03 -04:00
thePR0M3TH3ANandClaude Opus 4.7 dc31b023d5 Text layers: actually enter edit mode on second click (defer focus + relookup)
The previous attempt added a focus() call in the pointerup stop()
branch, but it never stuck. CDP-driven tests against the real app
showed two reasons the focus kept getting clobbered:

1. The click event still fires AFTER pointerup, propagates up to the
   .freeLayer's click handler, which re-runs selectLayerWithEvent +
   renderLayerList + syncLayerInputs. Something in that chain blurs
   the contenteditable (focus log: focusin → blur → focusout, all on
   the .textLayer, in that order).
2. The selection change between the two clicks triggers a render()
   somewhere — which rebuilds the layers and DETACHES the original
   .textLayer node that startLayerDrag's closure captured. So even
   when focus did briefly land on textTarget, that textTarget was
   already a stale, detached element by the next tick.

Fix:

- Defer the focus call via setTimeout(0), so it runs AFTER the click
  event chain completes and any blur-on-click side effects have
  fired.
- Inside the deferred callback, re-query the live .textLayer for the
  layer's id via `.freeLayer[data-layer-id="..."] .textLayer`, so we
  never focus a detached element.
- Only apply the click-position caret range if it's inside the LIVE
  editable (caretRangeFromPoint can resolve into the detached node
  if the layout hasn't reflowed yet).

Verified end-to-end via CDP + Input.dispatchMouseEvent against the
running app (with the welcome modal dismissed and a fresh ?n=…
cache-bust). Two single clicks in sequence:
- After click 1: state.selectedLayerId='tx1', activeElement=BODY.
- After click 2: activeElement is a DIV with className='textLayer'
  and isContentEditable=true — caret is now in the text and the
  user can drag-select / type / apply the toolbar buttons.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 09:04:39 -04:00
thePR0M3TH3ANandClaude Opus 4.7 df45b63f45 Text layers: click already-selected text to enter edit mode
The previous click flow only entered edit mode on a true double-click
(event.detail > 1 on the second pointerdown), which is timing-
sensitive — slow second clicks fell through to layer-drag and edit
mode was hard to reach. Result: users clicked the text and the
whole layer kept "selecting" as an object.

Three changes to startLayerDrag to fix it:

1. Capture `wasSoleSelected = isLayerSelected(layer.id) && selectedLayerIds().length === 1`
   at the top of the handler, BEFORE the selection logic runs.
   This tells us whether the user was clicking on a text layer
   that was already the lone selection — the standard signal in
   PowerPoint / Keynote / Google Slides that the user wants to
   edit the text now.

2. A small dead-zone in the move handler (Math.hypot(dx, dy) < 4
   ignores tiny jitter). Without it, a click with a 1-pixel mouse
   wobble flipped `moved` to true and skipped the edit-mode path.

3. New branch in stop() — if the pointer was released without
   crossing the dead-zone (moved === false), the click target was
   inside .textLayer, and wasSoleSelected was true, focus the
   contenteditable and place the caret at the click position via
   document.caretRangeFromPoint (with a caretPositionFromPoint
   fallback for Firefox-style browsers).

Net behaviour:

- First click on an unselected text layer → selects (drag handles
  appear, no edit mode).
- Second click on the same now-selected text layer → enters edit
  mode, caret lands where the click was.
- Click + drag on a selected text layer → drags normally (moved
  > 4px, edit-mode branch skipped).
- Existing double-click path (event.detail > 1) still works as a
  belt-and-suspenders fallback for very fast double-clicks.

CDP synthesized-event tests couldn't fully drive the pointer-
capture path so end-to-end verification needs a real browser; the
logic was code-reviewed.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-27 08:50:17 -04:00
thePR0M3TH3ANandClaude Opus 4.7 aaf044a946 Text layers: inline rich-text formatting toolbar
Replaces the previous whole-layer Link URL field with proper inline
rich-text editing. Users click into a text layer on the canvas,
select a range, and apply formatting to just that selection.

New side-panel toolbar buttons under the text-layer controls:

- B / I / U — bold / italic / underline on the selection.
- • / 1. — bulleted list / numbered list.
- 🔗 — insert link on the selection (prompts for URL; rejects
  anything that isn't http / https / mailto / tel / nostr via
  safeTextLinkHref).
- ⌧ — unlink the selection.

Wiring:

- Each button uses mousedown.preventDefault() so the contenteditable
  text layer keeps focus / selection when the user clicks the sidebar
  button.
- _runInlineFormat() locates the contenteditable via the
  .freeLayer[data-layer-id="..."] .textLayer selector, focuses it
  if needed, runs document.execCommand, reads innerHTML back into
  layer.html, and debounce-pushes one undo entry. No full render —
  that'd rebuild the editable and lose focus / selection.

Security: applyPreviewLayerTextStyle (the preview path) and the
in-editor render path both post-render-scrub every <a> tag —
unsafe hrefs get unwrapped (text kept, link dropped) and safe ones
get target="_blank" rel="noopener noreferrer" forced on.

End-to-end verified via headless Chrome + CDP. A test deck with
layer.html =

  Visit <a href="https://artstr.org">Artstr</a> for more or
  <a href="mailto:[email protected]">email us</a>.

  Try this <a href="javascript:alert(1)">bad link</a> too.
  Some <b>bold</b>, <i>italic</i>, and <u>underlined</u> bits.
  <ul><li>One</li><li>Two</li><li>Three</li></ul>

— PDF export:
  - pdftohtml -i extracts ONLY the legit URLs: https://artstr.org/
    and mailto:[email protected]. The javascript: link was stripped.
  - Visual render: "Artstr" and "email us" are underlined links;
    "bad link" is plain text; bold / italic / underline marks apply
    only to their selected sub-strings; <ul> renders with bullets.

The whole-layer Bold / Italic toggles in the existing text-tool row
remain — they set the default weight / style for the layer; inline
marks override per-span.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 22:25:28 -04:00
thePR0M3TH3AN 8b811bc176 dogfooding icons
made with artstr
2026-05-26 16:13:06 -04:00
thePR0M3TH3AN 824ae0350a dogfooding icon
made with artstr
2026-05-26 16:02:19 -04:00
thePR0M3TH3ANandClaude Opus 4.7 d753453258 Text layers: optional hyperlink URL, live in previews + clickable in PDF
A new "Link URL (optional)" field in the text-layer panel attaches a
hyperlink to a text layer. When set, every render path that goes
through applyPreviewLayerTextStyle wraps the layer's text in a
display:block <a href> covering the whole text box, so:

- Editor / community / publish-modal previews show the text as a
  visible underlined link.
- Deck PDF export passes the <a href> through Chrome's print engine,
  which converts it to a clickable PDF annotation. Verified end-to-
  end via headless Chrome + CDP: pdftohtml extracts the link from
  the generated PDF as href="https://...".

Safety:

- safeTextLinkHref() only accepts http / https / mailto / tel /
  nostr schemes. javascript: and data: URLs are stripped (verified
  in the same test by including a javascript:alert(1) layer that
  rendered as plain text with no PDF link emitted).
- escAttr() escapes the href value before it lands in the rendered
  HTML attribute.
- target="_blank" rel="noopener noreferrer" on the emitted anchor.

The canvas editor's contenteditable render path (separate from the
preview path) is untouched, so users can still edit the text inline
without the <a> wrapper interfering with cursor / selection. The
link only appears in preview / export contexts.

Tested with a CDP harness driving the actual app: 1-slide deck with
a legit link + a javascript: link, exported via Page.printToPDF.
- PDF page count: correct.
- pdftohtml -i: extracts only the legit https URL.
- Visual render: legit link is underlined, javascript link plain.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 15:09:16 -04:00