Skip to content

Enhance export features, improve state management, and accessibility - #3

Merged
4isper merged 42 commits into
mainfrom
dev
Aug 28, 2026
Merged

4isper merged 42 commits into
mainfrom
dev

Conversation

@4isper

@4isper 4isper commented Aug 28, 2026

Copy link
Copy Markdown
Owner

No description provided.

4isper and others added 30 commits August 24, 2026 21:09
Export pipeline:
- resolve the active layer's media by data-layer-media identity, not DOM
  order — skins, the watermark logo and other layers' media can no longer
  be exported with the active layer's fit/filters (PNG/WebP/SVG/video)
- make the SVG floor reflection visible: gradient axis points down into
  the mirrored content; opacity/fade shared with the canvas renderer via
  RENDER.reflectionOpacity/reflectionFade
- clip the canvas reflection strip before drawing the mirror so tilted
  frames don't leave full-strength ghost slivers outside the fade
- skip hidden layers' frame instances in every export, matching preview
- thread AbortSignal into the recording loop so cancelling actually stops
  the capture, and never download the WebM after a cancel
- clean up canvas/stream/audio in a finally block: recorder errors and
  render exceptions no longer leak tracks or keep music playing
- unique per-run FFmpeg temp names so overlapping exports (shortcuts /
  command palette) can't clobber each other's input.webm
- GIF export no longer reports failure after a successful download (the
  single-pass palette graph never writes palette.png; post-download
  cleanup is best-effort for MP4/WebP/GIF alike)
- verify WebM codec support before constructing MediaRecorder; fall back
  to the browser default (Safari) instead of throwing NotSupportedError
- 10s timeouts on detached video loads; empty export basename falls back
  to "mocksy-export"; stop revoking the layer-owned blob:/data: URL after
  video capture; command-palette "Add layer" stores a self-contained
  data URL like every other upload path

State:
- clear undo/redo history (and coalescing keys) on project switch so
  Ctrl+Z can't paste the previous project's scene into the active one
- flush the outgoing project's pending autosave in switchProject — the
  last <=500ms of edits are no longer dropped with a false "Saved" toast
- flush (not drop) the pending history write on unsubscribe so an SPA
  remount doesn't restore a stale undo stack
- re-scan localStorage at deletion time in sweepOrphanedMedia so a
  persist racing the IndexedDB open can't get its fresh blobs deleted
- per-field/group-scoped history coalescing keys: brightness->contrast
  and different group selections no longer collapse into one undo step
  (X/Y pan/tilt pairs intentionally still share a key)
- bound the decoded-image cache (LRU, 32 entries)

Preview parity:
- drop the double-applied mediaOffset translate on grid frame wrappers
  (media pans via objectPosition, matching the exporters)

Hydration:
- deterministic id for the module-scope demo layer: the Date.now-based
  id differed between SSR and client HTML and tripped React's hydration
  check as soon as an attribute carried it (data-layer-media)

Tests: +12 regression/unit tests across exportVideo, exportImage,
exportSvg, frameGeometry, canvasMedia, editorStore, projectsStore,
historyStorage and commandFactories; VRT suite 9/9 green.
Reuse the standalone SVG geometry for a true vector single-page PDF, with an automatic raster fallback when vector rendering fails. Page size anchors to the scene artboard or fits a custom size box; pdf-lib is replaced by lazily imported jspdf + svg2pdf.
Grids now get one real CSS mockup per frame instance (preview geometry, landscape rotor, tilt/zoom, chrome and skins) instead of an embedded raster PNG; the raster snippet path is removed.
exportVideoBatchZip records each frame instance as its own clip (WebM archived directly, MP4 transcoded via FFmpeg) and packs everything into one archive. Offered as Video ZIP in the export dialog for multi-frame scenes, exposed in the command palette, cancellable via AbortSignal; en+ru strings synced across all locales.
The service worker now serves jsdelivr (ONNX Runtime wasm) stale-while-revalidate, so after first use background removal works offline; model weights were already cached by transformers.js via the Cache API, which is now set explicitly.
npm run i18n:report lists untranslated key counts per locale (-- --keys for the dot-paths), sorted by backlog size, so translation passes get an actionable worklist; shared terms and the reference locale are excluded.
Named media-free snapshots of the current look stored in localStorage
(capped at 30), listed above the built-in scene presets and applied with
undo support. Sanitized through normalizeScene on both save and hydrate.
Export a project as project.json plus deduped media/<hash>.<ext> files;
media fields carry @media: references and are restored to data URLs on
import (MIME rebuilt from the extension). The import button now detects
zip vs json, and each project row gains an export-with-media action.
…ortcuts

