# Pixel Art — editable pixel layers & raster export
## Goals
Make Artstr a genuine, Nostr-native **pixel-art editor**. The user paints
on a hard-edged pixel grid; the editable grid lives in the project JSON
as compact palette-indexed data; PNG (and later GIF) are produced as
**exports**, never as the source of truth.
### The core realisation
Pixel art fits Artstr's JSON/Nostr model *better* than ordinary raster
editing does. A 64×64 sprite is a tiny indexed grid — compact, portable,
forkable, replaceable as a `kind:30078` event. The rule:
> Never store pixel art as a PNG while editing. Store it as an editable
> palette-indexed grid in the Artstr JSON; rasterise to PNG/GIF on export.
### Two faces — the disc-design / slide pattern, generalised
Pixel art has the same dual nature as a disc design or a slide — a
standalone publishable thing *and* an embeddable component — but
generalised: because it is a **layer type**, its host is not one specific
template, it is *any design*.
| Standalone design | Embeds into | As a… |
|---|---|---|
| Disc design | a disc-label sheet | disc **slot** |
| Slide | a slide deck | **card** |
| **Pixel art** | **any design** | **layer** |
- **Standalone** — a `kind:30078` event, `casewrap-pixelart`, a "Pixel
Art" layout on the **Designer tab** beside Custom Art / Slide / Disc
Design. Forkable, editable-in-place, `naddr`-shareable, encryptable.
- **Embedded** — a `pixelart` layer inside *any* design (cover, custom
art, slide, deck card, stack card). The pixel data embeds **inline** in
the host's event — the host stays self-contained, exactly like an
embedded slide or a disc-in-slot.
### Product goals
1. A **`pixelart` layer type** — a palette-indexed pixel grid, usable in
any design, rendered crisp (nearest-neighbour) at any scale.
2. A **Pixel Editor** — its own canvas-area view with a contextual pixel
tool set: paint, fill, pick, palette, grid, zoom.
3. A standalone **Pixel Art** Designer-tab layout, publishing as
`casewrap-pixelart`; importable into any design as a layer.
4. **PNG export** at 1× / 2× / 4× / 8×, transparency preserved.
5. (Later) **animation frames** and **GIF / sprite-sheet** export.
### Non-goals (v1 = Phase A)
- **GIF / animation in v1** — frames are designed into the data model
from day one, but the timeline UI and the (vendored) GIF encoder are
Phase C.
- **Whole-design → PNG export.** Rasterising an arbitrary layered Artstr
artboard is a separate, harder problem (the DOM-to-raster question).
This spec only rasterises *pixel content*. General design→PNG is out of
scope here.
- **Image import / quantization, selection/move, dither, onion skin,
symmetry, tile mode** — Phase C/D.
- **Embedding binary images** — pixel data is always the indexed grid,
never an embedded PNG blob.
- **External media offload** (Blossom) — inline only for v1; a manifest
escape hatch is sketched for later.
### Decisions (proposed — confirm before building)
1. **Layer-type-first.** The `pixelart` *layer* is the core. The
standalone "Pixel Art" mode is a thin wrapper — a project that is one
pixel grid.
2. **The Pixel Editor is its own canvas-area view** (`#pixelEditorView`),
not a toolbar tool — joining `#discDesignerView` and `#deckSorterView`.
The left tool palette is reused as a *contextual* host showing pixel
tools while it's active.
3. **Frames from day one** — `pixelArt.frames[]` always exists; a v1
static sprite is simply a one-frame stack with no timeline UI.
4. **Indexed palette + RLE**, never raw RGBA. Raster is export-only.
5. Additive schema — a `pixelart` layer type and a `pixelArt` payload
object. No `SCHEMA_VERSION` bump.
6. **Publishing is gated on a measured event-size budget** (derived from
the relays' NIP-11 limits), not on grid dimensions alone — see
**Size limits**.
---
## Implementation status
All four phases shipped.
- **Phase A1 — embedded pixel layer: shipped.** The `pixelart` layer
type + `pixelArt` data model (palette + RLE frames),
`renderPixelGridToCanvas` and crisp rendering across the editor /
preview-DOM renderers / export canvas. A `#pixelEditorView` with
pencil / eraser / fill / eyedropper, a palette panel (pick / add),
grid toggle, mirror X/Y, zoom, and per-stroke in-editor undo.
Reachable via the **▦** tool-palette button and an "Edit pixels"
button / double-click on a selected pixel layer.
- **Phase A2 — standalone Pixel Art design: shipped.**
`templateMode: 'pixelart'` — a "Pixel Art" layout on the Designer
tab where the editor view is the whole canvas area, bound to
`state.pixelArt`. Save / load / publish / fork as
`casewrap-pixelart`; shows in the community feed under the new
`pixel-art` category.
- **Phase B — raster export + editor extras: shipped.** Crisp PNG
export at 1× / 2× / 4× / 8× / 16× / 32× with transparency and
optional grid lines, sprite-sheet PNG (Grid or Row layout) for
multi-frame designs, line / rectangle / ellipse tools with a Fill
toggle, flip H/V + rotate 90° CW/CCW, grid resize (4–128 per side
with top-left anchor and transparent fill), and a Browse-community
/ Import-from-file pipeline that drops a published or local
pixel-art design into the current design as a new layer.
- **Phase C — animation: shipped.** Multi-frame `frames[]` data model
with a vertical timeline strip (add / duplicate / delete /
drag-reorder / click-to-select frames; arrow keys cycle), playback
with FPS (1–60) and Loop controls (Space to toggle, clicking the
canvas stops), Aseprite-style red-prev / blue-next onion-skin
toggle, sprite-sheet PNG export, and an inline GIF89a encoder for
animated GIF export — palette-indexed, no vendored dependency.
Multi-frame previews animate everywhere they're rendered (publish
modal, community browser, feed cards, profile pages).
- **Phase D — conversion: shipped.** "From image / GIF…" button in
the pixel editor toolbar: pick any image file → live side-by-side
preview as you tune W / H / Colors / fit mode (Cover / Fit /
Stretch) → median-cut palette quantization → area-averaged
downscale → applies into the editor as a drop-in replacement
(one undo restores the prior state).
- **Phase D2 — animation import: shipped.** An **inline GIF89a
decoder** — the mirror of the encoder, no vendored dependency —
turns an animated GIF into one editor frame per GIF frame. A
browser `
` only ever exposes frame 0 to canvas, so decoding
is the only way to get at the rest. It handles LZW, interlacing,
local colour tables, transparency, and all three disposal methods
(frames composite over their predecessor, so partial
"changed-rectangle" frames render correctly).
- **Disposal 2 and mis-tagged deltas.** Disposal 2 ("restore to
background") is implemented as a clear to transparent, as every
browser does — the spec's background colour is routinely garbage,
pointing at an unrelated palette slot. The clear is suppressed in
exactly one case: a **full-canvas** frame in an animation whose
**first frame was fully opaque**. Such a file demonstrably has a
baked-in background, so "restore to background" cannot mean "erase
to nothing"; in practice these are deltas whose exporter marked the
unchanged background as the transparent index to shrink each frame,
and honouring the clear punches the background out of every later
frame. Both conditions are load-bearing — full-canvas only, so
sprite erasure (disposal 2 on a small rect, the case the method
exists for) still leaves no trail; opaque-first-frame only, so an
animation on genuine transparency — including our own exporter's
output, which also writes full-canvas disposal-2 frames — keeps its
transparency and leaves no trail either.
- **One shared palette** across every frame, quantized from all
frames together — per-frame palettes made an animation shimmer
and burned through the 256-entry cap.
- **Playback FPS from the source**, taken as the median frame delay
(robust against the long pause frame animations often end on);
per-frame `durationMs` is preserved in the payload.
- **Frames** control evenly samples a long animation down, and the
decoder bounds both frame count and working resolution so a big
GIF can't exhaust memory — whatever it drops is reported, never
silent.
- A GIF already at grid size with a limited palette imports
**pixel-exact**: the downscaler switches to nearest-neighbour at
1:1 or integer ratios, and the quantizer keeps the source's exact
colours when there are fewer than requested.
- **Undo / redo integration.** The pixel editor's local undo /
redo is driven by the top-level Undo / Redo buttons while the
editor is open, and falls back to the global design history
when the editor is closed. Snapshots capture all frames plus
the current-frame index, so an undo across a flip / rotate /
resize restores everything coherently. Standalone-mode edits
push debounced entries to the global stack too.
Not built (deferred to the backlog): WebP / APNG / animated-WebP
export, Blossom offload + external manifest form for very large art,
and animated pixel layers that drive Stack actions on click.
---
## Why this fits the repo
### What's reused
- **The layer system** — a `pixelart` layer moves / resizes / rotates /
z-orders / locks / clips / sets opacity like any other layer; it shows
in the Layers panel; it rides in every payload and preview.
- **The "separate canvas-area view" pattern** — `#previewShell` already
swaps between `#sheetWrap`, `#discDesignerView`, and `#deckSorterView`.
`#pixelEditorView` is one more.
- **The edit round-trip** — entering the pixel editor for an embedded
layer and writing back on exit is the deck's "edit a slide → Back to
deck" pattern (`persistEditingDeckSlide` / `editingIndex`).
- **The Designer-tab layout system** — a `pixelart` `templateMode` slots
into `applyLayout`, `WORKFLOW_ORDER`, `updateBodyClasses`,
`normalizeTemplateMode`, `MODE_LABELS`, exactly as `slide` did.
- **Undo/redo** — the 50-deep snapshot history already covers `state`;
a pixel layer's data is in `state`, so a paint stroke is an ordinary
history entry.
- **Publish / fork / feed / encryption** — a pixel-art design is a normal
`kind:30078` design; the private-publishing envelope wraps it free.
- **Import-as-component** — pulling a published `casewrap-pixelart` in as
a layer mirrors the disc-design → disc-slot import flow.
### Genuinely new work
1. The **`pixelart` layer type** + the indexed-grid + RLE data model.
2. A shared **`renderPixelGridToCanvas()`** — the app's first real
raster/`