Commit Graph
299 Commits
Author SHA1 Message Date
thePR0M3TH3AN f6007876b7 External embed Phase 1: chromeless read-only viewer
Per docs/EXTERNAL_EMBED_FEATURE.md §18 Phase 1.

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

Manual smoke-test paths:
  http://localhost:8000/?embed=<naddr>            (query form)
  http://localhost:8000/#/embed/<naddr>           (hash form)
  http://localhost:8001/                          (cross-origin
                                                   iframe host)
2026-06-01 09:01:35 -04:00
thePR0M3TH3AN 4d355f87d4 External embed: draft v2 spec
Updated rewrite of the earlier embed plan covering every Artstr
feature shipped since the original sketch:

- Linked sub-design resolution inside embeds (reuses
  linkedDesigns.prefetchBatch + resolveLinked + disk cache, full
  cycle/depth/byte safety).
- Premium boundary: watermarked preview + "Open in Artstr to
  unlock" CTA; no in-iframe zap.
- Private boundary: hard refuse with 🔒 placeholder; no decrypt
  attempts.
- Pixel-art animation playback (auto-play, fps override,
  prefers-reduced-motion respected).
- Deck slide picker — `?slide=N` deep-links; linked-deck layers
  carrying design.slideIndex project to a single-slide payload.
- Credits overlay (opt-in via `?credits=1`), mirroring the
  in-app Credits panel.
- PostMessage handshake for parent-page integration (deck
  control, fullscreen request, ready/error events).
- Layer-group handling falls out naturally — groups are flat
  layers with groupId; the existing renderers already paint them.
- Open Graph metadata on the embed route URL itself.
- Per-event + per-tree byte caps inherited from linked-designs.
- Phase plan + acceptance criteria + risks updated.

No code; this is the planning artifact. Implementation phases
0-9 are scoped inside the doc.
2026-05-31 22:46:16 -04:00
thePR0M3TH3AN 8439b358c3 Linked Designs P3d: batched prefetch on cold start
Last Phase 3 polish piece per LINKED_DESIGNS_FEATURE.md §14. The
cold-start path used to fire one parallel queryRelays per linked
layer — a host with 20 distinct linked sources opened 20 sockets
to each relay in the union. Now restoreAutosave issues one
batched relay round-trip via the new linkedDesigns.prefetchBatch.

Mechanics:

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

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

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

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

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

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

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

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

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

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

Implementation:

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

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

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

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

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

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

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

Storage shape (artstr.linkedDesignCache.v1):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The selection-sync block now hides the embedded-design panel and
shows the linked-design panel when design.kind === 'linked'.
2026-05-31 17:21:33 -04:00
thePR0M3TH3AN 45d370164d Linked Designs P1c: import flow + Link from Nostr button
User-facing entry point to create a linked design layer. Both
layer-panel sites (designer-app disc target + template-app per-
target panel) now expose a "🔗 Link from Nostr" button alongside
the existing "Import from Nostr" — embed vs link is a one-click
distinction at import time.

Mechanics:

- openLinkedDesignNostrSearch(targetOverride) mirrors the existing
  embed-flow opener but stashes state._designLayerImportMode =
  'link' before opening the community modal. The modal is unchanged;
  the same row-click handler picks the route.
- addLinkedDesignLayerFromRow(row, target) creates a
  kind: 'linked' layer with { ref, mode, pinnedEventId: null }. Ref
  is built from the source event's kind + pubkey + d-tag. Refuses to
  link sources without a d-tag (legacy non-replaceable events). The
  initial layer dimensions mirror the embed path — uses
  designObjectAspect on the freshly-clicked payload — so the layer
  lands at the source's natural aspect. Layer name prefixed with
  '🔗 ' so it's visually distinct in the layer list.
- tryDesignLayerImportRow now dispatches on _designLayerImportMode:
  'embed' keeps the existing addDesignLayerFromPayload behaviour;
  'link' routes to addLinkedDesignLayerFromRow. Resets the mode
  back to 'embed' on completion or cancellation.
- openDesignLayerNostrSearch (the embed entry point) explicitly
  sets mode = 'embed' on open so a previously-cancelled link
  attempt doesn't pollute the next embed.
- The new addLinkedDesignLayerFromRow warms the resolver cache
  with the just-fetched payload so the very first render is sync —
  no placeholder flicker for the user who just clicked.

Toast copy is honest about the difference:
- embed: "Design imported as an editable object layer."
- link:  "🔗 Linked — renders the latest version of the source on
         each refresh."