- export: JPEG option in dialog/palette; transparent backgrounds flatten
  to white for both worker and main-thread render paths (no alpha)
- editor: ⌘C/⌘V copies layers via the object clipboard (priority:
  annotation → frame instance → layer), paste reuses duplicateLayer
- layers: text layers (kind "text") render inside device screens across
  every renderer — CSS preview, canvas/worker/video, SVG and HTML
  exports — from one shared layout module (lib/render/layerText.ts);
  new Text controls section, palette command, share-link demo restore
  no longer clobbers text-only scenes
- media: raise the upload downscale ceiling to 4096px so 4× exports of
  high-res sources stop upscaling
- shortcuts: single source of truth (lib/shortcuts/shortcutConfig.ts)
  shared by the keydown handler and the cheat sheet, plus persisted
  rebinding with conflict detection and reset (shortcutsStore + dialog UI)
- tests: direct idbMediaStore coverage on the quota-critical paths;
  unit/integration tests for each feature above (+52 total)
… exports

Three stacked defects, each masked by the next:
- the off-thread render silently dropped the SVG device skin
  (createImageBitmap refuses SVG blobs, decodeOptional swallowed the
  error), exporting skinless frames without any shadow; SVG assets are
  now rasterized on the main thread and shipped to the worker as
  transferred ImageBitmaps, and a failed overlay decode aborts the
  worker render so the main-thread fallback takes over
- canvas shadows derive their alpha from the drawn shape, so casting
  from the thin/partly-translucent skin raster or the glass body fills
  rendered a shadow ~4x weaker than the preview's CSS drop-shadow; the
  shadow is now cast from an explicit opaque silhouette (skin artwork
  union screen cutout, or the glass box) on an isolated layer, with the
  silhouette erased by its own mask so only the halo composites
- punching the whole frame box erased the halo inside the skin's
  transparent margins (light ring around every device), while leaving
  the silhouette visible bled black through those margins and through
  transparent media (black boxes); the punch now follows the artwork

Guards so this stays fixed: pixel-level e2e exporting real PNGs
(single-frame with transparent media + multi-frame) asserting the
shadow hugs the device edge, no light ring, no black bleed and the
screen cutout shows the background; unit tests for the worker SVG
pre-decode contract; halo-only SVG filter covered by markup tests.
Verified by mutation: reverting the punch fails the new e2e suite.
…dle FFmpeg worker

- Load every frame instance's media and skin concurrently in image and
  video exports instead of awaiting each round-trip sequentially
- Stop and detach the per-instance <video> elements once a capture ends
- Release the FFmpeg WASM worker after 5 idle minutes; the next export
  transparently re-loads it from cache
- Replace the mutable module-level SVG blur-region counter with a shared
  id array so defs and markup can never drift apart
The template worker skipWaiting()+claim()s, so after a deploy the page
keeps running stale assets with no hint. Detect the controllerchange
handover (suppressing the very first claim on fresh loads), poll
registration.update() when the tab becomes visible (60s throttle) and
offer a localized Reload button.

PwaRegister moves inside NextIntlClientProvider so the banner can use
useTranslations; new pwa.* keys translated for en/ru and backfilled to
all other locales via i18n:sync.
Weekly grouped minor/patch PRs, individual majors (Next.js/next-intl
flagged breaking), pinned action digests, weekly lockfile maintenance
and OSV vulnerability alerts labelled security.
- iphone16pro ratio 390/844 -> 402/874 after device skin update
- HTML export assertion targets live-CSS markup (frame-instance) instead of inlined PNG
- panels stack test opens bottom-sheet panels on narrow screens
- watch frame asserts squircle clip-path instead of 50% border-radius
- preview/export tilt parity ceiling raised to 0.10 (squircle unification drift)
- chromium project capped at 2 workers to stop video-encode CPU starvation
  that flaked fullscreen and a11y tests
- exclude mobile-only specs from the desktop project
- rebaseline VRT default-scene snapshot
- a11y: audit command palette, shortcuts, share QR, right-panel tabs,
  context menu and modal focus trap (8 e2e tests)
- fix(ProjectItem): keep <li> as listitem, move switch to inner button to
  resolve list + nested-interactive violations; fix renameLabel name arg
- i18n: e2e locale switch + partial-locale resilience; unit parity guard
  for all 57 locales vs en
