Commit Graph
178 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 4.7 ca6a150ff1 Deck PDF: override the global single-sheet print lock on html/body
Verified via headless Chrome + CDP: the live app rendered only 1 PDF
page with the slide canvas at ~56% page width and the right side
white. Minimal CSS repros produced 3 pages correctly, so the bug
was in interaction with the rest of the app's stylesheet.

Root cause — base print stylesheet (line 2455):

  @media print {
    html, body {
      width: var(--sheet-w) !important;
      height: var(--sheet-h) !important;
      overflow: hidden !important;
    }
  }

Designed for single-sheet prints (cover / disc / jewel — one
physical sheet per design). For deck PDF that's wrong:

- width / height pin body to the cover-template sheet dims (the
  --sheet-w / --sheet-h variables don't get reset for deck mode),
  so 20in × 11.25in deck pages get clipped to ~11in × 8.5in. The
  canvas previewing 1920×1080 inside a body locked to a smaller
  width ends up as a square (height-constrained), filling only
  ~56% of the actual PDF page width.
- overflow: hidden kills the multi-page break-after flow — the
  second and third .deckPrintPage divs overflow body's height
  and just get clipped instead of paginated.

Fix: deck PDF stylesheet now also overrides html/body to
width: auto; height: auto; overflow: visible so the body
expands to hold every .deckPrintPage and break-after works.

Verified end-to-end with a CDP-driven export of a 3-slide deck:
- pdfinfo: 3 pages, 1440x810 pts (20x11.25in)
- pdftoppm + visual inspection: each page is 1920x1080 fill,
  correct background colour, slide text centred at expected
  position. Yellow slide ONE / blue slide TWO / green slide THREE
  all render.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 15:02:12 -04:00
thePR0M3TH3ANandClaude Opus 4.7 f6d0394735 Fix renderCustomArtPreviewDOM: thumbnails were collapsing to 1px wide
The `opts.renderWidth` resolver from the previous commit had a subtle
JS coercion bug:

  const renderWidth = Math.max(1, opts.renderWidth | 0) || PREVIEW_BASE_PX;

When `opts.renderWidth` is undefined (every caller except the deck
PDF exporter):

  undefined | 0      = 0
  Math.max(1, 0)     = 1
  1 || PREVIEW_BASE  = 1   ← the bare-`|| fallback` never fires
                              because `Math.max(1, ...)` already
                              produced a truthy `1`.

Result: every preview without an explicit renderWidth (deck-builder
thumbnails, community-browser previews, publish-confirm modal,
profile-page previews) rendered at 1px wide. The editor canvas
view was unaffected because it renders layers directly via
`render()` — not through this helper.

Replace with an explicit "is the input positive?" check so undefined,
0, and negative inputs all fall through to PREVIEW_BASE_PX, and
positive ints (including stringy "1280") are honoured. Verified
against five test cases in a node harness.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 14:47:30 -04:00
thePR0M3TH3ANandClaude Opus 4.7 d1388f76fd Deck PDF: render previews at slide pixel size — drop the print transform
Two problems with the transform-based approach: the second slide
got dropped (the transform's clipped rendering area collided with
the page-break flow), and the right side of the first slide came
out white (the layout box of the scaled inner was only 720px wide
in CSS, so the print engine clipped beyond that).

Fix: teach `renderCustomArtPreviewDOM` to accept `opts.renderWidth`,
replacing the hard-coded PREVIEW_BASE_PX in both the canvas-div
sizing and the `applyPreviewLayerTextStyle` pxPerInch calculation.
The deck PDF exporter now calls it with `renderWidth: wPx` (the
slide's natural pixel width — 1920 for a default 1920×1080 slide)
so the preview lays out edge-to-edge at the page's pixel size. No
CSS transform, no clipping, text scales coherently with layer %.

Print CSS now:
- Forces the preview's outer div to `width: 100% !important;
  height: 100% !important; max-width: none !important`, so the
  preview's own `width: min(100%, renderWidth)` cap doesn't fight
  the page size.
- Uses both `break-after: page` (modern) and `page-break-after:
  always` (legacy) for cross-browser page breaks.
- Drops the `.deckPrintInner` wrapper entirely — preview goes
  directly into `.deckPrintPage`.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 14:41:55 -04:00
thePR0M3TH3ANandClaude Opus 4.7 5ac9f3b6ce Deck PDF export: explicit width / height on the scale-up wrapper
The new exporter rendered blank pages because .deckPrintInner was
position: absolute with no width / height set. The preview's own root
uses `width: min(100%, 720px)`, which resolves against its containing
block — `.deckPrintInner`. With no resolved width on the container,
`100%` came back as 0 and the whole preview tree collapsed to zero
size before the transform was applied.

Set width: PREVIEW_BASE_PX (= 720px) and a height matched to the
slide's aspect, so the preview lays out at its natural rendering size
first, then transform: scale enlarges it to fill the page.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 14:36:55 -04:00
thePR0M3TH3ANandClaude Opus 4.7 f3c697fcb1 Deck PDF export: page = slide canvas size + wait for images to load
Two bugs in the first deck-PDF cut:

1. PDF page didn't match the slide canvas. The page was sized to the
   PREVIEW_BASE_PX-wide preview (7.5in × 4.22in for a 16:9 slide),
   which gave a tiny PDF page that the browser sometimes scaled
   awkwardly. Now the page is sized to the slide's natural design
   dimensions (wPx / 96 × hPx / 96 inches — e.g. 20 × 11.25in for
   a default 1920×1080 slide), so the PDF is 1:1 with the canvas.
   A CSS `transform: scale(wPx / PREVIEW_BASE_PX)` enlarges the
   PREVIEW_BASE_PX-wide preview to fill the larger page; transform
   scales text proportionally, so font sizes stay correct.

2. Some images weren't rendering. The export called window.print()
   one RAF after attaching the print container — slower remote
   image URLs hadn't finished loading yet, so they missed the
   print snapshot. Now we await every <img>'s load/error event
   (with a 5s per-image cap so one hung URL can't block forever)
   before calling print.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 14:31:50 -04:00
thePR0M3TH3ANandClaude Opus 4.7 84ad374f2d Slide deck: surface "Export deck as PDF" in the Print & Save panel
The single-page Print / Save PDF row is CSS-hidden in deck mode
(it doesn't make sense for the sorter view), so my previous
context-aware printBtn handler had no entry point users could
actually click.

Add a dedicated #deckExportRow with an "Export deck as PDF" button
directly in the Print & Save fieldset. CSS shows it only in
template-deck mode, and only when no single slide is open in the
editor (otherwise users get the regular single-page print path
back). Click wires straight to exportDeckAsPdf — same multi-page,
themed, edge-to-edge output the previous commit set up.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 14:27:22 -04:00
thePR0M3TH3ANandClaude Opus 4.7 7439bc12b2 Slide deck PDF export: one page per slide
The Print / Save PDF button is now context-aware. In the Deck Builder
(deck mode, no slide open in the editor) it builds a hidden print-only
DOM container with one page per slide, each rendered via the same
renderCustomArtPreviewDOM the sorter thumbnails use:

- Themed at render time via composeSlideForDeck so the deck's font /
  background applies (and per-slide ignoreDeckTheme is honoured).
- Each page sized to the preview's native dimensions
  (PREVIEW_BASE_PX / 96 inches wide, aspect-matched from the first
  slide) so the @page directive is the exact slide size and the
  slide content fills the page edge-to-edge without scaling.
- `page-break-after: always` between slides; no trailing blank.
- Print-only CSS hides every other top-level element on body so
  the editor chrome doesn't bleed through.
- document.title is set to "<save-stem>_deck" so the browser
  defaults the saved PDF's filename helpfully.
- A `window.afterprint` listener tears the print container, the
  injected stylesheet, and the title override back down — so
  cancelling the dialog leaves no residue.

Inside a single-slide editor (deck mode, a slide is open), the
Print button still calls printWithCurrentFilename for the existing
single-page slide print.

TODO.md: move Slide deck PDF export and Copy / paste transform
consistency into "Shipped (recent)". Adam's working list is empty.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 14:18:11 -04:00
thePR0M3TH3ANandClaude Opus 4.7 3b9415e4c4 TODO: log pixelsB64 size-fallback as future pixel-art polish
Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 14:13:29 -04:00
thePR0M3TH3ANandClaude Opus 4.7 68bc0df145 Pixel editor: default Colors to current palette + bulk image → frames
Two improvements to "From image…":

1. Colors input now defaults to the editor's current palette size
   (clamped 2–64), just like W / H already follow the current grid
   dims. A per-frame import lands as a drop-in replacement rather
   than always re-quantizing to a stock 16-colour palette and
   bloating the palette via the merge step.

2. The file picker now accepts multiple images. Selecting N files
   loads them all, opens the same pixelize modal, and processes
   the batch:

   - Target is forced to "New frame" (the only sensible meaning
     for "import 5 photos into a single sprite"). Current frame
     and Whole sprite buttons are disabled in batch mode.
   - The source-preview label shows "Source · 1 / N" and the
     Apply button reads "Add N frames".
   - Each image is pixelized with the same W / H / Colors / Fit /
     target options. Palettes merge sequentially into one working
     palette, so frames share colours where possible and the
     palette grows organically (cap 256, then nearest-RGB fallback).
   - All N frames are appended together in pick-order after the
     current frame in one undo entry; the editor lands on the
     last inserted frame.

Drag in a sequence of references and the animation timeline fills
up in one step.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 14:04:06 -04:00
thePR0M3TH3ANandClaude Opus 4.7 163daa61bd Pixel editor: per-frame image import (Phase D bugfix)
Before: "From image…" → Apply replaced the entire pixel art —
all frames, the palette, and the grid dims. Importing onto frame
2 of an animation wiped frame 1.

Now the modal exposes an "Apply to" picker with three targets,
defaulting to per-frame import:

- Current frame (default) — replace only the active frame's
  indices. Keeps every other frame, the grid dims, and the
  animation timing intact.
- New frame — insert the pixelized image as a new frame after
  the current one and select it.
- Whole sprite — the previous behaviour: rebuild dims + palette
  + frames from scratch.

The W / H inputs lock to the editor's current grid dims for the
two per-frame targets (frames in one design must share dims;
changing the grid is what the resize button is for). They unlock
for Whole-sprite. The Apply button label changes to match the
chosen target so the action is obvious before clicking.

Palette merging for per-frame targets:

- _pixelMergePaletteAndRemap walks the freshly-quantized palette,
  matches each entry against the existing palette by hex, and
  appends only the colours that are genuinely new. The pixelized
  frame's indices are remapped to point at the merged-palette
  positions, so every other frame stays correct.
- Index 0 (transparent) short-circuits to the existing transparent
  slot.
- Caps at 256 entries (one byte per index). If full, new colours
  fall back to the nearest-RGB existing entry via
  _pixelNearestPaletteIdx.

pixelSnapshot is called before mutating, so one Ctrl+Z restores
the previous state regardless of which target was used.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 13:57:38 -04:00
thePR0M3TH3ANandClaude Opus 4.7 c82267c6a1 Fix copy/paste transform consistency — group-uniform scale + translate
Before: pasteLayersFromClipboard clamped each item's w / h individually
to the target bounds, then re-centred any item whose box landed
out-of-bounds. With multiple copied items, this:

- Collapsed every out-of-bounds item onto the same centred position
  (lost their relative positions).
- Gave each item a different effective scale (lost their relative
  sizes).

So a "Copy All" → cross-mode paste turned a tidy 4-layer composition
into a pile of overlapping clamped boxes.

Now: compute the bounding box once across every clipboard item, pick
a single scale (only shrinks — never enlarges — so a paste into a
larger target keeps items at their original size), and apply one
shared translation that centres the (possibly scaled) bbox in the
target. Both axes use the same scale, preserving the group's aspect
ratio. Items missing w / h fall back to 70%-of-target uniformly, so
the default-size path can't desync either.

Result:
- Two items copied with their original gap and a 2:1 size ratio
  retain that gap and ratio after paste, regardless of target size.
- A wide cover → narrow disc paste shrinks everything proportionally
  rather than collapsing edges onto the centre.
- Same-mode paste with everything already in-bounds is unchanged
  (scale stays at 1; translation is identity).

TODO.md: move the resolved colour-picker work to "Shipped (recent)".

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 13:46:33 -04:00
thePR0M3TH3ANandClaude Opus 4.7 d57e6026da Pixel editor: selecting a palette swatch syncs the picker too
When the user clicks a palette swatch (or samples a cell with the
eyedropper tool), push that colour into the HSV picker / hex input
/ OS swatch so it becomes the "starting point" for the picker.

This makes the natural workflow possible: grab a palette colour →
drag the SV square to darken / lighten / desaturate it, or shift
the hue → click "+ Add" to push the tweaked variant back into the
palette. Previously the picker stayed wherever it was last, so
modifying a palette colour meant retyping the hex.

A new pixelSelectPaletteIndex(i) helper centralises the logic:

- Validates the index and sets _pixelEdit.current.
- For non-transparent slots, calls pixelPickerSyncFromHex with the
  6-digit hex slice (the picker is alpha-agnostic) and re-renders
  the SV square and hue strip so the marker jumps to the new
  position. pixelPickerCommit() then mirrors the colour out to the
  hex input and OS swatch.
- Transparent slots (index 0 by palette convention) are still
  selectable for painting but skipped for picker sync.

Both the palette swatch click handler and the in-canvas eyedropper
tool now route through pixelSelectPaletteIndex — the eyedropper
previously only updated _pixelEdit.current, so it had the same
gap.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 13:41:39 -04:00
thePR0M3TH3ANandClaude Opus 4.7 1d77ddabb7 Remove the Screen Capture-based pixel-editor screen eyedropper
Now that the native OS colour picker (<input type="color">) works
in the pixel editor — the same swatch the vector tools use, ported
over in the previous commit — the Screen-Capture-API workaround is
redundant. The OS picker's built-in eyedropper covers the same use
case (pick any colour from anywhere on screen) without the extra
permission dialog and the in-app screenshot overlay.

Removed:

- HTML for the 🎯 Pick from screen / Stop buttons and their row.
- CSS for .screenPickerOverlay / .screenPickerStage /
  .screenPickerCanvas / .screenPickerHint.
- JS: pickColorFromScreenCapture, openScreenPickerOverlay,
  _getOrCreateScreenShareStream, stopScreenShareStream,
  _pixelPickStreamLive, _updatePixelPickStopBtn, and the
  _screenShareStream / _screenShareTrack module-level state.
- The stopScreenShareStream() call in pixelEditTearDown.
- The screen-eyedropper attachEvents block.

Also updated the now-stale comments above the HSV picker and the
picker-sync wiring (they previously described the in-app HSV picker
as the only reliable input path because the OS picker was bugged
in Brave; now both are reliable and they stay in sync).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 13:39:35 -04:00
thePR0M3TH3ANandClaude Opus 4.7 36c9d59f66 Pixel editor: port the OS native colour picker over from the vector tools
The vector tool panels (Pen / Shape / Text fill+stroke) already use
plain <input type="color"> and that works reliably — including its
built-in eyedropper, which is the part Adam actually wanted in the
pixel editor.

Replace the static <span> "current pick" preview in the pixel-editor
palette panel with an <input type="color"> styled to look like the
existing 32×32 swatch:

- Same flex / size / border / radius rules via .pixelPickerPreview.
- Padding: 0 and ::-webkit-color-swatch-wrapper / ::-webkit-color-
  swatch + ::-moz-color-swatch rules strip the browser chrome so
  the element renders as a flat colour tile.
- Click opens the native OS colour picker with its eyedropper —
  same behaviour as the vector tool panels.

The wiring that syncs the OS picker ↔ hex text ↔ in-app HSV picker
was already in place but had no DOM element to bind to; it now lights
up. pixelPickerCommit drops its dead getElementById('pixelPickerPreview')
lookup — the OS picker shows its colour natively from .value, set
on the line above.

The in-app HSV picker (SV square + hue strip) stays as the primary
input path, so anyone whose OS picker is broken (the Brave R=NaN
bug we hit earlier) still has a working route.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 13:35:41 -04:00
thePR0M3TH3ANandClaude Opus 4.7 27074b9fb6 QR layer: add Insert npub + Insert share link buttons (Phase B)
Two convenience buttons under the QR data textarea:

- Insert npub — async; pulls the signed-in pubkey via the Nostr
  extension (window.nostr.getPublicKey), encodes it to bech32
  npub, writes it into the QR data field. Toasts an error if no
  extension is present.
- Insert share link — async; only valid once the design has been
  published (checks state.editingDTag). Builds the same naddr /
  nevent shape that shareIdForRow produces for the community-
  browser Copy-share-link button:
    * naddr for replaceable events (kind 30000-39999) with a
      mode-prefixed d-tag (cover: / disc: / jewel: / customart: /
      disc-design: / slide: / deck: / pixelart:)
    * nevent fallback otherwise, using state.editingEventId
  Then wraps it as `${location.origin}/#/share/<id>` and writes
  it into the data field.

Both writes go through updateQrField('data', value) so undo /
history / the live preview pick them up the same way typing in
the textarea does.

TODO.md: mark QR Phase B as shipped.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 11:38:34 -04:00
thePR0M3TH3ANandClaude Opus 4.7 6a1352904e Docs: ship pixel-art Phases A–D, prune stale TODOs, update README
PIXEL_ART_FEATURE.md: rewrite Implementation status to reflect that
Phases A–D all shipped (embedded layer, standalone design, raster
export + editor extras, animation, image → pixelize), with the
backlog (WebP/APNG, Blossom offload, animated pixel layers driving
Stack actions) called out as deferred.

TODO.md: drop stale items now that they're resolved —

- Pixel Art (moved to a "Shipped (recent)" section pointing at the
  spec doc).
- Pixel editor layout expansion (subsumed by the collapsible
  sidebar — both the pixel editor and vector canvas can now use
  the full viewport width).
- Print / Save event ID wrapping (the `<dd>` already uses
  `overflow-wrap: anywhere; word-break: break-word`).
- TMDB auto-fill cleanup (the doc was already deleted).

The Color picker fixes item is kept but narrowed to the vector
tools — the pixel editor's in-app HSV picker shipped in Phase A.

README.md: add Pixel Art everywhere it's relevant —

- One-liner mention in the intro paragraph.
- New "Pixel art" Designer-tab layout entry alongside Slide / Disc
  design.
- `pixelart` layer type added to the Layers list.
- New dedicated "Pixel art" section before Editor canvas, covering
  tools / transforms / animation / export / import / undo /
  animated previews.
- Editor canvas section mentions the new sidebar collapse toggle.
- Schema section lists `pixelart` template mode and layer type,
  documents the `pixelArt` data-model shape, and notes that the
  inline GIF89a encoder lives in `index.html` (not a vendored dep).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 10:54:10 -04:00
thePR0M3TH3ANandClaude Opus 4.7 e7e3039748 Add collapsible sidebar with a chunky early-web toggle tab
A vertical handle tab on the seam between the sidebar and the canvas
toggles the side panel open / closed. State persists to localStorage
so the panel stays where the user left it across reloads.

Style choices to match the existing chunky early-web aesthetic:

- Hard right edge with a small 4px corner, no left border (visually
  fuses with the sidebar edge).
- Manual two-tone bevel via inset box-shadow — light top, dark
  bottom — for the old-school extruded-tab look, without using
  border-style: ridge/outset (which modern browsers render
  inconsistently).
- Monospace bold guillemet glyph: « when open, » when collapsed.
- Hover paints accent-blue; active state nudges 1px right (chunky
  click feedback).
- Dark-mode-aware bevel colours.

Positioning:

- Fixed at top: 24px so it aligns with the logo row instead of
  floating mid-screen on top of editor toolbars (the original
  mid-screen position obscured the pixel and vector editor
  toolbars).
- Height 44px — compact enough not to dominate the header.
- Slides smoothly with the sidebar via `left` transition matched
  to the grid-template-columns animation (220ms ease).
- When collapsed, the pixelEditorBar / canvasToolbar / toolPalette
  get a 26px padding-left so their leftmost buttons don't sit
  under the tab.

Trade-off worth noting: the responsive `minmax(340px, 420px)`
sidebar width has been replaced with a fixed `380px` because
grid-template-columns doesn't smoothly interpolate between
minmax() and a plain length. 380px is in the middle of the
original range and works on every viewport size we care about.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 10:48:44 -04:00
thePR0M3TH3ANandClaude Opus 4.7 a13ef22b92 Add image → pixelize (Phase D) and move it into the pixel editor
A new "From image…" button in the pixel editor toolbar opens a file
picker and then a pixelize modal. The modal previews source and
pixelized output side by side, with live updates as the user changes:

- W / H (4–128, matching the editor's resize cap; default = the
  editor's current dims so Apply lands as a drop-in replacement)
- Colors (2–64; index 0 reserved as transparent)
- Fit mode — Cover / Fit / Stretch

Algorithm:

- _pixelDownsampleImage draws the image onto an off-screen canvas
  with imageSmoothingEnabled=false; fit-mode math computes source
  and dest rects so Cover crops edges, Fit letterboxes with
  transparent, Stretch ignores aspect.
- _pixelMedianCut quantizes the downscaled pixels: collect opaque
  samples, recursively split the bucket with the widest single-
  channel range until N buckets, average each bucket → palette
  entry. Pixels with alpha < 128 become transparent (index 0).
- _pixelNearestPaletteIdx maps each opaque pixel to the nearest
  palette entry by squared RGB distance.

Apply replaces the editor's current pixel art — frames, palette,
and dims all get swapped to match the pixelized output. One
pixelSnapshot before mutating, so Ctrl+Z restores everything.
Standalone mode commits immediately.

Drive-bys in this commit:

- Move the From image entry out of the vector-canvas ▦ popup and
  into the pixel editor toolbar (per request — the editor is where
  the workflow lives).
- Fix the modal show bug: .modal CSS rule sets display:none, so the
  modal must be opened with style.display = 'flex' (matching the
  publish-confirm modal). The previous '' didn't override the rule.
- Drop the long hint paragraph under the colour picker — the SV
  square + hue strip + Add button are self-explanatory and the
  eyedropper has its own tooltip.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 10:33:44 -04:00
thePR0M3TH3ANandClaude Opus 4.7 130e0218d8 Add GIF export + animate multi-frame previews (Phase C5)
Inline GIF89a encoder — no vendored dependency. Pixel art is
already palette-indexed, which is exactly what GIF needs, so the
encoder hands frame index buffers straight to LZW and writes the
GIF structure directly.

Encoder:

- encodePixelArtToGif assembles a full GIF89a: header, global
  colour table sized to the smallest 2^(N+1) that fits the
  palette, NETSCAPE2.0 application extension for loop count
  (0 = infinite, 1 = play once), per-frame Graphic Control
  Extension with disposal=2 (restore to background) and
  transparent-colour-index=0 (matches our palette convention),
  Image Descriptor, LZW-encoded image data in ≤255-byte sub-
  blocks, trailer.
- _gifLzwEncode does standard variable-bit-width GIF LZW: codes
  start at minCodeSize+1 bits, grow to 12, dictionary resets via
  clear codes when full.
- exportPixelArtToGifBlob decodes RLE frames, nearest-neighbour
  upscales to the chosen scale (so cells stay crisp in the GIF),
  encodes, returns a Blob.
- Smoke-tested at 4×4, 8×8, and 32×32: `file` recognizes all
  outputs as valid GIF89a.

Export UI:

- Format picker at the top of the export menu: PNG / GIF. PNG-
  only controls (Transparent bg, Grid lines, Sprite sheet) hide
  when GIF is active. GIF uses the design's stored fps + loop.
- Refuses GIF export with a toast when the design has <2 frames.
- Filename includes the frame count, e.g. mysprite_32x32_6f_4x.gif.

Animated previews (publish modal + community browser + feed cards
+ profile page) — all four entry points share one preview helper,
so this lights up everywhere:

- renderPixelArtPreviewDOM detects multi-frame designs and animates
  via a requestAnimationFrame loop with an accumulator (correct
  pacing across dropped frames).
- Per-frame canvases are pre-rendered once and cached; the tick
  just blits the right cached canvas — no RLE decode per tick.
- The RAF loop self-stops on `cv.isConnected === false`, so closing
  a modal, swapping the preview panel, or scrolling a feed card
  out of the DOM ends the loop. No leaks.
- fps and loop come from the payload; non-looping animations run
  once and freeze on the last frame.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 10:08:40 -04:00
thePR0M3TH3ANandClaude Opus 4.7 20a389f0ee Add sprite-sheet PNG export (Phase C4)
Extends the existing Export PNG menu with a "Sprite sheet (all
frames)" checkbox that tiles every frame into one PNG. A layout
picker (Grid / Row) appears when sprite-sheet is on — Grid is the
default and uses a square-ish layout (ceil(sqrt(N)) columns); Row
is a single horizontal strip. Scale, transparent background, and
grid-lines options all apply to the sheet as a whole.

Filename includes the frame count and a _sheet tag — e.g.
mysprite_32x32_4f_4x_sheet.png — so game-engine tools can parse
the dims from the name.

renderPixelGridToCanvas(pixelArt, frameIndex) now takes an optional
frame index (defaults to 0, so every existing single-frame caller
keeps working unchanged). pixelExportCurrentPixelArt now packages
all frames + fps + loop, so the exporter sees the full animation.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 09:19:14 -04:00
thePR0M3TH3ANandClaude Opus 4.7 aebd86c11c Add onion-skin to the pixel editor (Phase C3)
A new "Onion" checkbox below Loop in the frame strip. When on, the
canvas draws the previous frame as a flat red tint at 30% opacity
and the next frame as a flat blue tint at 30% opacity, both behind
the editing frame in its real colours. Aseprite convention — flat
tints make the neighbouring frames immediately distinguishable from
the frame you're painting.

- Only renders while editing (skipped when overrideIndices is set,
  i.e. during playback — the playing frame stays unobscured).
- Only renders when there are neighbouring frames to show.
- Onion is editor-only state (like the grid toggle); not persisted
  to the design payload.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 09:12:09 -04:00
thePR0M3TH3ANandClaude Opus 4.7 b8e1a11605 Add playback + move the frame strip to a vertical left sidebar (Phase C2)
Playback:

- ▶ / ■ Play button toggles a requestAnimationFrame-driven playback
  loop that uses an accumulator, so framing stays correct across
  tab-switches and dropped frames.
- FPS input (1–60) and Loop checkbox, both saved with the design.
- Space key toggles play / stop while the pixel editor has focus.
- Clicking the canvas during playback stops it and resumes editing.
- Arrow keys, tool shortcuts, and any frame-op stop playback first
  so the canvas and `_pixelEdit.currentFrame` stay in sync. Frame
  switches via pixelSetCurrentFrame auto-stop playback for the same
  reason.
- Refuses to start with fewer than 2 frames.
- Playback frame is rendered via a new optional `overrideIndices`
  argument to renderPixelGrid — `_pixelEdit.indices` (and thus
  `currentFrame`) keep pointing at the editing frame the whole time.

Layout:

- The frame strip is now a vertical sidebar to the left of the
  canvas instead of a horizontal row underneath. Frame thumbnails
  scroll independently (overflow-y: auto on the thumb list), so
  the main art area never gets pushed when there are many frames.
- Strip layout, top to bottom: Play, FPS, Loop, the 3 frame-op
  buttons (+, ⎘, −) in a row, then the scrolling thumb column.
- Thumbs use `height: auto` so their aspect matches the grid:
  square grids → square thumbs, non-square grids → correctly
  proportioned thumbs (cells stay square in the preview).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 09:09:35 -04:00
thePR0M3TH3ANandClaude Opus 4.7 6dad832a8d Add multi-frame timeline UI to the pixel editor (Phase C1)
_pixelEdit now tracks frames[] with currentFrame index and the
animation's fps/loop fields; _pixelEdit.indices is a live reference
to the active frame's Uint8Array so every existing paint / fill /
shape / mirror code path keeps mutating the right frame with no
further changes. decodePixelArtFrames / encodePixelArtFrames round-
trip the whole frame set between JSON RLE and in-memory typed arrays.

A timeline strip below the canvas holds:

- + / ⎘ / − buttons to add a blank frame, duplicate the current
  frame, and delete the current frame (delete disables when only
  one frame remains).
- A scrollable row of frame thumbnails — each thumb is the frame's
  indices rendered at native size and scaled up with image-rendering:
  pixelated. Active frame has an accent-coloured border.
- Click-to-switch and HTML5 drag-reorder.
- Thumbnails refresh on stroke end, transform, resize, and frame ops.

Left / Right arrow keys cycle to the previous / next frame (wraps).

Transforms (flip / rotate / resize) now iterate over every frame so
dims stay coherent across the animation. Undo snapshots capture the
full frame set plus currentFrame; restoring an edit also restores
the editing frame so the user lands back where they edited.

Commit paths (closePixelEditor, pixelCommitIfStandalone,
pixelEditTearDown) all encode all frames + fps + loop. Old single-
frame saves decode correctly because decodePixelArtFrames falls back
to a one-element array.

sanitizePixelArtBlob caps at 32 frames as a defensive ceiling and
preserves fps / loop.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 08:44:40 -04:00
thePR0M3TH3ANandClaude Opus 4.7 2a8d0e3707 Import a published pixel-art design as a layer (Phase B)
The pixel-art tool button (▦) in the tool palette now opens a small
popup with three options, mirroring the disc-slot and deck-slide
patterns:

- Add blank pixel-art layer — original behavior (32×32 default).
- Browse community… — opens the existing Nostr browser filtered
  to nostrType='pixelart'. Selecting a design imports it as a new
  pixelart layer in the current design (rather than loading it as
  the current document).
- Import from file… — file picker for .json designs.

Plumbing:

- sanitizePixelArtBlob clamps dimensions to 4–128 (matches resize
  cap), trims palettes to 64 entries, defaults missing palettes to
  DEFAULT_PIXEL_PALETTE, decodes/re-encodes RLE so corrupt runs
  either coerce to something valid or get rejected.
- importPixelArtAsLayer accepts either a full design payload (with
  .pixelArt) or a raw pixelArt blob; rejected payloads toast an
  error rather than creating a broken layer.
- tryPixelArtImportRow intercepts community-browser selections
  while state._pixelArtImportMode is true — added alongside the
  existing tryDeckImportRow at the four selection sites.
- nostrClose handler clears state._pixelArtImportMode so closing
  the modal without picking doesn't leave the flag stuck on.
- Menu closes on outside click, Escape, and after picking — same
  pattern as the export-PNG popover.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-26 08:26:34 -04:00
thePR0M3TH3ANandClaude Opus 4.7 f20a90929d Add grid resize to the pixel editor (Phase B)
The dims badge in the pixel editor toolbar is now a button — click
it to open a popover with W / H number inputs, a row of preset
buttons (16 / 32 / 48 / 64 / 96 / 128), and an Apply button.

Resize behavior:

- Bounds clamped 4–128 per side. The cap is set to keep the
  serialized event comfortably under conservative relay limits;
  raise later if real-world art shows it's safe.
- Top-left anchor: existing pixels at (x, y) stay where they are.
- New cells = palette index 0 = transparent (Uint8Array zero-
  initializes; index 0 is the conventional transparent slot in
  DEFAULT_PIXEL_PALETTE).
- Oversize is cropped.
- pixelSnapshot is called before the mutation, so Ctrl+Z reverts
  the resize correctly — snapshots already capture {indices, w, h}.
- pixelCommitIfStandalone is called after, so standalone mode
  pushes a debounced global history entry.

The popover closes on Apply, outside click, Escape, the Done
button, or any tool / transform button click — same pattern as
the Export PNG menu.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 15:46:37 -04:00
thePR0M3TH3ANandClaude Opus 4.7 335afdab4b Integrate pixel-editor undo/redo with the top-level toolbar
Pixel editor undo/redo were a local stack with no redo and no
connection to the global design-history stack. Several gaps:

- No redo in the pixel editor.
- The pixel snapshot only captured `indices`, not `w`/`h` — so
  undoing a rotate left the editor with swapped dims and stale data.
- Standalone pixelart mode never pushed global history, so leaving
  the editor (or switching templates) lost the ability to undo
  pixel work from the global stack.
- The pixel toolbar had its own ↶ Undo button duplicating the
  top-level toolbar's ↶.

Changes:

- `pixelSnapshot` now captures `{ indices, w, h }` and clears the
  redo stack on every new mutation. `_pixelRestoreState` updates
  the dims badge alongside indices, so rotate-then-undo is correct.
- Added `pixelRedo` mirroring `pixelUndo`. `_pixelEdit.redo` lives
  on both editor inits.
- Top-level `#undoBtn` / `#redoBtn` are now context-aware: while
  the pixel editor is open they route to `pixelUndo` / `pixelRedo`;
  otherwise they route to the global `undo` / `redo`. Tooltips and
  disabled state flip accordingly via `updateUndoRedoUI`.
- Pixel-editor keydown handles Ctrl+Shift+Z and Ctrl+Y for redo.
- `pixelCommitIfStandalone` pushes one debounced global history
  entry per stroke / shape / transform (600ms window), guarded by
  `_pixelHistorySuppress` so pixel undo/redo and serialize-for-
  publish don't pollute the global stack.
- Removed the redundant local ↶ button from the pixel toolbar
  (the top-level button now drives it).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 15:37:19 -04:00
thePR0M3TH3ANandClaude Opus 4.7 f9f800985d Add line / rectangle / ellipse drawing tools (Phase B)
Three new shape tools in the pixel editor toolbar:

- ╱ Line (L): Bresenham line from drag start to current cell.
- □ Rectangle (R): outline by default; filled when the new "Fill" toggle
  is on.
- ○ Ellipse (O): inscribed in the dragged bounding box; outline /
  filled per the Fill toggle. Outline cells are those inside the ellipse
  with at least one outside 4-neighbour.

A new "Fill" checkbox lives next to Mirror Y in the toolbar; applies to
rect / ellipse only. Mirror X / Y still apply to all shape tools.

Live-preview during the drag works by taking a one-shot snapshot of the
indices on pointerdown, then on every pointermove restoring from the
snapshot and re-applying the shape — the in-progress preview never
accumulates. One in-editor undo entry per shape (snapshot taken on
pointerdown), so Ctrl+Z removes the whole shape, not individual cells.
Standalone mode commits the final shape on stroke end so autosave /
publish always see it.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 15:21:05 -04:00
thePR0M3TH3ANandClaude Opus 4.7 06af73aeff Add PNG export to the pixel editor (Phase B)
A new "Export PNG ▾" dropdown in the pixel editor toolbar, available in
both the embedded-layer and standalone editing modes:

- Scale: 1× / 2× / 4× / 8× / 16× / 32× (3-column grid).
- Transparent background on/off (index-0 transparent cells stay
  transparent in the PNG when on; a white fill is laid down first when
  off).
- Optional 1-px grid line overlay at ≥ 2× scale.
- Filename is "<title>_<W>x<H>_<scale>x.png"; toast confirms output
  dimensions.

Self-contained <canvas> → toBlob('image/png') pipeline. Reads the
editor's current working grid for both layer-edit and standalone modes,
so you can export while you're still editing. Settings persist for the
session.

Menu dismissal is strict: opens only on the Export button, closes on
outside click, outside pointerdown (catches drag-starts), Esc, picking
a pixel tool, hitting Done, and after Download. Fixed a default-open
bug where the .pixelExportMenu CSS display:flex was beating the [hidden]
attribute via specificity tie + later-rule-wins — switched to a class-
toggled open state.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 15:14:55 -04:00
thePR0M3TH3ANandClaude Opus 4.7 07fcde0b97 Fix pixel editor colour picker + close layout gaps
The OS <input type="color"> popup eyedropper and the standalone EyeDropper
API are both broken on the user's Brave build — they return R=NaN /
always-white sample regardless of where you point. Replace them with
controls that don't touch any OS picker:

- New in-app HSV picker: Saturation/Brightness square + Hue strip drawn
  to canvases; pure sRGB math, no OS calls. The cursor position IS the
  picked colour. Render is wrapped in try/catch so SES lockdown (e.g.
  MetaMask) freezing canvas helpers can't break the commit path.
- New 32x32 preview swatch next to the hex input, updated by the
  picker — gives unambiguous visual feedback of the pending colour.
- A typeable #rrggbb hex field with live validation (red border on
  invalid), syncs both ways with the picker.
- A "Pick from screen" button backed by the Screen Capture API: take a
  one-frame screenshot of a chosen surface, show it in our own overlay,
  click any pixel to sample. The capture stream is kept alive for the
  duration of the pixel-edit session so subsequent picks don't re-prompt
  for permission (with a Stop button to end the share early). Auto-
  released when the editor closes.
- Drop the OS <input type="color"> swatch and "More options" disclosure
  entirely; they only confused users on browsers where they don't work.

Also close the editor layout gaps: in pixel-editing / template-pixelart
modes, .previewShell padding goes to 0, #pixelEditorView is height:100%
min-height:0 so it reaches the screen edges, the inner canvas wrap
padding tightens to 12px, and the fit-zoom reserved margin tightens to
28px so the canvas auto-fits at a higher integer zoom in the freed
space.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 09:41:04 -04:00
thePR0M3TH3AN 8483718abc Keep canvas size pills on one row 2026-05-25 08:05:45 -04:00
thePR0M3TH3AN af3bc03c71 Wrap publish event IDs in sidebar 2026-05-24 15:42:12 -04:00
thePR0M3TH3AN 866d59e7f3 Improve layer panel group controls 2026-05-24 15:38:12 -04:00
thePR0M3TH3AN 216f176aab Fix canvas group shift selection 2026-05-24 15:33:24 -04:00
thePR0M3TH3AN 237e6ffbf4 Add multi-layer transform box 2026-05-24 15:29:14 -04:00
thePR0M3TH3AN ad22985ca5 Add layer keyboard and reorder controls 2026-05-24 15:25:17 -04:00
thePR0M3TH3AN 86dd560587 Fix layer delete focus handling 2026-05-24 15:10:34 -04:00
thePR0M3TH3AN 279a529b92 Add layer panel range selection 2026-05-24 15:07:09 -04:00
thePR0M3TH3AN 1f2581e1d4 Render layer groups as collapsible folders 2026-05-24 14:59:12 -04:00
thePR0M3TH3AN fc4adb151d Add layer multi-select and groups 2026-05-23 19:57:19 -04:00
thePR0M3TH3AN 9d5c2a8e43 Prevent design object preview flex squish 2026-05-23 19:49:40 -04:00
thePR0M3TH3AN 473e7f6714 Center scaled design object previews 2026-05-23 19:47:47 -04:00
thePR0M3TH3AN 9423f6c2b3 Lock edit banner and Nostr widget heights 2026-05-23 19:45:22 -04:00
thePR0M3TH3AN 3836f4003a Match Nostr widget height to edit banners 2026-05-23 19:44:05 -04:00
thePR0M3TH3AN a610d020e9 Reserve Nostr account space for edit banners 2026-05-23 19:42:35 -04:00
thePR0M3TH3AN daa623271d Preserve transparent pixels in design objects 2026-05-23 19:35:51 -04:00
thePR0M3TH3AN 5e2f98ecd7 Add editable design object layers 2026-05-23 13:22:27 -04:00
thePR0M3TH3AN 6fc0fc8a98 Trim duplicate pixel art TODOs 2026-05-23 13:13:22 -04:00
thePR0M3TH3AN e26025f679 Add working TODO list 2026-05-23 13:09:12 -04:00
thePR0M3TH3AN 8a2c146408 added pixel art editor 2026-05-22 16:06:36 -04:00
thePR0M3TH3ANandClaude Opus 4.7 b24511ad80 Add Pixel Art Phase A1 — editable pixel layers
A new `pixelart` layer type: a palette-indexed, RLE-encoded grid stored
in the project JSON, rendered crisp (image-rendering: pixelated) across
the editor, preview-DOM renderers, and the export canvas.

A dedicated #pixelEditorView with pencil / eraser / fill / eyedropper
(drag-paint with line interpolation), a palette panel (pick / add
colours), grid toggle, mirror X/Y, zoom, and per-stroke in-editor undo.
Reachable via a tool-palette button (adds a 32x32 layer + opens the
editor) and an "Edit pixels" button / double-click on a pixel layer.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-22 15:30:43 -04:00