Layer-options panel (refresh / pin / unlink / replace) for selected
linked layers is the next commit in this phase.
2026-05-31 17:14:35 -04:00
thePR0M3TH3AN dd6f7e35c3 Linked Designs P1b: async render branch in renderDesignObjectIntoElement
Implements the sync-placeholder + async-upgrade render pattern
from spec §8.1. Linked design layers now resolve their source from
relays on first render, with the safety rails wired in.

Render flow for kind: 'linked' layers:

1. Compute the cache key (address or pinned event id).
2. Walk DOM ancestors (data-linked-key markers) to detect
   cycles (key already in chain) and depth-3 overshoots. Both
   render an explicit visible placeholder rather than silently
   no-op'ing, so the user sees why nothing painted.
3. Tag the host element with data-linked-key so deeper renders
   can see this layer in their own ancestor walk.
4. Sync cache peek (linkedDesigns.peek) — if the source is
   already in the session cache, render the resolved payload
   immediately and skip the placeholder flicker.
5. Otherwise: show a "⟳ Loading linked design…" hint (or the
   persisted fallbackPreview JPEG if the user opted in), kick
   off the async resolver, and upgrade fitWrap when it returns.
   Self-aborts via el.isConnected check if the host re-rendered
   while we were fetching.
6. On resolve, cache lastResolvedAt + lastResolvedSize on the
   layer for the upcoming layer-options panel.
7. On miss (source not on relays), show "🔗 Linked source not
   found — refresh to retry" unless the layer's fallbackPreview
   is already drawn.

Embedded path (kind: 'embedded' or legacy layers without kind)
unchanged — sync render from inline payload, same as before.

Two new helpers:
- _collectLinkedAncestorKeys(el): walks DOM upward collecting
  data-linked-key values. Lets cycle / depth detection work
  without threading a context object through every renderer.
- _sizeFromDesignPayload(payload): pulls a (w, h) hint from any
  design payload (customart / deck-first-slide / pixel-art).
  Used to set lastResolvedSize for the options panel.

The resolver IIFE gained peek() and keyFor() public helpers so
the renderer can do its sync cache check + key computation
without reaching for the underscore-prefixed internals.

No UI yet for creating linked layers — that's Phase 1c. With this
commit, a manually-crafted linked layer (e.g. via a debug console)
renders correctly. Cycles + depth-4 attempts render visible
"blocked" placeholders.
2026-05-31 16:47:52 -04:00
thePR0M3TH3AN 197282f562 Linked Designs P1a: resolver IIFE + schema discriminator
Adds the linkedDesigns IIFE alongside the existing purchase-vault
helpers. Resolves a layer's { ref, pinnedEventId } to its current
source payload via relay query, with all the safety rails the spec
calls out.

What's in this commit:

- _parseAddressCoord: parses NIP-01 "kind:pubkey:dTag" addresses
  with hex-pubkey + numeric-kind validation. Returns null on
  malformed input so a tampered host event can't crash the resolver.
- Session cache (Map keyed by address-or-eventId) with 5-minute
  TTL per spec §7. Inflight-Promise dedupe so parallel resolves
  for the same key share one network round-trip.
- _fetchPayload: queries the wider purchase-vault relay union
  (user relays + DEFAULT_NOSTR_RELAYS) so linked sources are
  reachable cross-domain. Pinned mode uses ids: [eventId] filter,
  latest mode uses kinds + authors + '#d'. Picks the most-recent
  event by created_at as a defensive measure for relays that
  somehow returned more than one addressable match.
- Per-event content cap (256 KB / LINKED_DESIGN_MAX_EVENT_BYTES) —
  oversized source events are rejected before parse.
- walkLinkedTree: recursive walker for the layer graph with
  depth-3 cap, cycle-safe via visited Set keyed by cache key,
  per-tree byte cap (1 MB / LINKED_DESIGN_MAX_TREE_BYTES) that
  short-circuits the walk when exceeded. Decks: walks every
  slide's layers too.
- walkContributors: convenience wrapper that returns the distinct
  set of source pubkeys reachable through the linked-tree walk.
  Backs the attribution `p`-tag emission in Phase 3, exposed now
  so a Credits panel can also use it.
- clearCache: manual eviction, used by the per-layer Refresh
  button + the upcoming pre-publish strip.

Internals (_parseAddressCoord, _cacheKey) are exposed so the
pure-Node unit tests in Phase 1d can hit them without a browser
environment.

No render-side wiring in this commit — that's Phase 1b. With this
commit only, calling `linkedDesigns.resolveLinked({ref: '30078:...'})
from the browser console resolves the source's payload, which is
the minimum building block for the render-time path.
2026-05-31 16:45:40 -04:00
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