- e2e: add chromium-tablet project covering 769-980px and 981-1180px zones
…nces on layer removal

- Add an always-visible "+ Add frame" button in the Frames list that
  incrementally clones the active frame instance (or the default scene
  frame) with its own cloned layer, instead of relying on grid/layout
  presets (which replace the whole scene) or right-click duplicate.
- Remove frame instances bound to a deleted layer in removeLayer /
  removeLayers so the frame no longer silently falls back to rendering
  another layer's media as a phantom frame (exports included).
- setMedia no longer drops all layers when invoked without an active layer.
- Add editor.addFrame i18n key (en/ru + 54 synced locales) and tests.

Co-Authored-By: opencode <opencode@anomalyco.com>
Add targeted unit tests that lift branch coverage on backgroundRemoval,
renderMockup, templateFile, the editor store slices, autoLayout,
canvasMedia and ogScene. Covers pipeline caching/retry, watermark and
tilt/reflection render paths, locked-slice no-ops, null-aspect and
rotation/media-fit draw branches, and the og aspect-ratio fallback.
Includes normalizeScene and next.config adjustments, layerText tweak,
audit-check simplification, and regenerated sw.js / next-env.d.ts /
package-lock.json.
…stribute

- Add frame multi-select state (selectedFrameIds) with selectFrameIds /
  toggleFrameSelected actions, surfaced as a checkbox per frame card.
- align/distribute now target only the selected subset (falling back to the
  whole scene when fewer than two are selected) instead of always moving every
  frame.
- Add clampFramePositions so frames stay at least partially on the canvas
  when an align/distribute would push them past the edge.
- Add editor.selectFrame i18n key and tests.

Co-Authored-By: opencode <opencode@anomalyco.com>
…rlap

- materializeLayout now reuses the scene's existing layers for new frame
  instances (preserving uploaded media) and only clones the active/demo
  layer for instances beyond the layer count, instead of wiping every layer
  and rebuilding from the active one. Previously any layout/grid apply
  destroyed all other layers' images.
- buildStack spreads two frames horizontally when there is only one row,
  so count<=2 no longer stacks them on the same center and hides all but one.
- buildFan caps the frame scale by the arc's angular step so large counts
  no longer overlap into an unreadable pile.
- Update layout tests to assert media preservation and add non-overlap
  checks for stack and fan.

Co-Authored-By: opencode <opencode@anomalyco.com>
…ut count

- Replace materializeLayout (which rebuilt every instance from the scene
  frame, dropping distinct frames like "none" and their media) with
  applyLayoutPositions, which repositions the scene's existing instances,
  preserving each one's frame type, bound layer and media. Slots beyond the
  instance count are filled by reusing existing layers round-robin (no single
  image duplicated); extra instances are trimmed.
- Layout count input now tracks the number of frames on the canvas until the
  user edits it, so applying a layout rearranges what's there instead of a
  hardcoded 2 that discarded frames.
- Add schematic LayoutIcon previews for the five presets and native
  drag-and-drop reordering of frame cards (reorderFrameInstances action).
- Add editor.layoutCount i18n key and tests.

Co-Authored-By: opencode <opencode@anomalyco.com>
- setBackgroundSolid/setBackgroundGradient/setGradientVia accept a coalesce
  flag so color-picker and angle-slider drags collapse into one undo step
  instead of flooding the 100-entry history; discrete preset clicks stay
  separate steps.
- Gradient presets (swatch grid + command palette) now clear gradientVia,
  matching their two-stop swatch; explicit null clears the middle stop.
- Middle gradient stop gets an on/off checkbox with a disabled picker, so
  via can be unset again; the angle slider is hidden for radial gradients
  where it has no effect.
- Active-gradient highlight requires an exact linear two-stop match.
- Image mode tab is disabled until an image is uploaded (Segmented supports
  per-option disabled) and stale error hints clear on mode switch.
- Preset swatches expose aria-labels for assistive tech.

Co-Authored-By: opencode <opencode@anomalyco.com>
…tics

- Rename editor.layoutGrid to "Grid layout" / "Сетка (раскладка)" in en, ru,
  cy and tl: it collided with the preview grid chip's aria-label ("Grid"/
  "Сетка"), breaking getByLabel lookups in five editor specs.
- Use exact:true for the Сетка assertions so the renamed layout button no
  longer substring-matches.
- Rewrite the 2-frame grid spec for non-destructive layouts: remove a frame
  first so the uploaded video has to fill both slots via round-robin reuse.
