Skip to content

Features: MCP screenshot server, localization workflow, device/text options, panorama, resizable panels, perf#37

Open
flocom wants to merge 165 commits into
YUZU-Hub:mainfrom
flocom:main
Open

Features: MCP screenshot server, localization workflow, device/text options, panorama, resizable panels, perf#37
flocom wants to merge 165 commits into
YUZU-Hub:mainfrom
flocom:main

Conversation

@flocom

@flocom flocom commented Jun 7, 2026

Copy link
Copy Markdown

This PR brings a large batch of features and fixes developed on the flocom fork. It's split conceptually below; happy to break it into smaller PRs if you'd prefer to review/merge piecemeal.

🤖 New: MCP server (mcp-server/)

A self-contained Model Context Protocol server that generates App Store / Play Store / web screenshots headlessly — a Node port of the app's 2D pipeline using @napi-rs/canvas (no browser). It lets an AI agent (Claude, etc.) generate marketing screenshots on demand or in batches.

  • Tools: list_output_sizes, list_gradient_presets, generate_screenshot, generate_batch.
  • Full parameter parity with app.js: gradient/solid/image backgrounds + noise, device placement (scale/position/rotation/shadow/border), headline+subheadline, free-floating elements (text/emoji/icon/graphic with laurel/badge/star frames), cropped "popout" callouts, multi-language text, and an avoidTextOverlap layout safeguard.
  • Image input by data URL, raw base64, http(s) URL, an appscreen-file:// ref from a binary POST /upload, or a server path. Image output inline (base64) or as a temporary auto-expiring download URL.
  • Transports: stdio and Streamable HTTP (+ CORS), Docker + Cloudflare-Tunnel ready, one-command setup.sh. Doesn't touch the existing web app.

🌍 Localization workflow

  • Full set of 39 App Store Connect locales (adds Malay, Catalan, Czech, Greek, Hebrew, Croatian, Hungarian, Romanian, Slovak, es-MX, fr-CA, en-AU, en-CA, pt-PT) + an "All App Store languages" button.
  • Redesigned Project Languages modal: per-language drag-and-drop upload cards, bulk folder import (fastlane sub-folders like ms/, de-DE/ auto-classified), CSV text template export/import, and a main-language sync.
  • New "All languages" canvas view: a matrix of every screenshot × every language (lazy, batched rendering), each a live preview + drop zone.

📱 Device & text

  • 2D iPhone/Samsung device models with an optional bezel/chassis and auto corner radius.
  • Notch / Dynamic Island / punch-hole in 2D, baked into the 3D screen texture too.
  • Text background color/opacity for headline & subheadline.
  • Panorama mode: span one composition across N App Store slots, sliced on export.
  • 8 more position presets; Tilt/Rotation range widened to ±90.

🪟 UX & performance

  • Resizable + collapsible left/right panels (persisted).
  • Update checker next to Settings (reloads when the server serves a newer build).
  • Delete/Backspace removes the selected screenshot.
  • Perf: IndexedDB persistence is now debounced off the render hot path (a large multi-language project went from ~80 ms to ~0.4 ms per updateCanvas).

All changes are backward-compatible (new fields default to off). Thanks for the great tool! 🍋

flocom and others added 30 commits June 7, 2026 11:57
A Model Context Protocol server that generates App Store / Play Store / web
marketing screenshots server-side — a Node port of appscreen's 2D rendering
pipeline using @napi-rs/canvas (no browser).

- Tools: list_output_sizes, list_gradient_presets, generate_screenshot,
  generate_batch
- Transports: stdio (default) and Streamable HTTP (--http / MCP_TRANSPORT=http)
- Full parameter coverage faithful to app.js: gradient/solid/image backgrounds
  with noise, screenshot placement (scale/position/rotation/perspective/corner
  radius/shadow/device frame), headline + subheadline text, free-floating
  elements (text/emoji/icon/graphic) with laurel/badge/star frames across all
  three z-order layers, cropped "popout" callouts, and multi-language text maps
- layout.avoidTextOverlap: measures the text block and constrains the screenshot
  to the free band so text never overlaps the image regardless of line count

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MCP server:
- Dockerfile (multi-stage) with the fonts @napi-rs/canvas needs to render text
  headless, vendored laurel SVG assets, and an HTTP-by-default runtime
- docker-compose.yml, .dockerignore, and a one-command setup.sh that builds and
  registers the server with Claude Code (stdio) or Docker (HTTP)
