mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-02 19:50:39 +00:00
a8c855fc5c335dbf75b984ba3e9bdf63f4fe45b4
178
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a8c855fc5c |
Premium step 1 — soft-gate crypto primitive
First piece of PREMIUM_DESIGNS_FEATURE.md §6. Adds the static
client-side decryption layer for premium designs: HKDF-SHA256 over
an obfuscated per-epoch pepper + a per-design public salt, then
AES-256-GCM with the event coordinate as additional-authenticated-
data so ciphertext can't be copy-pasted to a different event.
noble bundle
- Add @noble/hashes/hkdf to the bundle entry; expose as
window.ArtstrNoble.hkdf and .hmac. Bundle size 14.0 KB → 16.5 KB.
Premium IIFE (new section in index.html)
- _softGatePepperParts — per-epoch lookup of 4x8-byte fragments
("palette seed I..IV"). For epoch 2026-05. Sub-strings don't
look like a "premium key"; reconstruction concats at use time.
Comment explicitly calls this obfuscation, not security, per
PREMIUM_DESIGNS_FEATURE.md §2 / §6.3.
- SUPPORTED_EPOCHS / CURRENT_EPOCH — adding a future epoch is
one entry in the parts table.
- pepperBytesForEpoch(epoch) — reconstructs the 32-byte pepper.
- deriveKey({ epoch, creatorPubkey, premiumAddress, schemaVersion,
publicDesignSalt, puzzleNonce }) -> 32 bytes via HKDF-SHA256
with the pepper as IKM, salt = publicDesignSalt, info =
"artstr-softgate-v1|<epoch>|<creator>|<addr>|v<schema>" (and an
optional "|p<nonce>" for the §6.7 CPU-puzzle hook).
- encodeAad(creatorPubkey, dTag) -> UTF-8 of
"30078:<creator>:<dTag>" so AES-GCM authenticates the
coordinate.
- encryptPayload(payloadObj, coord, { epoch? }) — random
32-byte salt + 12-byte IV; returns the spec §5.2 envelope
(base64 fields, softgate metadata, etc.).
- decryptPayload(envelope, coord) — reverses the above.
- Self-test runs on every load: round-trip a known payload AND
verify AAD/creator/schema mismatches all reject the decrypt
(event-coordinate binding). Single console.warn on failure.
Tested via CDP — all green:
- Round-trip with random keypair: ok
- AAD mismatch (different dTag) -> AES-GCM rejects: ok
- Creator pubkey mismatch -> KDF info changes -> rejects: ok
- Schema version mismatch -> KDF info changes -> rejects: ok
- KDF deterministic with identical inputs: ok
- Output length = 32 bytes (AES-256): ok
- Pepper reconstruction = 32 bytes: ok
- Envelope size for tiny payload = 577 bytes; ~63 KB headroom
under typical relay limits, plenty for an embedded preview
JPEG in step 2.
- Regression: NWC crypto self-test still passes; existing editor
still loads and renders layers.
Code only; nothing in the publish/feed flow uses this yet. Next
step (PREMIUM_DESIGNS_FEATURE.md §19 item 2) is the watermarked
preview pipeline.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
4e7ec76817 |
Lightning tips: pay in-app via NWC when a wallet is connected
Adam pointed out the tip flow still showed the bolt11 / QR view even when the user had a NIP-47 wallet set up. That re-introduces the exact wallet-handoff friction the NWC integration is meant to solve. Changes to the existing LNURL tip flow: - openTipModal sets the action button to "⚡ Send tip" when nwc.isConnected(), and to the original "Get invoice" otherwise. - getTipInvoiceFlow, after fetching the bolt11, tries nwc.payInvoice() FIRST when a wallet is connected. On success: toast "Sent N sats ⚡" + close modal. On failure (preimage missing, wallet error, budget exhausted, etc.): falls through to the existing showTipInvoiceView so the user can still pay the bolt11 externally with the visible error inline. - Button label restoration uses the same dynamic source so post- error retry shows the correct (state-appropriate) label. Pre-flight gates (amount validation, LNURL meta fetch, zap path construction) are unchanged — NIP-57 zap tagging still wraps the LNURL request so the recipient still sees a tracked kind-9735 receipt; the only difference is who pays the bolt11. Verified via CDP: button label flips between "Get invoice" and "⚡ Send tip" with nwc.isConnected() state. Co-Authored-By: Claude Opus 4.7 <[email protected]> |
||
|
|
c6955cd2ad |
Phase 3 — zap-gated templates UI
The publish flow now lets a creator tag a kind-30078 event as premium with a sat minimum, and the feed swaps the Use/Fork button for a "⚡ Unlock for N sats" CTA that drives the dual-zap split orchestrator from Phase 2. Publish side: - state.publish = { zapGate, zapMin } added to the editor state so autosave persists the user's last choice. - getTemplateTags() emits `zap-gate=true` + `zap-min=<sats>` tags when state.publish.zapGate is on. Encrypted (private) designs cannot be gated — they're not feed-listed, so the tags are deliberately suppressed. - publish-confirm modal grows a "Premium template" checkbox + a minimum-sats input. An inline note recomputes the creator/ platform split from the current input and the PLATFORM_FEE_BPS constant on every keystroke. Toggling Private hides the whole premium section. - Two blocking pre-publish checks: (a) creator's kind-0 must carry a lud16 or lud06 — corrective inline error + a hard stop in handlePublishConfirmPublish; (b) the chosen zap-min must split to ≥1 sat creator share. Consumer side: - isZapGated(event) / getZapMin(event) — pure tag readers. - buildRowsFromEvents annotates rows with `row.zapGate = { gated, min, unlocked }` so downstream code can branch cheaply. - buildFeedCard swaps "Use/Fork" for "⚡ Unlock for N sats" when `row.zapGate.gated && !row.zapGate.unlocked`, with a tooltip that names the platform-fee share. Preview button + whole-card click still open the design preview (the gate is on importing, not on looking). - openUnlockConfirm(row) — wallet/login checks, confirm() with the split shown, runs payZapSplit with a live status toast, marks unlocked, refreshes the feed. - localStorage cache UNLOCK_CACHE_KEY ('casewrap-unlocked-templates') with markUnlocked / isUnlockedLocally / getAllUnlocked. markUnlocked also drops any matching card DOM so diffWallCards rebuilds it on next rerender — without this, the optimistic same-id reuse kept the lock button on screen after payment. Relay fast-path: - relayFastPathUnlockCheck(gatedEventIds) — kind-9735 query with `#e=[gatedIds]` + `#P=[myPubkey]`. Any matching receipt = the user already paid, so we mark unlocked locally without a re-pay. Wired into fetchFeedAndReactions right after template fetch so a cache wipe / new device picks up existing unlocks for free. Tested via CDP (synthetic events; live e2e is Phase 4): - isZapGated tag detection (true/false/missing). - getZapMin parses the value. - markUnlocked / isUnlockedLocally round-trip. - getTemplateTags emits zap-gate + zap-min when on/public, omits when private. - Feed renders the gated event with "⚡ Unlock for 150 sats" button when locked; after markUnlocked + rerender, switches back to "Use/Fork". - Publish modal: Premium toggle expands the details panel; split note matches platform.splitZapAmount math; lud16-missing surfaces the corrective inline error. - Regression smoke: custom-art editor + render still work. Co-Authored-By: Claude Opus 4.7 <[email protected]> |
||
|
|
d22089c3e5 |
Phase 2 — platform-fee plumbing + dual-zap orchestrator
Adds the platform constants, the npub → lud16 lookup, the split math,
the partial-unlock localStorage cache, and payZapSplit — the
orchestrator that drives a creator/platform dual-zap through the NWC
client added in Phase 1.
`platform` IIFE:
- PLATFORM_NPUB hard-coded; PLATFORM_FEE_BPS = 3000 (30%). Single
constants so the fee is a one-line change later.
- getPubkeyHex() — decodes the npub once, caches the hex result.
- resolveLud16() — lazily fetches the platform's kind-0 from the
user's relays (with a 4.5s timeout), picks the most-recent profile
that has a Lightning address, caches for the session. Falls back
to lud06 → bech32-decoded lud16 string if lud16 is missing.
- resolveLnurlMeta() — pulls the LNURL payRequest metadata so we
can validate minSendable before fetching a real invoice.
- splitZapAmount(total, bps) — platform takes floor((total*bps)/
10000); creator gets the remainder so the legs always sum to
`total`. Defends against tiny amounts (negative, zero, 1-3 sats
where the platform share floors to zero).
Partial-unlock cache (PARTIAL_UNLOCK_KEY = 'casewrap-partial-unlocks'):
- Shape: { [eventId]: { creator?: { signedReq, bolt11, preimage? },
platform?: { ... }, startedAt } }
- Tiny helpers (getAll / get / save / clear) — Phase 4 will wire the
cross-session resume banner on top of this.
payZapSplit({ creatorPubkey, creatorMetadata, eventId, totalSats,
onStatus }) — the core orchestrator:
1. Resolve creator + platform LNURL metadata in parallel.
2. Validate minSendable on BOTH sides; refuse with a precise error
if either LSP won't accept the split share.
3. Build + sign both kind-9734 zap requests via Promise.all on
window.nostr.signEvent — the user sees two prompts back-to-back
rather than minutes apart.
4. Fetch both bolt11s in parallel.
5. Pay creator FIRST, then platform (sequential, so a half-failure
at least pays the creator and the user only retries the platform
leg).
6. Before each pay, run nwc.lookupInvoice(bolt11) — if the wallet
already settled it (idempotent retry across sessions), reuse the
preimage instead of double-paying.
7. Persist progress to PARTIAL_UNLOCK_KEY after every step; clear
only when both preimages are in hand.
8. Mark unlocked on preimage return — the preimage IS proof of
payment (it hashes to the bolt11's payment hash). Kind-9735
receipt arrival is async verification picked up via the existing
feed-load zap fetcher.
9. Throws with `err.stage = 'creator' | 'platform'` so the UI can
show a targeted retry button.
Tested via CDP:
- Constants + module shape ✓
- npub → hex pubkey: a4a6b5849b... (correct decode of PLATFORM_NPUB)
- Split math: 9 cases, including 100→70/30, 101→71/30 (rounding),
1-3 sats (creator wins ambiguity), and negative/zero edge cases.
- Partial-unlock cache round-trip ✓
- Live resolve against real relays: platform npub → adammalin@
strike.me → Strike LNURL endpoint, minSendable=1000 msats,
allowsNostr=true. So zaps to the platform will actually settle
AND issue kind-9735 receipts. (Receipt subscription continues to
happen via the existing feed-load fetchZapsForEvents path; no
new background sub needed for v1 auditability.)
The live `payZapSplit` end-to-end with a real CoinOS source wallet
runs in Phase 4 E2E.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
c3d280c0be |
Phase 1 — NWC client + wallet settings UI
The NIP-47 client (RPC over a relay-backed WebSocket) plus the
Settings-panel UX for pasting / managing a Lightning wallet
connection. Both required for the zap-gated unlock flow that lands
later in this arc.
NWC client (`nwc` IIFE):
- parseNwcUri: handles `nostr+walletconnect://` and the
`nostrwalletconnect://` alt scheme; multiple `relay` params;
URL-encoded secrets; optional `lud16` param. Returns null on
any malformed input.
- connect: opens a WS to the first relay, subscribes to
`{kinds:[23195], '#p':[clientPubkey]}` for responses and
`{kinds:[13194], authors:[servicePubkey]}` for the wallet's
capability info, then fires a `get_info` request to fetch the
alias / node pubkey. Persists creds + capabilities + last-test
timestamp under `casewrap-nwc-connection`.
- payInvoice / lookupInvoice / getInfoFresh: RPC helpers built on
a generic `_request(method, params, opts)` that NIP-04-encrypts
the JSON payload to the wallet service pubkey, signs the kind-
23194 event with the NWC secret (NOT the user's identity key),
publishes it, and resolves with the decrypted response via the
`e`-tag correlation. Default 25s timeout, configurable per call.
- Persistent listener registry (`nwc.on(fn)`) for UI re-renders on
any state transition (connect, disconnect, response decrypted,
capability list arrived, WS closed).
- Lazy reconnect: WS closed → next `_request` reopens; in-flight
promises are rejected so the caller can surface a real error.
Wallet settings UI (`walletUI` IIFE) — new <details> panel inside
the Nostr modal:
- Disconnected: CoinOS-led setup with three numbered steps + a
deep link to coinos.io/settings/connect; a generic paste field
that also accepts Alby Hub / Mutiny / Phoenix / Cashu.me URIs;
Connect + Paste-from-clipboard buttons; per-action status line.
- Connected: alias (from get_info, falling back to "Connected
wallet"), lud16 or short pubkey, relative "Tested X ago"; Test
and Disconnect actions; a capability warning that surfaces only
when the wallet doesn't advertise `pay_invoice`.
- Summary badge ("— PersistedWallet" / "— not connected") so the
state is visible without expanding the panel.
- Subscribes to `nwc.on()` so background reconnects and capability
discovery update the UI without manual refresh.
Tested via CDP (everything that doesn't need a real wallet):
- URI parser: real-shape (Alby-style URL-encoded relay + lud16),
multiple-relay, alt scheme, malformed scheme, short secret,
empty string. All produce the expected pass / null outcomes.
- Bad URI on Connect → parser error in status line; UI stays in
disconnected view.
- Valid-shape URI to a fake wallet → connects, switches to
connected view, persists to localStorage.
- localStorage round-trip: page reload restores the connected UI
with alias, lud16, relative timestamp, capability set.
- Disconnect: clears storage + returns to disconnected view.
- Regression check: existing custom-art editor still works.
The ship gate ("CoinOS-issued URI: get_info returns alias;
pay_invoice 10 sats settles") needs a real wallet, so the live
test moves to Phase 4 E2E.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
7eae78e909 |
Phase 0 — crypto bundle for NWC
Bundle @noble/secp256k1 v3.1.0 + @noble/hashes v2.2.0 as an IIFE
(noble-bundle.min.js, 15.8 KB) and load it before the main script.
The bundle exposes window.ArtstrNoble with schnorr, getPublicKey,
getSharedSecret, sha256, hex/randomBytes utils, plus secpUtils.
(noble v3 keeps hash functions external; the bundle wires
hashes.sha256 + hashes.hmacSha256 so schnorr.sign / verify work.)
On top of that, add an `NWC` IIFE in the main script with:
- sharedSecret(privHex, peerPubHex) — NIP-04 32-byte ECDH x-coord
(handles x-only 32-byte and full 33-byte pubkey hex).
- encrypt04 / decrypt04 — AES-256-CBC via crypto.subtle, 16-byte
random IV, `<base64ct>?iv=<base64iv>` wire format.
- getEventHash + signEventWithKey — NIP-01 event hashing + schnorr
signing using a *passed-in* hex privkey (the NWC connection's
secret, not the user's identity).
- verifyEvent — for kind-9735 receipt verification later.
- getPubkeyHex — schnorr x-only pubkey from a hex privkey.
Phase-0 self-test runs once at load: BIP-340 test vector #1 derives
the expected x-only pubkey AND verifies the published signature
through window.ArtstrNoble.schnorr; a NIP-04 round-trip encrypts
+ decrypts a known plaintext between two random keypairs; a
synthetic Nostr event is signed and verified through our helpers.
All checks pass silently; any failure logs a single console.warn
so the user sees "Crypto self-test failed — NWC features will not
work" if a bad bundle ever ships.
Verified via CDP:
- derivedPub matches BIP-340 vector 1: dff1d77f...502ba659
- sigVerify on the vector signature: true
- nip04Roundtrip: true
- eventSigned + eventVerify: true
- No console errors loading the app; existing custom-art editor
smoke test (add a color layer, render) still works.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
809e0f8fae |
Pixel editor: fix dropdown clipping when toolbar wraps near the sidebar
The pixel-art canvas-size dropdown (and the PNG-export dropdown that
shares the same class) used `position: absolute; right: 0` relative to
its toolbar wrap. The wrap lives inside `.pixelEditorBar`, which has
`flex-wrap: wrap`. When the bar wrapped and the trigger button landed
on the LEFT of its row, the menu's right-anchor pushed it 260px to the
LEFT — straight under the main sidebar.
Fix:
- Switched .pixelExportMenu to `position: fixed` (only when open).
- Added a `placeFlexMenu(menu, btn)` helper that anchors the menu's
right edge to the button's right edge by default, flips to the
button's left edge if anchoring right would push the menu past the
sidebar (using the aside's actual right edge as the safe-left
boundary, not just the viewport edge), and finally clamps to the
viewport on both sides.
- Added a fixed width (260px) + box-sizing border-box to keep the
menu from expanding to its natural content width once it's freed
from the previous absolute-positioning constraint.
- Window-resize handler auto-closes any open menu — the fixed coords
were computed against the previous layout, so resizing would
otherwise strand the menu away from its button.
Verified via CDP across 920px / 1000px / 1100px / 1440px viewports and
in both sidebar-open and sidebar-collapsed states. Also exercised the
specific failure scenario (button moved to the leftmost slot of the
bar, simulating the worst-case wrap layout) — menu now opens to the
right of the button, fully inside the canvas area.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
733d123edc |
Top-right action strip: pin undo/redo + canvas-size readouts under Nostr widget
The undo/redo pair and the trim/sheet canvas-size pills lived inside
.topbar's flex row, which wraps at narrow widths. Adam wanted them in a
permanent, predictable place so muscle memory works regardless of
editor mode or sidebar state.
Changes:
- .status is now `position: fixed; top: var(--top-strip-height);
right: 0; flex-wrap: nowrap` with the same panel/border styling as
the Nostr widget — a second strip directly below it.
- Reordered the markup so the canvas-size pills come first and the
undo/redo group last, so undo/redo end up hugging the viewport edge
and the pills sit to their left (matching the requested layout).
- Dropped the `body.app-designer .status` override (full-width
left-aligned), since the new positioning is universal across modes.
- updateNostrWidgetSpace() now measures the wider of the Nostr widget
or the new strip and reserves topbar right padding accordingly, so
the project title can't slide under the strip.
Verified via CDP at 1440px (cover, customart, pixelart modes) and at
900px narrow — strip stays pinned top-right with undo/redo on the
outside edge in every case.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
3c40951a8a |
Template browser: stop pinning rendered count to the first stream batch
Reported issues that all turn out to share one root cause:
1. "Open browser, keep it on All — nothing loads. Switch to Case Covers
or Disc Designs — they're all there."
2. "Still need to scroll down to see them all."
3. "Up-vote sorting and zap sorting seems not to work."
Symptom CDP confirmed: after opening Browse, `_feedPagination.New` ends
up with `rows: 72, rendered: 7`. Only 7 cards in the wall on the All
filter; 7 on Case Covers (out of 26); 7 again on All. So the wall is
clamped to the first batch size the streaming fetch happens to deliver.
Root cause: in loadFeedTab's rerender(), the reset for rendered was
`!rendered || rendered > rows.length`. As templates stream in via
notifyFeedProgress, the first rerender sees ~7 events and sets
rendered=7. Later rerenders with rows growing to 72 leave rendered
pinned: 7 is neither 0 nor `> 72`, so neither reset clause fires. The
auto-fill RAF check then short-circuits because 7 cards already
overflow the (short, modal) viewport, so it never tops up. And the
Popular / Zapped sorts re-sort all rows but only the first 7 ever
reach the DOM — so the visible order looks like the wrong top results.
Fix: change the reset to `rendered < chunkTarget`, where chunkTarget =
min(rows.length, chunk). That grows rendered up to a full chunk on
every rerender as long as enough rows exist, while still preserving
the user's scroll-loaded extra pages (rendered > chunk stays put).
Verified via CDP: All shows 24/72, Case Covers 24/26, All again 24/72.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
15ad4e93c3 |
Nostr login: persist to autosave on completion, not just on next render
Reported: "the nostr login doesnt persist after a refresh now."
Root cause: state.community.myPubkey is written by loadCommunityIdentity,
but the localStorage write only fires from inside render(). nostrLogin
calls loadCommunityIdentity → syncCommunityInputs + updateNostrWidget,
none of which call render(). A user who signs in and refreshes
immediately (without making any other interaction that triggers a
render — adding a layer, toggling a setting, etc.) loses the login,
because the autosave still reflects the pre-login state captured by an
earlier init-time render().
Fix: extract the autosave write into a `persistAutosave()` helper and
call it from both early-return paths of loadCommunityIdentity (logout /
no extension and successful sign-in). render() now calls the same
helper, so the on-disk format is identical to before.
Verified end-to-end via CDP:
- Stub window.nostr → call loadCommunityIdentity → no render → refresh
→ state.community.myPubkey restored, profile widget shows.
- Same flow but window.nostr throws (logout) → cleared pubkey
persisted as ''.
- render() autosave still captures layers correctly (regression check
on the helper extraction).
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
d6dcdfba11 |
Community browser: don't trash the welcome modal's pre-warmed cache
The previous "auto-refresh on Browse click" commit unconditionally called invalidateFeedCache() before opening the modal. That set _feedCache.events = null and forced a 6-second re-fetch on every Browse click — wiping the cache the welcome modal had populated seconds earlier. Symptom: cold reload → welcome loads instantly → exit → Browse shows "Loading..." for ~6s with nothing rendered, while changing the type filter (which only re-filters the cache) appears to "work" once the fetch finally lands. Smarter behaviour: - Add a `force` option to fetchFeedAndReactions that bypasses its 5-minute cache check. Use this from the Browse buttons. - Don't blow away the cache. Open the modal, switch to the New tab, which renders cached cards immediately via the existing `if (_feedCache.events && _feedCache.events.length) rerender()` path in loadFeedTab. Then trigger a force refresh only if the cache is staleish (>30s old). So: welcome just-loaded cache (age=2-5s) → Browse opens instantly with those cards, no extra fetch. Older cache (age=1m, 4m, etc.) → Browse renders cached cards instantly AND kicks off a background refresh whose progress streams in via notifyFeedProgress. The explicit Refresh button in the modal is still available for on-demand forced pulls. Co-Authored-By: Claude Opus 4.7 <[email protected]> |
||
|
|
e8c3115236 |
Community browser: auto-fill viewport on initial render
Symptom: after opening the browser, users had to scroll down a few pixels to see the rest of the initial card chunk. Cause: loadFeedTab's rerender() draws the first chunk (24 cards) via diffWallCards but skips the scroll-triggered auto-fill chain. appendFeedRows DOES have that chain (chained-RAF that keeps adding chunks until the modal scroller becomes scrollable), but it only runs when the user scrolls. Fix: after the initial diffWallCards, schedule a RAF that runs the same viewport-fullness check appendFeedRows uses — if the modal isn't yet scrollable AND there are unrendered rows, call appendFeedRows. The recursive RAF inside appendFeedRows then keeps adding chunks until the viewport is full or rows are exhausted. Co-Authored-By: Claude Opus 4.7 <[email protected]> |
||
|
|
fc8a89d2fa |
Community browser: auto-refresh on open + longer relay timeouts
Three reported issues:
1. "When I click the button it should auto-refresh." The two Browse-
Community buttons opened the modal and called switchModalTab('New'),
which routes to loadFeedTab. fetchFeedAndReactions honours a 5-minute
cache (line 16310), so a user reopening the browser within that
window saw stale results — including not seeing their own design
they just published. Add invalidateFeedCache() before opening the
modal so the 5-minute cache is bypassed on every Browse click.
Applied to both #browseCommunityBtn and #designerBrowseCommunityBtn.
2. "Sometimes it does not load everything in correctly each time."
The relay fetch timeouts were tight enough that slow relays would
miss reply windows. Bump:
- templates: 4500ms → 6000ms
- deletions: 3500ms → 5000ms
- reactions: 2500ms → 4000ms
- zap receipts: 4500ms → 6000ms
Streaming via onEvent is unaffected, so the first event still
appears the moment any relay responds; the longer ceiling just
means more events make the cut before the await resolves.
3. "Zap and up-vote ranking is not working reliably." The sort logic
in loadFeedTab is correct — Popular sorts by zap total, then
reactions, then trust, then date; Zapped sorts purely by zap
total. Reactions are fully fetched before the initial rerender;
zap receipts stream in asynchronously and trigger debounced
rerenders via scheduleZapStreamRerender → notifyFeedProgress,
which re-sorts using the now-current _zapCache. So unreliable
perception was mostly "I see initial sort, then it reorders."
The fix here is indirect — longer zap timeout + auto-refresh on
open mean more zaps arrive within the first rerender window, so
the user sees the correct order sooner instead of watching it
shuffle.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
88bd59bf12 |
JPEG export: DPI / quality selector
Small dropdown next to Export JPEG with three presets:
- 96 dpi / quality 0.85 — fast, screen-only
- 150 dpi / quality 0.92 — standard (default)
- 300 dpi / quality 0.95 — print-grade
The picker lives next to the existing Print/Save PDF + Export JPEG
buttons in the Print & Save panel. The click handler reads the
selected option and passes the right { dpi, quality } pair to
exportArtboardAsJpegBlob. No persisted state — defaults to standard
on each page load.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
0cf8d05c1e |
Resize: corner handles default to independent axes (Shift = lock aspect)
Corner handles used to lock the aspect ratio whenever the sidebar 🔒 toggle was on (the default). Result: dragging the corner horizontally a lot and vertically a little still scaled both axes by the horizontal-driven factor — width and height grew at the SAME rate regardless of the drag vector. Switch to the Figma / Photoshop / Keynote convention: - Corner handle drag → axes are independent. Each axis follows its own drag delta. Drag right-and-a-bit-down → width grows a lot, height grows a little, matching the cursor exactly. - Hold Shift while dragging the corner → lock the aspect ratio. Same math as before (the larger-magnitude axis sets the scale, both axes get it). The sidebar 🔒 toggle still governs typed W / H input cascades — so typing Width:5 with the lock on still updates Height proportionally. Only the visual corner-drag behaviour changed. Same fix applies to: - startLayerResize (single-layer corner handles). - startLayerSelectionResize (multi-select bounding-box corner handles). Edge handles (n/s/e/w) are unchanged — they're always single-axis. Co-Authored-By: Claude Opus 4.7 <[email protected]> |
||
|
|
54cf915b4f |
JPEG export: rich-text rendering (P2) via canvas run-layout
The plain-text fillText path (drawTextLayerPlainToCanvas) strips inline
marks via layerHtmlToPlainText. Add a rich-text path that preserves
inline bold / italic / underline / lists / links in the JPEG output.
First attempt was an SVG <foreignObject> wrapping the layer's HTML,
loaded via a Blob URL and drawImage'd onto the canvas — clean and
short. Chromium taints the canvas on every <foreignObject> SVG
regardless of origin, so toBlob('image/jpeg') threw SecurityError.
Confirmed via CDP: `kind:'taint', err:"Tainted canvases may not be
exported."`
Switched to a pure-canvas rich-text engine:
- _parseTextRunsFromHtml walks the layer's HTML into a flat list of
styled runs ({text, bold, italic, underline} or {br}). Handles
<b> / <strong>, <i> / <em>, <u>, <a> (visual underline only,
links don't survive into the canvas image), <br>, <p> / <div> /
<h1>-<h3> (block break before+after), <ul>/<ol>/<li> (bullets
for unordered, "1.", "2.", … prefixes for ordered, properly
nested via a list stack).
- _layoutTextRuns walks the runs with ctx.measureText, builds a line
list with word-wrap at the layer's inner width. Trailing whitespace
at a wrap point is discarded.
- drawTextLayerRichToCanvas applies the layer's clip, walks the lines
with proper left / center / right alignment using each line's total
width, fillText per item, and a tiny fillRect under each underlined
run (sat just below the baseline at ~95% line-height, ~6% thick).
The dispatcher drawTextLayerToCanvas calls the rich path when
_textHasInlineMarks(layer.html) — i.e. layer.html contains any of
b/strong/i/em/u/a/br/ul/ol/li/span/font — and the plain path
otherwise. drawLayerToCanvas now awaits drawTextLayerToCanvas (it
became async).
End-to-end verified via CDP on a Custom Art canvas:
layer 1: "<b>Bold</b>, <i>italic</i>, <u>underlined</u>,
<a href=…>link</a>."
layer 2: "<ul><li>Item one</li>… </ul>"
Resulting JPEG renders Bold as bold, italic as italic, both
underlined and link as underlined, and the list as bulleted lines.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
2d1ed1124d |
Custom Art: match canvas to imported image's natural dimensions
New "Match canvas to image size" button in the selected image layer's panel. Only visible in Custom Art mode (Slide is 16:9- locked; Cover/Disc/Jewel use sheet dimensions tied to physical media). Behaviour: - Loads the image via loadImageWithCors with a short timeout so a CORS-hostile or unreachable URL errors cleanly instead of hanging. - Updates state.customArt.width / height to the image's naturalWidth / naturalHeight, clamped to [CUSTOM_ART_MIN, CUSTOM_ART_MAX]. Marks preset as 'custom'. - Pushes one history entry so the resize is undoable. - Toasts the resulting canvas size (and notes if the image was clamped down because it exceeded CUSTOM_ART_MAX). The button visibility is computed in syncLayerInputs: image type + has src + templateMode === 'customart'. Hidden otherwise. Co-Authored-By: Claude Opus 4.7 <[email protected]> |
||
|
|
902befa189 |
Fix blank PDF print regression — split exporting / export-print classes
Recent commit
|
||
|
|
29dcdeea61 |
Canvas crop marks for Custom Art + Slide modes (toggleable)
Crop marks were a cover-only feature; Custom Art / Slide canvases exported with no corner registration marks at all. Add a corner crop-mark set for canvas modes with the existing showCrops / printCrops toggles wired up. Changes: - New <div class="cropMarks canvasCrops"> sits inside .sheet alongside the existing cover cropMarks. Eight corner marks, positioned outside the canvas edges via new .cc-* classes that are scoped under .canvasCrops so they don't collide with the bleed-anchored cover .cm-* positions. - CSS shows .canvasCrops only in body.template-customart and body.template-slide. It reuses the existing `--crop-display` / body.print-crops gating, so state.showCrops drives on-screen visibility and state.printCrops drives print visibility — the same toggles the cover mode has used all along. - Move showCrops and printCrops <label>s from `cover-toggle` to a new `crops-toggle` class. Cover-toggle still hides showGuides / showBleed / printGuides in non-cover modes (those are bleed/safe concepts that don't apply to canvas modes); crops-toggle shows in cover / customart / slide, hidden only in disc + jewel sheets. JPEG export was already unaffected — the direct canvas rasterizer doesn't touch the DOM, so canvasCrops never appears there. The PDF Print route does render the DOM, where printCrops now controls inclusion. Verified via CDP: in customart mode, both toggle labels render, .canvasCrops is visible by default with a valid corner-span bounding box, and `state.showCrops = false; render()` hides it. Co-Authored-By: Claude Opus 4.7 <[email protected]> |
||
|
|
119b14e1d3 |
Editor UX: click background to deselect + strip selection chrome in print
Three small editor-UX fixes:
1. Click in the canvas gutter → deselect. The existing #sheet click
handler only fired when the click landed inside .sheet; clicks in
the area between the sheet edge and the previewShell edge
("sheetWrap" parent) hit no handler and the selection stuck.
Added a #previewShell click handler that deselects when the
click isn't on a .freeLayer and isn't on #sheet (which the
existing handler still covers). Layer .freeLayer clicks already
stopPropagation, so they don't bubble through.
2. Selection chrome no longer bleeds into PDF / JPEG-fallback
output. The codebase had `body.exporting .freeLayer.selected
{ outline: none }` rules sitting dead — JS only ever toggles
`body.export-print`. Renamed the CSS to match. Also added rules
to hide .layerHandle (resize squares) and .layerSelectionBox
(multi-select dashed box) under the same class.
3. The regular Print / Save PDF button (printWithCurrentFilename)
wasn't adding any export class around window.print(), so
selection chrome bled into single-sheet prints too. Added
body.classList.add('export-print') before the print + the
matching remove on afterprint. The JPEG-PDF-fallback flow
(enterExportPrintMode) already added this class; now both flows
benefit from the same CSS.
Verified via CDP: clicking in the gutter outside the sheet now
deselects ({selId:'', isSel:false}).
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
b890f78f1f |
JPEG export P1: one-click direct rasterizer, PDF fallback on CORS
Replace the click-Export-JPEG → print-to-PDF → save-to-disk → re-pick
flow with a single click. The canvas-rasterizer infrastructure was
already 90% built in this codebase — drawLayerToCanvas,
loadImageWithCors, setupExportCanvas, and rasterizers for cover /
disc-sheet / jewel / disc-design all existed but were never wired
into any user-facing entry point. This commit ties them together.
What changed:
- New renderCustomArtToExportCanvas(dpi) — the one missing rasterizer.
Handles customart / slide canvases: background colour fill + every
canvas-target layer drawn in z-order via the existing per-type
drawers.
- New exportArtboardAsJpegBlob({dpi, quality}) orchestrator. Branches
by current templateMode (and appMode for the disc-design designer):
cover → renderCoverToExportCanvas
disc → renderDiscSheetToExportCanvas
jewel → renderJewelSheetToExportCanvas
designer-disc → renderDiscDesignToExportCanvas
customart / slide → renderCustomArtToExportCanvas
pixelart / deck → {ok:false, kind:'unsupported'} (caller falls back)
Clears _exportFailedImages, renders, then:
- if any image failed to load (CORS-blocked, 404, timeout) →
{ok:false, kind:'cors', urls: [...]}
- if toBlob throws (canvas tainted, shouldn't happen but defensive) →
{ok:false, kind:'taint'}
- else {ok:true, blob}.
- exportArtboardAsJpeg(btn) rewritten as an async click handler:
disables the button + sets a "Rendering…" label, calls the
orchestrator, downloads the blob on success, or routes to the
existing openPdfJpegModal() with a toast on cors/unsupported/taint.
End-to-end verified via CDP against the running app:
- 4-layer Custom Art (color + shape circle + plain text + QR code) →
direct JPEG download, 1280×720 px, ~43 KB at dpi=96 quality=0.92.
All four layers render in the right positions (PNG preview attached
to the verification — color block top-left, red circle adjacent,
bold text below, scannable QR overlap with the text bounds).
- Single image layer with an unresolvable URL → {ok:false, kind:'cors',
urls:[...]}, which the click handler converts to a toast + the
existing print-to-PDF modal.
P1 scope ships customart / slide / cover / disc-sheet / jewel /
disc-design. Pixel-art standalone keeps its own PNG/GIF export, and
slide-deck keeps the multi-page PDF flow (both intentionally not
covered here).
Rich-text inline marks (bold / italic / underline / links / lists
applied to sub-spans) still render as plain text in the JPEG —
existing drawTextLayerToCanvas uses layerHtmlToPlainText. P2 adds
SVG-foreignObject rich-text rendering.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
|
||
|
|
dc31b023d5 |
Text layers: actually enter edit mode on second click (defer focus + relookup)
The previous attempt added a focus() call in the pointerup stop() branch, but it never stuck. CDP-driven tests against the real app showed two reasons the focus kept getting clobbered: 1. The click event still fires AFTER pointerup, propagates up to the .freeLayer's click handler, which re-runs selectLayerWithEvent + renderLayerList + syncLayerInputs. Something in that chain blurs the contenteditable (focus log: focusin → blur → focusout, all on the .textLayer, in that order). 2. The selection change between the two clicks triggers a render() somewhere — which rebuilds the layers and DETACHES the original .textLayer node that startLayerDrag's closure captured. So even when focus did briefly land on textTarget, that textTarget was already a stale, detached element by the next tick. Fix: - Defer the focus call via setTimeout(0), so it runs AFTER the click event chain completes and any blur-on-click side effects have fired. - Inside the deferred callback, re-query the live .textLayer for the layer's id via `.freeLayer[data-layer-id="..."] .textLayer`, so we never focus a detached element. - Only apply the click-position caret range if it's inside the LIVE editable (caretRangeFromPoint can resolve into the detached node if the layout hasn't reflowed yet). Verified end-to-end via CDP + Input.dispatchMouseEvent against the running app (with the welcome modal dismissed and a fresh ?n=… cache-bust). Two single clicks in sequence: - After click 1: state.selectedLayerId='tx1', activeElement=BODY. - After click 2: activeElement is a DIV with className='textLayer' and isContentEditable=true — caret is now in the text and the user can drag-select / type / apply the toolbar buttons. Co-Authored-By: Claude Opus 4.7 <[email protected]> |
||
|
|
df45b63f45 |
Text layers: click already-selected text to enter edit mode
The previous click flow only entered edit mode on a true double-click (event.detail > 1 on the second pointerdown), which is timing- sensitive — slow second clicks fell through to layer-drag and edit mode was hard to reach. Result: users clicked the text and the whole layer kept "selecting" as an object. Three changes to startLayerDrag to fix it: 1. Capture `wasSoleSelected = isLayerSelected(layer.id) && selectedLayerIds().length === 1` at the top of the handler, BEFORE the selection logic runs. This tells us whether the user was clicking on a text layer that was already the lone selection — the standard signal in PowerPoint / Keynote / Google Slides that the user wants to edit the text now. 2. A small dead-zone in the move handler (Math.hypot(dx, dy) < 4 ignores tiny jitter). Without it, a click with a 1-pixel mouse wobble flipped `moved` to true and skipped the edit-mode path. 3. New branch in stop() — if the pointer was released without crossing the dead-zone (moved === false), the click target was inside .textLayer, and wasSoleSelected was true, focus the contenteditable and place the caret at the click position via document.caretRangeFromPoint (with a caretPositionFromPoint fallback for Firefox-style browsers). Net behaviour: - First click on an unselected text layer → selects (drag handles appear, no edit mode). - Second click on the same now-selected text layer → enters edit mode, caret lands where the click was. - Click + drag on a selected text layer → drags normally (moved > 4px, edit-mode branch skipped). - Existing double-click path (event.detail > 1) still works as a belt-and-suspenders fallback for very fast double-clicks. CDP synthesized-event tests couldn't fully drive the pointer- capture path so end-to-end verification needs a real browser; the logic was code-reviewed. Co-Authored-By: Claude Opus 4.7 <[email protected]> |
||
|
|
aaf044a946 |
Text layers: inline rich-text formatting toolbar
Replaces the previous whole-layer Link URL field with proper inline rich-text editing. Users click into a text layer on the canvas, select a range, and apply formatting to just that selection. New side-panel toolbar buttons under the text-layer controls: - B / I / U — bold / italic / underline on the selection. - • / 1. — bulleted list / numbered list. - 🔗 — insert link on the selection (prompts for URL; rejects anything that isn't http / https / mailto / tel / nostr via safeTextLinkHref). - ⌧ — unlink the selection. Wiring: - Each button uses mousedown.preventDefault() so the contenteditable text layer keeps focus / selection when the user clicks the sidebar button. - _runInlineFormat() locates the contenteditable via the .freeLayer[data-layer-id="..."] .textLayer selector, focuses it if needed, runs document.execCommand, reads innerHTML back into layer.html, and debounce-pushes one undo entry. No full render — that'd rebuild the editable and lose focus / selection. Security: applyPreviewLayerTextStyle (the preview path) and the in-editor render path both post-render-scrub every <a> tag — unsafe hrefs get unwrapped (text kept, link dropped) and safe ones get target="_blank" rel="noopener noreferrer" forced on. End-to-end verified via headless Chrome + CDP. A test deck with layer.html = Visit <a href="https://artstr.org">Artstr</a> for more or <a href="mailto:[email protected]">email us</a>. Try this <a href="javascript:alert(1)">bad link</a> too. Some <b>bold</b>, <i>italic</i>, and <u>underlined</u> bits. <ul><li>One</li><li>Two</li><li>Three</li></ul> — PDF export: - pdftohtml -i extracts ONLY the legit URLs: https://artstr.org/ and mailto:[email protected]. The javascript: link was stripped. - Visual render: "Artstr" and "email us" are underlined links; "bad link" is plain text; bold / italic / underline marks apply only to their selected sub-strings; <ul> renders with bullets. The whole-layer Bold / Italic toggles in the existing text-tool row remain — they set the default weight / style for the layer; inline marks override per-span. Co-Authored-By: Claude Opus 4.7 <[email protected]> |
||
|
|
d753453258 |
Text layers: optional hyperlink URL, live in previews + clickable in PDF
A new "Link URL (optional)" field in the text-layer panel attaches a hyperlink to a text layer. When set, every render path that goes through applyPreviewLayerTextStyle wraps the layer's text in a display:block <a href> covering the whole text box, so: - Editor / community / publish-modal previews show the text as a visible underlined link. - Deck PDF export passes the <a href> through Chrome's print engine, which converts it to a clickable PDF annotation. Verified end-to- end via headless Chrome + CDP: pdftohtml extracts the link from the generated PDF as href="https://...". Safety: - safeTextLinkHref() only accepts http / https / mailto / tel / nostr schemes. javascript: and data: URLs are stripped (verified in the same test by including a javascript:alert(1) layer that rendered as plain text with no PDF link emitted). - escAttr() escapes the href value before it lands in the rendered HTML attribute. - target="_blank" rel="noopener noreferrer" on the emitted anchor. The canvas editor's contenteditable render path (separate from the preview path) is untouched, so users can still edit the text inline without the <a> wrapper interfering with cursor / selection. The link only appears in preview / export contexts. Tested with a CDP harness driving the actual app: 1-slide deck with a legit link + a javascript: link, exported via Page.printToPDF. - PDF page count: correct. - pdftohtml -i: extracts only the legit https URL. - Visual render: legit link is underlined, javascript link plain. Co-Authored-By: Claude Opus 4.7 <[email protected]> |
||
|
|
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]>
|
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]>
|
||
|
|
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]> |