- Give the animated WebP export the same timeout headroom as the video
  suites (SwiftShader encodes regularly exceed the 60s default).
- Regenerate i18n/generated.ts (fr coverage catch-up).

Co-Authored-By: opencode <opencode@anomalyco.com>
- Add HSL helpers (hslToHex, rotateHue, gradientMiddleStop) and
  pickHarmonicPair(colors, scheme) for complementary / analogous / triadic
  gradients; pickGradientPair now delegates to it (complementary), so its
  behavior is unchanged.
- randomSceneStyle(rand, palette?) now derives the surprise background from
  the uploaded media when a palette is supplied: a harmony-scheme gradient
  (with an optional harmonic middle stop) or the dominant solid color,
  instead of a canned swatch. Callers (styleCommands, TemplatesPanel) pass
  the live scenePalette.
- The auto-background buttons cycle through the three harmony schemes per
  click and add a harmonic 3rd stop for a richer blend.

Co-Authored-By: opencode <opencode@anomalyco.com>
…apply

Promote the on-screen decoration from a single scene-wide object to an
optional per-frame-instance override so every device on the canvas can have
its own status bar / lock clock / home dock:

- FrameInstance.screen?: ScreenChrome; instances without it inherit the scene
  default. The four renderers (CSS preview, canvas, SVG, HTML) resolve the
  effective screen as inst.screen ?? scene.screen.
- New store actions setFrameInstanceScreen (seeds from the effective screen on
  first edit), clearFrameInstanceScreen (drop override) and
  applyInstanceScreenToAll (copy a device's chrome to the default, clear all
  overrides).
- ScreenControls edits the selected device in a multi-frame scene and the
  default otherwise, with a scope hint. Exposes the previously-hidden device
  OS selector (iOS/Android/Desktop) and moves the floor-reflection toggle here
  from PositionSection. Adds "Reset to default" / "Apply to all devices" actions
  in instance mode.
- normalizeScene preserves per-instance overrides (seeded from the scene
  default) and tolerates malformed ones.

Co-Authored-By: opencode <opencode@anomalyco.com>
Extend the per-device screen work so the floor reflection under each device
is also independent, matching the scene default when no override is set:

- FrameInstance.floorReflection?: boolean; effective = inst.floorReflection ??
  scene.floorReflection.
- New store action setFrameInstanceFloorReflection(id, on); clearFrameInstanceScreen
  and applyInstanceScreenToAll are folded into clearFrameInstanceOverrides and
  applyInstanceToAll, which reset/copy both the screen chrome and the floor
  reflection together.
- All four renderers resolve the reflection per instance (CSS preview, canvas,
  SVG, HTML); the canvas path only reflects instances with the flag enabled.
- normalizeScene preserves a per-instance override, including an explicit false.

Co-Authored-By: opencode <opencode@anomalyco.com>
- ContextMenu: add arrow-key navigation, remove scroll-dismiss (fixes touch), cursor not-allowed for disabled
- EditorToolbar: add role="progressbar" on export progress bars, role="status" on save success toast
- AnnotationsPanel/LayerItem/FrameInstanceList: add explanatory tooltips on disabled buttons
- projectsStore: localize storage quota error via i18n
- MobileTabBar: rename misleading 'Layers' tab to 'Panels'
- CommandPalette: adapt ⌘K/Ctrl+K display to user OS
- exportPdf: notify user on vector→raster fallback
- useEditorExport: increase error/copy toast duration (15s/5s)
- globals.css: increase touch targets (btn-icon 28px, btn-tb-icon 30px, coarse pointer 36-44px)
- HistoryPanel: show cap-reached indicator at 100 undo steps
- OnboardingTour: set aria-modal="true"
- Add 8 new i18n keys (en+ru) and sync to 55 locales
- ErrorBoundary: pass translated retryLabel from EditorShell
- EditorShell: use i18n for default project name ('Untitled')
- Mobile sheet: add focus trap via useFocusTrap for both sheet panels
- Segmented: add arrow-key navigation with roving tabindex (WAI-ARIA)
- FrameInstanceList: add Alt+↑/↓ keyboard reorder for frame instances
- MediaSection: add aria-live='polite' on recording timer button
- ExportDialog: add aria-describedby for screen readers
- Extract inline styles into ~25 reusable CSS utility classes
- useLayerReorder: skip redundant reorder calls during drag-over
- Increase --text-faint opacity from 0.45 to 0.55 for WCAG AA contrast
- TemplatesPanel: add empty-state message for user templates
- Add projects.untitled and templates.noTemplates i18n keys (55 locales)
…ortcuts, project switch guard