- CORS headers (+ OPTIONS preflight) so a browser can connect; lock down with
  MCP_CORS_ORIGIN
- assets/ now holds the laurel SVGs so the server is self-contained (img/ fallback)

Web app:
- Settings → MCP Server section: URL + optional Bearer token, Connect button,
  live status dot, and a listing of the server's tools
- The server URL is auto-detected from the page origin (same host, port 3000),
  so localhost and LAN work with no typing
- A project-scoped .mcp.json so Claude Code auto-discovers the server

Docs:
- README: new "MCP Server" section, Features/Automation + Tech Stack entries

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dockerize MCP server + in-app connection with auto-detected URL
The claude.ai / Claude Desktop "custom connector" flow needs a publicly
reachable HTTPS endpoint on port 443 — localhost and :3000-behind-Cloudflare
don't work. Add an opt-in `cloudflared` service (compose `--profile tunnel`)
that exposes the server over HTTPS with no open ports, plus README steps and a
configurable MCP_CORS_ORIGIN. Ignore .env so tunnel tokens aren't committed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add Cloudflare Tunnel option for public HTTPS (claude.ai connectors)
Add a "Use it from claude.ai / Claude Desktop" subsection to the MCP section on
the landing page, explaining the public-HTTPS/443 requirement and the included
Cloudflare Tunnel one-liner, with a note that local use needs none of it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
README: document public connector (Cloudflare Tunnel)
- styles.css: the Settings modal grew with the MCP section and overflowed the
  viewport with no way to scroll. Add max-height: 85vh + overflow-y: auto.