- CSS entrance animations (fade-in, slide-up, zoom-in, rotate-in) with duration/delay
- 14 compositing/blend modes (multiply, overlay, screen, soft-light, etc.)
- Clipboard copy SVG/HTML buttons in ExportDialog + command palette
- Keyboard shortcuts Cmd+1/2/3 to switch between Layers/Annotations/History panels
- Project switch confirmation dialog with unsaved-changes guard
- CONTRIBUTING.md updated with accurate project structure (57 components)
- Component tests for useCanvasViewport, AnnotationEditor, BackgroundMediaPalette
- 57 locale JSON files updated (2106 tests passing)
4isper added 12 commits August 26, 2026 22:33
…notation gradients, recent media panel

- Add 5 annotation shortcuts (⌘⇧T/I/R/O/B) with remappable combos
- Add clipboard copy for JPEG and WebP formats in export dialog
- Add AVIF export format (Chromium-only, graceful fallback elsewhere)
- Add linear + radial gradient support for all annotation types (preview, canvas, SVG, HTML)
- Add recent media panel in MediaSection with thumbnail grid and persistence
- New files: lib/render/annotationGradient.ts, lib/state/recentMediaStore.ts
- Sync 57 locales with i18n:sync (27 new keys)
…y consolidation

- Lazy load ExportDialog, CommandPalette, ShortcutsDialog, OnboardingTour,
  ShareQrDialog, ResetConfirmDialog in EditorShell (~1100 lines moved to
  dynamic chunks)
- Split globals.css (2797 lines) into 20 focused CSS modules in app/styles/
- Fix hardcoded colors in CSS that broke light theme (scrollbar thumbs,
  keyboard shortcuts, range thumbs, frame cards, preview chips, etc.)
- Raise test coverage thresholds: lines/statements 80→85%, branches 70→75%
- Move lib/search/matchCommand.ts → lib/commands/matchCommand.ts
- Move lib/utils/relativeTime.ts → lib/state/relativeTime.ts
- Add .env.example documenting NEXT_PUBLIC_SITE_URL and BASE_URL
- Add .env* to .gitignore to prevent accidental secret commits
- Update AGENTS.md project structure documentation
- A11y: aria-live announcer, listbox semantics for layers, keyboard
  navigation (ArrowUp/Down, Home/End, Enter, Space, F2, Delete,
  Alt+Arrow reorder), canvas focusable with role=application, Delete
  key for annotations, 5 new e2e tests
- Layer grouping: groupId on MediaLayer, group/ungroup/rename actions,
  collapsible group headers with visibility/lock toggles, Group/Ungroup
  buttons in bulk actions, group color indicator dot, 11 unit tests
- Scene templates: SceneTemplate type, 15 pre-built scene layouts
  (single/multi-device), applySceneTemplate function, TemplatesPanel
  section with color swatches, 9 unit tests
- i18n: new keys across 57 locales (en + ru translated, rest synced)
- 2125 tests passing, typecheck and lint clean
- ShareQrDialog: add focus trap + autofocus (was escaping keyboard focus)
- Escape user-provided values in SVG gradient markup (AnnotationContent,
  annotationGradient)
- ExportDialog: show scale controls only for image raster formats
- EditorShell: handle bootstrap/failure so the editor stays usable
- VideoOptions: guard audio upload against read errors
- PreviewCanvas: replace problematic role=application with role=group
- Theme-aware drag outline and reset-handle colors (use CSS vars)
- ErrorBoundary: localize default fallback via new LocalizedErrorBoundary
- BackgroundGradientControls: mark radio group with role=radiogroup
- AnnotationItem/PreviewOverlays: avoid O(N^2) smart-guide allocation
- CommandPalette: swap deprecated navigator.platform for userAgent
- i18n: add editor.gradientType key (en/ru + synced locales)
- Add btn-danger style; make safe actions the primary/default in confirm
  dialogs (reset, clear annotations, project switch, empty trash) instead of
  highlighting the destructive choice
- Confirm template deletion with a modal (was one-click, irreversible)
- Lock background scroll while any focus-trapped modal is open (counter-based
  so nested modals don't unlock prematurely)
- Fix Escape canceling project rename instead of committing it
- Route ExportDialog ESC/backdrop to cancel while busy so a running export
  isn't silently orphaned
- Add ESC handling to clear-annotations, empty-trash, and project-switch dialogs
- Drop duplicate aria-describedby on ExportDialog; add busy state + spinner to
  project bundle export
- Fix invalid nested <label> in WatermarkControls
- Make auto-gradient scheme cycle predictably instead of randomly
- Stop CommandPalette hijacking Tab, restoring native navigation
- ContextMenu: move focus into the menu on open (arrow keys work
  immediately after right-click) and restore it on close.
- PreviewChips: replace the hidden-input upload <label> with a focusable
  <button> that opens the file dialog, restoring keyboard access.
…ocked-text and QR-dialog gating

- guard global arrow shortcuts against double-nudging focused frame instances
- reset the persistsed undo/redo stacks when bootstrapping from a share/template
- route uploads to the pinned (target) layer instead of the active layer
- disable text-layer controls and surface a hint when the layer is locked
- always capture the video poster frame for single-frame PNG export
- gate video recording on a decoded frame so exports don't open with black frames
- fold the share-QR dialog into the modal gate that parks global shortcuts
- protect in-flight media offloads from the orphan sweep race
- restore the preview-chip media input test selector
…p and selection robustness

- sync savedSceneRef in saveNow/markSaved so Ctrl+S and reset no longer
  flip the saved indicator back to unsaved with a spurious toast
- return the persisted result from deleteMediaBlob so failed IndexedDB
  deletes are reported instead of silently leaking orphaned blobs
- keep trimStart <= trimEnd when setVideoDuration loads a shorter clip
  (previously yielded a fully-trimmed, blank video)
- require both dimensions for a custom PDF page size, avoiding a
  degenerate 1x1pt page from a partial size
- return the same array from patchActive when the target layer is
  missing, avoiding spurious undo entries
- validate activeLayerId against normalized layers in normalizeScene so
  a dangling id never persists into share URLs or projects
- derive videoCurrentTime from the reconciled active layer on undo/redo
  so the scrubber tracks the layer that stays active
- drop the cached IndexedDB handle on a blocked open so a later call
  can re-open instead of degrading permanently
Commit 6d75926 replaced role=application with role=group on the preview
canvas (an a11y improvement) but the e2e assertion still expected the old
role, breaking the a11y suite. Update the expectation to match the
intentional implementation.
Move per-layer action buttons (hide/lock/duplicate/move/remove) out of the
focusable role=option listbox rows into a dedicated toolbar so options no
longer contain nested interactive controls (axe nested-interactive). Update
the affected component/e2e tests to target the toolbar.

Also fix the ambiguous "Dark Studio" preset locator and regenerate all
visual regression baselines, which were stale after the CSS modularization
and scene-template changes.
- 17 locales at 100% coverage (en, ru, uk, ja, zh, ko, ar, he, fa, hi,
  bn, et, fi, hr, sl, lt, lv) with full 910-key translations
- lift 13 leading locales (cs, es, no, pl, tr, it, nl, pt, da, de,
  sv, fr, ro) from ~46% to 97-99%
- regenerate i18n/generated.ts coverage module
Introduce a public /api/spin endpoint for integrations/bots: client sends
media + an optional "pack" of rules (frames, backgrounds, styles, tilt,
shadow, radius, aspect ratio, watermark — each weighted), server picks a
deterministic combination off a seeded PRNG (Mulberry32) and returns the
fully-normalized EditorScene. Re-running the same seed + pack + media
reproduces the same scene bit-for-bit, letting bots re-roll predictably.

format=png additionally renders the scene through the exact client PNG
export pipeline (renderSceneToImageBlob) via a headless-Chromium harness
page ([locale]/spin-render) driven from lib/server/pngRender.ts, with an
LRU cache so identical spins never re-render. Falls back to JSON + image:null
when a browser isn't installed.

Supporting fixes:
- proxy.ts excludes /api/* from the locale middleware (was redirecting
  /api/spin to /en/api/spin)
- normalizeScene imports initialScene from its defining module instead of
  the editorStore barrel, whose re-exported snapshot could drop fields in
  Turbopack server bundles

Tests: picker/route/size units (84 new) + e2e spinRender spec (4, incl.
byte-identical determinism).
@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
mocksy Ready Ready Preview Aug 28, 2026 4:57pm

@4isper
4isper merged commit 80e9fe5 into main Aug 28, 2026
6 of 10 checks passed

This branch was successfully deployed

1 active deployment
Preview — d0b4b2be Deployed Aug 28, 2026 by vercel[bot]
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.

1 participant