- app.js: defaultMcpUrl() always appended :3000, which is wrong for a public
  HTTPS deployment (Cloudflare Tunnel / reverse proxy on 443, where Cloudflare
  won't proxy 3000 anyway). Now https pages default to https://<host>/mcp
  (no port); http (local/LAN) keeps http://<host>:3000/mcp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fix settings scroll + HTTPS auto-detected MCP URL
A URL saved before the HTTPS fix (e.g. https://appscreen.example.com:3000/mcp)
was kept in localStorage and shown as-is, overriding the corrected auto-detect.
Add normalizeMcpUrl(): on an HTTPS URL with port 3000 (a dev HTTP port Cloudflare
can't proxy), drop the port → :443. Applied when loading the saved value (and
re-persisted), on Connect, and on Save. Leaves http://localhost:3000 and real
custom HTTPS ports untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Self-heal stale https://host:3000 MCP URL in settings
Image inputs (screenshot/background/element) already worked via data URL and
server file paths, but raw base64 was unreliable: base64 contains '/', so the
old heuristic mistook it for a file path. Detect raw base64 by the image-format
signature (PNG/JPEG/GIF/WebP/BMP) instead, so all three forms work. Document
image input forms and how the rendered PNG is returned (base64 image block +
optional outputPath) in the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Accept raw base64 image input + document image I/O
generate_screenshot / generate_batch gain a `deliver` mode:
- inline (default): PNG as a base64 image block, as before
- url: store the PNG in an in-memory TTL cache and return a temporary
  https://<host>/files/<id>.png link that auto-deletes after ttlSeconds
  (default 600). Keeps the MCP response tiny and is fetchable from a browser
  or another machine (reachable through the Cloudflare Tunnel).
- both: inline image + URL

New filestore.ts (memory-only, auto-eviction), a GET /files/:id endpoint, and
a public-base-URL resolver honoring X-Forwarded-* or PUBLIC_BASE_URL. Over stdio
(no HTTP server) url falls back to inline with a note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Temporary download URL delivery (deliver:'url')
Symmetric to deliver:'url' for output. Image inputs (screenshot/background/
element) now also accept:
- http(s) URL — the server fetches the image
- appscreen-file://<id> — a reference from the new POST /upload endpoint, which
  takes raw image bytes (no base64) and stores them in the TTL filestore; the
  ref is resolved straight from memory (no round-trip), auto-deletes after ?ttl.

This lets a Mac push a large screenshot to a remote server without embedding a
giant base64 blob in the MCP request. README documents the upload flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Import images by URL or binary upload (no base64)
Agents only see MCP tools, not the /upload HTTP endpoint, so they resort to
third-party temp hosts for large input images. Embed a dynamic hint (with this
server's actual upload URL + a ready curl command) in the generate_screenshot
and generate_batch descriptions, so any agent discovers the native, no-base64
upload path: POST raw bytes to <base>/upload → pass the appscreen-file://<id> ref.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Advertise /upload in MCP tool descriptions
…uage upload, sync, more presets

- Languages: full set of 39 App Store Connect locales (adds Malay/ms, Catalan,
  Czech, Greek, Hebrew, Croatian, Hungarian, Romanian, Slovak, es-MX, fr-CA,
  en-AU, en-CA, pt-PT) + an "All App Store languages" button.
- Languages modal: stacked rows, each with a drag-and-drop zone to upload that
  language's screenshots (matched to existing screens by base filename), an
  image count, and Main/Current badges. Modal scrolls (max-height + overflow).
- Folder import: pick a folder; sub-folders named by language (ms/, de-DE/,
  zh-Hans/ …) are classified automatically via normalizeLangCode, falling back
  to filename detection. Fastlane-aware.
- Text CSV: download a template (screenshot/name/language/headline/subheadline)
  and re-import it to apply all text at once.
- Sync main language: toggle to mirror main-language edits to every language
  live, plus a "Copy main language to all now" button.
- Position presets: +8 trending layouts (hero, showcase ↑/↓, strong tilts,
  perspective-left, minimal, full-bleed) → 16 total.

New self-contained module appstore-features.js (extends a few globals) to keep
app.js changes minimal. Verified in the browser: all functions load, add-all
1→39, stacked drop zones render, presets apply, propagate + CSV parse correct,
settings & languages modals scroll, no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
App Store language workflow: all locales, folder/CSV import, per-language upload, sync, more presets
The central preview can now switch to an "All languages" mode: a Single /
All-languages toggle above the canvas. In all-languages mode it shows one row
per project language for the current screenshot — each a live rendered preview
plus a drag-and-drop zone to set that language's image (with a "no image" badge
where missing). Debounced refresh keeps the rows in sync as you edit. This is
the in-canvas per-language view requested (vs. only the languages modal).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add 'All languages' canvas view (one row per language)
saveState() was called synchronously on every updateCanvas() — structured-
cloning the entire project (which can be 100+ MB of localized image data) into
IndexedDB on every keystroke/slider tick. With a real multi-language project
(~155 MB) each render cost ~80 ms, making the whole app sluggish.

Add scheduleSaveState(): a trailing debounce (700 ms) used by updateCanvas and
addLocalizedImage, with an immediate flush on beforeunload / visibilitychange so
nothing is lost. saveState() (immediate) is unchanged and still used for
structural changes; it cancels any pending debounce.

Result (155 MB project, measured): updateCanvas 80 ms → 0.4 ms; a 60-render
slider drag 4800 ms → 3.4 ms with 0 disk writes during the drag (one write after
editing settles). No change to what gets saved — full quality retained.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
perf: debounce IndexedDB save off the render hot path (≈200× faster updates)
A polished overhaul of the languages modal:
- Sticky header with subtitle + a live language-count chip; scrollable body;
  sticky Done footer (modal is now a flex column).
- Clean add-bar: globe-icon select + accent "All App Store" button.
- Language cards: rounded flag tile, name, Main/Current pill badges, a
  "code · N screenshots" subline, hover-revealed remove, and the per-language
  drop zone (solid green when it has images, dashed when empty).
- "Bulk import & text" icon action cards (folder import / CSV template / CSV
  import) instead of plain buttons.
- A proper toggle switch for "Sync main language", in its own card.

Also fixes a layout bug: the old .languages-list had max-height:300px which made
the flex cards shrink and clip their drop zones; cards now keep natural height
and the modal body scrolls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… color

- 2D device frame can now render an iPhone notch or Dynamic Island. New
  "Device Model" selector in the Device tab (2D): No Notch / Dynamic Island /
  Notch — this also restores a Device Model choice in 2D mode (it was 3D-only).
- Text can have a colored background: new "Text Background" color + opacity
  controls for both headline and subheadline, drawn as a rounded highlight box
  behind each line (baseline-aware).

Implemented as shared helpers (drawNotchShape, drawTextHighlight) wired into
both render paths (main canvas + the *ToContext path used by side previews,
the all-languages view, and export) so it shows everywhere. State defaults:
frame.notch='none', headline/subheadline BgColor + BgOpacity=0 (off).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
iPhone notch/Dynamic Island, 2D Device Model, and text background color
claude and others added 19 commits June 10, 2026 05:41
updateCanvas() ran a full-resolution main render PLUS up to 4 side-preview
renders (each the whole pipeline, including the 14M-iteration noise loop), and
was called synchronously on every slider tick — so a single drag triggered many
×5 full renders per frame.

- updateCanvas() now coalesces to at most ONE render per animation frame
  (requestAnimationFrame): repeated calls in the same frame collapse into one.
  The synchronous body is split out as renderCanvasNow().
- Side previews show the NEIGHBOURING screenshots, which don't change while you
  edit the current one. They now render immediately only when the selection or
  output layout changes, and are otherwise debounced (one trailing render once
  edits settle) instead of re-rendering 4 screenshots every frame.
- Export paths (exportCurrent / exportAllForLanguage / exportAllLanguages) and
  the slide transition call renderCanvasNow() directly, so they never read a
  stale canvas now that updateCanvas is deferred; batch-export per-frame wait
  cut from 100ms to 16ms.

Net effect while dragging a slider: ~1 main render per frame and zero
side-preview renders, instead of 5+ renders multiplied by every input event.

https://claude.ai/code/session_01Q5Cd4M127ug84nhGjkiyLe
Optimize preview rendering — fix UI lag while editing
Closes every path found where an uploaded image could silently vanish, and
makes the cache-vs-server policy explicit: THE SERVER IS THE SOURCE OF TRUTH.
The browser cache never argues with it — the user's own uploads are protected
by a dedicated ledger instead of cache-vs-server heuristics.

Pending-upload ledger (app.js, language-utils.js):
- Every imported image (upload, per-language, folder, replace, desktop) is
  recorded in IndexedDB and removed ONLY once the server has confirmed both the
  blob and the record referencing it. Survives tab close.
- Whenever the app adopts the server's copy (SSE update, freshness reload, push
  conflict 409, startup sync), pending entries are re-applied into the fresh
  server record and re-pushed. Previously a 409 reload silently DISCARDED any
  local images whose push hadn't landed — the main "file disappears" path when
  Claude/MCP wrote concurrently.
- Server-truth rule: an entry only fills an EMPTY slot; a slot the server has
  since filled wins — except entries fresher than 10 min (the user's own
  replacement, their latest intent). 7-day max age. Startup resumes interrupted
  uploads for every project, even unopened ones.

Server-wins sync (app.js):
- syncWithRemote rewritten: server content replaces the cache whenever revisions
  differ (newer OR older); equal revision skips the download. The count
  heuristics ("richer local repairs the server") are gone — they could resurrect
  stale state.
- Tombstones: deleting a project records it in .tombstones.json (30-day
  retention, cleared by an explicit re-create/save). The client checks GET
  /tombstones before migrating cache-only projects, so a stale browser cache can
  no longer resurrect a deleted project; tombstoned cache entries are dropped.

Server-side reference verification + self-heal (server.ts, projectstore.ts, app.js):
- Every record PUT is verified: the response reports imageRefs/missingRefs (any
  referenced blob not on disk, after attempting restore from the GC trash).
- The client re-uploads missing blobs from bytes it still holds (current push
  map, session blob cache) and shows a loud warning naming the affected
  screenshots/languages for anything it cannot repair. Silence was how images
  used to disappear.
- blobExists() now restores from the GC trash, so blobs/check and verification
  never report a recoverable blob as missing.
- Dotfiles (.tombstones.json) excluded from project scans (no ghost projects).

Upload robustness (app.js, appstore-features.js):
- processImageFile/processDesktopImageFile had NO onerror: one undecodable file
  (HEIC, corrupt) left the promise pending forever and silently swallowed every
  remaining file of the batch. All import paths now resolve on failure and
  report the failed filenames in an alert.
- Push failures retry automatically with backoff (5s→60s, 5 attempts) from the
  freshest cached copy; a final failure shows "modifications NON enregistrées".
- beforeunload warns while a push is queued/in-flight or uploads are unconfirmed.

Tested: server e2e (missingRefs reported then clean after upload; blobs/check
restores from trash; tombstone on delete, cleared on re-create; no ghost
project); ledger apply logic (6 scenarios: empty-slot fill, stale-vs-filled
server wins, fresh replacement wins, screenshot recreation from defaults,
age-out, applied-stays-pending).

https://claude.ai/code/session_01Q5Cd4M127ug84nhGjkiyLe
Uploads can never silently vanish; server is the source of truth
The server is the only storage that counts: a project is on the server or it
doesn't exist. The browser keeps an internal mirror purely as plumbing, so the
UI must not advertise a "local cache" state — and an upload must visibly block
until the server has confirmed it.

UI cleanup:
- Project dropdown badges ("✓ sur disque" / "cache local") removed, along with
  their CSS. The screenshot count remains.
- Status messages reworded: "Enregistré sur le serveur" (was "sur le disque");
  unreachable server now says "Serveur injoignable — modifications non
  enregistrées" instead of advertising a local-cache fallback.

Blocking upload overlay (all import paths):
- New full-screen modal (spinner + status + detail) shown from the moment files
  enter the app and held until the SERVER confirms the save. Covers: file
  picker/drag-drop batches, desktop (Tauri) imports, per-language uploads,
  fastlane folder imports, screenshot replacement, and background images.
- Push machinery refactored: _executeScheduledPush (shared outcome handling),
  flushRemotePushNow (fire the debounced push immediately, awaitable) and
  waitForServerConfirmation (loops a few rounds so a 409 reload's follow-up
  push — pending uploads re-applied — is awaited too).
- The overlay mirrors live push progress ("Envoi des images… 3/12",
  "Enregistrement du projet…") via setSyncStatus.
- It sits below the duplicate-image dialog (z-index 999 < 1000) so that choice
  stays clickable during an import.
- Decode failures during replace/background uploads are now reported too.

https://claude.ai/code/session_01Q5Cd4M127ug84nhGjkiyLe
Remove "cache local" from the UI + blocking upload overlay until server confirmation
"C'est soit sur le serveur soit ça ne l'est pas du tout": projects now live
exclusively on the MCP server's disk. The browser holds the open project in
memory and pushes every change; nothing project-related persists browser-side
anymore (only the current-project id, in localStorage). Net: -225 lines.

- Deleted: openDatabase, the projects/meta IndexedDB stores, idbGet/Put/Delete
  helpers, refifyForIdb (records no longer stored locally), persistCommittedRev
  (rev lives in memory: currentProjectRev/remoteRev). The legacy database is
  deleted at startup (one-time cleanup).
- loadState(injectedRecord?) fetches the open project from the server (or uses
  the record reloadProjectFromServer already fetched — no double download).
  The stale-token guard is preserved.
- syncWithRemote() is now just "load the list from the only store": server list
  replaces the in-memory list; session-only projects are kept only while the
  editor holds content for them or work is queued (their next save creates them
  server-side). Client-side tombstone handling is gone — without a cache there
  is nothing to resurrect (the server endpoint stays).
- Push-retry rebuilds from the live editor state (saveState) for the open
  project, or re-uses the snapshot for background pushes.
- duplicateProject / backup export / backup import all go through the server
  (duplicate shares content-addressed blobs — no image bytes travel). Legacy
  full-IndexedDB dumps import each project record to the server.
- SSE "deleted" now removes the project from the list live, switching away if
  it was open (server truth, applied instantly).
- Pending-upload ledger is in-memory (the blocking upload overlay + beforeunload
  warning cover the tab-close window).
- No server configured → explicit "Aucun serveur configuré — rien ne sera
  enregistré" (the editor still works for the session, exports stay possible).
- CLAUDE.md updated to describe the server-only architecture.

https://claude.ai/code/session_01Q5Cd4M127ug84nhGjkiyLe
Server-only storage: remove IndexedDB entirely
A big backup failed with the generic "le serveur n'a pas accepté le projet".
Root cause: the import inlined EVERY image into one record (inlineImages →
~660MB of base64 strings), then RemoteStore.put deep-cloned it again in
externalizeForUpload → browser OOM, swallowed into a useless message.

Rewrite the import to stream: walk the manifest, and for each image reference
(zip: entry or legacy inline data URL) read just that one image's bytes, upload
it as a content-addressed binary blob, and replace it with an "appdisk://" ref.
Memory is bounded to one image at a time; the final record PUT carries refs only
(a few KB), so it can't hit the 50MB JSON limit and never builds a giant object.

- New importRecordToServer(data, zip, label): collect image sources, upload each
  via PUT /projects/:id/blobs/<sha256[:40]>.<ext> (binary), replace in place,
  then PUT the tiny refs record. Used by both the backup and legacy-dump paths.
- Real errors surfaced: HTTP status with explicit 413 hints ("image > 60 Mo",
  "record trop volumineux") instead of a generic failure.
- Import is refused up front when no server is configured (server-only storage).

Verified against the live server: 200 image blobs uploaded + a 25KB refs-only
record accepted, imageRefs=200, missingRefs=0.

https://claude.ai/code/session_01Q5Cd4M127ug84nhGjkiyLe
Fix large project import (OOM) — stream images to blobs
…resumable import

The import hung at "123/195" then a refresh showed ZERO projects. Diagnosis: the
MCP container's project store is not on a mounted volume, and CI rebuilt the MCP
image on EVERY merge to main — so Watchtower restarted the MCP container minutes
after a web-only merge, killing the in-flight upload (fetch with no timeout =
infinite hang) and wiping the ephemeral store.

Three layers of fix:

1. Server persistence guard (REQUIRE_PERSISTENT_STORE=1, default in the Docker
   image): at startup the server detects a store that is NOT on a mounted volume
   (same st_dev as the container root) and refuses ALL writes with HTTP 507 and
   an explicit French message, instead of accepting data the next redeploy would
   destroy. /health reports it; startup log says FATAL. Dev/stdio unaffected
   (flag absent). Verified: 507 on record+blob writes when ephemeral, 200 when
   the guard is off.

2. CI path filters: docker-publish-mcp.yml only triggers on mcp-server/**
   changes; docker-publish.yml only on web files. Web-only merges no longer
   restart the MCP server (which used to kill uploads and SSE streams for
   nothing).

3. Import robustness (app.js):
   - fetchRetry: every import request now has a hard timeout (90s) + 3 attempts
     with backoff — a stalled upload FAILS with a clear message instead of
     hanging the overlay forever. 413/507 are not retried (permanent).
   - Resumable: images are hashed first (bytes discarded, re-read from the ZIP
     at upload time — still one image in memory at a time), then blobs/check
     tells which blobs already exist: a re-import after a failure skips
     everything already uploaded.
   - Explicit error hints for 413/507 (storage not persistent, file too big).

https://claude.ai/code/session_01Q5Cd4M127ug84nhGjkiyLe
Persistence guard + CI path filters + resumable import
…age)

The MCP server had NO batch upload — every image went in its own
PUT /projects/:id/blobs/:name. Importing/saving a 195-image project meant 195
sequential round-trips (slow and fragile over the Cloudflare tunnel, and the
likely reason imports stalled).

Server (server.ts): new POST /projects/:id/blobs/batch. Binary framing, no
base64 bloat: [uint32 BE headerLen][JSON [{name,size},…]][blob bytes…]. Each
blob is content-addressed and hash-verified exactly like the single PUT; the
response reports stored[] + per-blob errors[]. 110 MB body limit (client caps
batches well under Cloudflare's 100 MB). Refuses writes (507) on an ephemeral
store like the other write routes.

Client (app.js):
- RemoteStore.uploadBlobs(id, items, onProgress): groups blobs into batches
  (≤40 MB / ≤40 images each) → a handful of requests instead of hundreds, with
  a per-blob PUT fallback when the server is older (404/405 → 'no-batch-endpoint').
- _put (normal save) now uploads missing blobs through it.
- Import pass-2 batches too, re-reading bytes from the ZIP per batch so peak
  memory stays ~one batch, never the whole project; resume (blobs/check) and the
  90s-timeout fetch are preserved.

Forward/backward compatible: new client + old server falls back to per-blob;
old client + new server still uses the single PUT.

Verified e2e: 5-blob batch stored, missing→0 after, byte-exact round-trip,
and a hash-mismatched entry rejected while valid ones succeed.

https://claude.ai/code/session_01Q5Cd4M127ug84nhGjkiyLe
Batch image upload (many blobs per request)
…copy (#72)

renderPerScreenTextUI captured screenshot.text once, but getText()
reassigns screenshot.text to a fresh object on every render, so the
toggle/field handlers were mutating an orphaned object the renderer no
longer read — typed panel text never appeared and the toggle reverted.
Each handler now re-fetches the live object via getTextSettings().

Also store/read per-screen subheadlines under the subheadline language
key (matching makePanelTxt) instead of the headline language, so they
survive when the two languages differ.

https://claude.ai/code/session_01MdKQ535A9VpJfHff3Yv5xk

Co-authored-by: Claude <noreply@anthropic.com>
* Fix per-screen panorama text: write to live text object, not a stale copy

renderPerScreenTextUI captured screenshot.text once, but getText()
reassigns screenshot.text to a fresh object on every render, so the
toggle/field handlers were mutating an orphaned object the renderer no
longer read — typed panel text never appeared and the toggle reverted.
Each handler now re-fetches the live object via getTextSettings().

Also store/read per-screen subheadlines under the subheadline language
key (matching makePanelTxt) instead of the headline language, so they
survive when the two languages differ.

https://claude.ai/code/session_01MdKQ535A9VpJfHff3Yv5xk

* Per-screen panorama text: full independent styling per panel

When "Text per screen" is on, each panorama panel now owns a complete
text object (panelTexts[]) — headline, subheadline AND all styling
(font, size, color, weight, position, offset, line height, text
background). The Text tab gains a Screen 1/2/… selector at the top; the
whole tab edits the selected panel.

- getText()/setTextSetting redirect to the active panel when per-screen
  mode is active; the renderer reads the container via getContainerText()
  and draws each panel from its own object (makePanelTxt).
- Panels follow the current language selection (edit + render).
- Enabling seeds panelTexts from current settings and migrates legacy
  panelHeadlines/panelSubheadlines content; old projects still render
  via a fallback path.
- panelTexts round-trips through the MCP server unchanged; tool docs
  updated.

https://claude.ai/code/session_01MdKQ535A9VpJfHff3Yv5xk

---------

Co-authored-by: Claude <noreply@anthropic.com>
* Fix per-screen panorama text: write to live text object, not a stale copy

renderPerScreenTextUI captured screenshot.text once, but getText()
reassigns screenshot.text to a fresh object on every render, so the
toggle/field handlers were mutating an orphaned object the renderer no
longer read — typed panel text never appeared and the toggle reverted.
Each handler now re-fetches the live object via getTextSettings().

Also store/read per-screen subheadlines under the subheadline language
key (matching makePanelTxt) instead of the headline language, so they
survive when the two languages differ.

https://claude.ai/code/session_01MdKQ535A9VpJfHff3Yv5xk

* Per-screen panorama text: full independent styling per panel

When "Text per screen" is on, each panorama panel now owns a complete
text object (panelTexts[]) — headline, subheadline AND all styling
(font, size, color, weight, position, offset, line height, text
background). The Text tab gains a Screen 1/2/… selector at the top; the
whole tab edits the selected panel.

- getText()/setTextSetting redirect to the active panel when per-screen
  mode is active; the renderer reads the container via getContainerText()
  and draws each panel from its own object (makePanelTxt).
- Panels follow the current language selection (edit + render).
- Enabling seeds panelTexts from current settings and migrates legacy
  panelHeadlines/panelSubheadlines content; old projects still render
  via a fallback path.
- panelTexts round-trips through the MCP server unchanged; tool docs
  updated.

https://claude.ai/code/session_01MdKQ535A9VpJfHff3Yv5xk

* Add "copy layout from another project" (text layout + device placement)

New toolbar button + modal to copy text styling/layout and device
placement/size from another project onto the current one, matched
screenshot-by-screenshot by position (e.g. reuse an iPhone presentation
for an iPad project).

- Reads the source project via RemoteStore.get() without switching away;
  only the current (target) project's in-memory state is mutated, then
  saved through the normal path.
- Preserves the target's text CONTENT and images — only styling/layout
  and device placement are copied (mirrors the existing per-view design
  copy semantics). Handles per-screen panorama styling per panel.
- Excludes the device-model selectors (deviceModel2D/use3D/device3D) so
  copying an iPhone layout onto an iPad project keeps the iPad device.
- Optional checkboxes to copy text, device, or both.

https://claude.ai/code/session_01MdKQ535A9VpJfHff3Yv5xk

---------

Co-authored-by: Claude <noreply@anthropic.com>
Auto-fit text was a single global toggle (persisted in localStorage).
It is now stored per screenshot (text.autoFit):

- Each screen has its own autoFit flag, edited via the Text-tab toggle
  which now reflects/writes the currently selected screen.
- Renderer reads txt.autoFit instead of the global state.autoFitText.
- New screens (and thus new projects) default to autoFit on.
- Existing projects migrate from the legacy global flag, so projects
  that had auto-fit enabled keep it on for every screen.
- All-languages view badge and overlap status use the per-screen flag.

https://claude.ai/code/session_012x9ZSgPU3ehv4FfvxbBP4p
Make Auto-fit text a per-screen setting
flocom and others added 10 commits June 19, 2026 07:55
Auto-fit previously only shrank text to avoid the device image and
bailed out entirely when no device image was present, so tall wrapped
text could overflow the canvas edge and get clipped. Bound the
available height by both the device rect and the canvas edge so the
font scales down until the text fits within the frame.


Claude-Session: https://claude.ai/code/session_01WFTC7aoAtiyaGRc5pF3AY5

Co-authored-by: Claude <noreply@anthropic.com>
Add a per-screenshot "Text zone" to the Text tab: an optional rectangle the
headline + subheadline are confined to. When enabled, the font size is reduced
automatically so the whole text fits inside the zone — entirely, centered, and
without breaking any word across lines.

- State: textZone {enabled,x,y,width,height} in text defaults + normalization
- Rendering: computeZoneFit/maxWordWidth shrink to fit zone height and keep the
  widest word on one line; drawTextToContext/textVerticalExtent confine wrap
  width and center the block in the zone
- UI: toggle + X/Y/W/H sliders and a draggable/resizable dashed overlay on the
  preview (DOM-only, never part of exports)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5pzx2vkZB8DN2dDy4YRNH
The zone auto-fit shrank each language independently, so a longer
translation could overflow the box even when the edited language fit.
Add a "Same size for all languages" mode (on by default): the font is
reduced to the size that fits the most-constrained project language and
applied uniformly, so the text stays inside the zone — entirely and
without breaking a word — in every language, with consistent sizing.

- app.js: split computeZoneFit into per-language zoneFitForText + a
  uniform pass over state.projectLanguages (min scale); textZone.uniform
  flag + normalization; toggle wiring/sync
- index.html / styles.css: "Same size for all languages" toggle

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5pzx2vkZB8DN2dDy4YRNH
Make text-zone auto-fit respect every language
The zone auto-fit only shrank the text, so it could stay on one line and
never wrap to use the box. Now the font grows or shrinks so the wrapped
block fills the zone: short text grows and wraps to fill the box, long
text shrinks — always within the zone height and width, and never
breaking a word across lines.

- app.js: zoneFitForText searches [0.1, 6]x for the largest wrapped block
  that fits the zone height, capped by the widest word fitting the zone
  width (prevents mid-word breaks and bounds growth)
- index.html: zone hint updated to "auto-size and wrap to fill the box"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5pzx2vkZB8DN2dDy4YRNH
Make text zone fill the box (grow + wrap)
"All App Store" only added 39 of Apple's 50 App Store localizations.
Add the 11 missing ones so the button — and the add-language menu, which
share the same source — cover the full set: Bangla, Gujarati, Kannada,
Malayalam, Marathi, Odia, Punjabi, Slovenian, Tamil, Telugu, Urdu. Also
relabel 'es' to "Spanish (Spain)" to match Apple.

- app.js: add the 11 missing entries (with flags) to languageNames /
  languageFlags, kept in sync (single source for the button + menu)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5pzx2vkZB8DN2dDy4YRNH
Complete the App Store language list (50 languages)
* Add Mac to 2D device models and output sizes

Adds a "Mac" option alongside iPhone, iPad and Samsung in the 2D device
model selector. Mac renders as an iMac/Studio Display style shell (thin
bezel, aluminium chin with logo dimple, neck and stand) and adds the
standard Mac App Store output sizes (2880x1800, 2560x1600, 1440x900,
1280x800). Mirrors the new sizes server-side for rendering/list_output_sizes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDE1DGRBGFMS4BVHAqzXtu

* Improve 2D Mac mockup rendering

Redraws the Mac shell with brushed-aluminium gradients on the chin, a
tapered neck and a flat foot, plus a sheen line and metallic outer rim,
for a cleaner modern-iMac look in 2D.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDE1DGRBGFMS4BVHAqzXtu

* Render Mac as MacBook Pro instead of iMac in 2D

Replaces the iMac stand/chin with a MacBook Pro shell: thin-bezel lid,
dark hinge, and an aluminium base shown in slight perspective (wider at
the front) with a centred finger-groove and metallic sheen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDE1DGRBGFMS4BVHAqzXtu

* Upgrade 2D MacBook Pro mockup to a more realistic render

Reworks drawMacBezel: an aluminium-rimmed lid with a dark bezel frame
(screenshot shown through an even-odd cutout), a camera dot, a lit
"open-lid" deck sliver, a two-tone front lip with finger-groove, and a
soft contact shadow grounding the machine. Selected from several
candidate renders judged for realism and elegance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDE1DGRBGFMS4BVHAqzXtu

* Add selectable Space Black finish for the Mac mockup

Adds a per-screenshot deviceMacFinish setting ('silver' | 'space-black')
with a Silver/Space Black selector shown when the Mac model is active.
drawMacBezel now takes a finish and renders from a per-finish aluminium
palette; the dark bezel stays black on both. Mirrors the default
server-side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDE1DGRBGFMS4BVHAqzXtu

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants