Skip to content

Fix bugs in exports, editor UX, state management, and media handling - #4

Merged
4isper merged 57 commits into
mainfrom
dev
Sep 4, 2026
Merged

4isper merged 57 commits into
mainfrom
dev

Conversation

@4isper

@4isper 4isper commented Sep 1, 2026

Copy link
Copy Markdown
Owner

No description provided.

4isper added 17 commits August 30, 2026 14:08
Cache the handle element before React invalidates the synthetic event's currentTarget, preventing a TypeError when the pointermove listener is removed on pointerup/pointercancel.
The video elements in SingleFrameView and FrameInstanceGrid hardcoded object-fit: contain, overriding the layer's mediaFit (cover/fill). Remove the override so video respects the fill/fit setting like images.
Start video playback and wait for an actually-presented frame before the MediaRecorder captures the canvas stream, and repaint the canvas before recorder.start(). readyState >= 2 alone wasn't enough (it can be satisfied by the pre-load position), so drawImage rendered blank/black frames at the head of the recording.
…coding

The capture loop opened the first chunk with the pre-media warm-up frame:
captureStream() samples on its own cadence, and requestFrame() is
Chromium-only, so the blank lead survived on Safari/Firefox. Paint the
finally-presented media, force a stream frame where possible, and wait
one sampling period before starting the recorder. Also wait for real
playback (clock advancing past the poster frame) on the active layer and
every frame-instance video, and keep the capture canvas composited with
a tiny non-zero opacity.

Quality was capped by the browser's low default intermediate bitrate:
record a clean 16 Mbps VP8/WebM stepping stone and let the H.264 pass
set the real quality via CRF (26/20/16). Encode was slow because the
WASM x264 is single-threaded; cap the auto-sized canvas long edge at
1440px and use the ultrafast preset, cutting an 18s/3-frame export from
~4 minutes to ~2.
The config uses top-level import/defineConfig, so Vite loads it as CommonJS unless the file is ESM (.mts). Rename and switch __dirname to import.meta.dirname; update doc references.
The sitemap already advertises every locale sibling, but the [locale] pages didn't. Add alternates.languages mirroring sitemap plus a canonical URL, and drop two dead messages.errors locals.
Exports:
- even-out video capture dimensions + -vf scale guard so default MP4
  export (1200x675) no longer fails libx264/yuv420p
- exportImage/Jpeg/Webp/Avif/Pdf return success; "Exported" toast no
  longer masks failures
- aborted video/batch exports no longer surface an error toast; batch
  MP4 loop purges ffmpeg temp files on failure
- PDF page uses customSize verbatim so the raster fallback is not
  stretched when aspects differ
- SVG video scene always snapshots the poster frame (parity with PNG);
  crossOrigin set before src in all three detached-element spots
- downloadBlob revokes after 5s to survive large-blob handoffs
- multi-frame transparent scenes stay transparent (no white wash)

Renderers:
- landscape instances draw skin/body/shadow/URL in native orientation
  inside the rotated context (canvas + SVG + tilt quad), matching the
  preview rotor; tiltMatrixSvg now maps non-origin rects correctly
- text layers map "middle" to canvas "center" instead of a silently
  dropped invalid value
- annotation gradients use the CSS angle convention (sin, -cos) and
  farthest-corner radial radius; SVG radial uses userSpaceOnUse
- arrow annotations stroke with the configured width and round caps
- watermark never draws literal "null" when the logo fails to load

State:
- recent-media persistence drops oversized data URLs instead of
  rehydrating truncated (invalid) ones onto layers
- setMedia on a locked layer no longer records a recent-media entry
- removing a frame instance/layer re-points activeFrameInstanceId and
  selectedFrameIds instead of leaving dangling ids
- toggleLayerSelected keeps the active layer inside the selection
- normalizeScene.num treats null/false/"" as missing (fallback), not 0

Components/hooks:
- ContextMenu resize listener actually removes itself (leak + focus steal)
- pinch baseline clears on touchcancel so stale gestures can't hijack
  later two-finger touches
- RecentMediaGrid defers to after mount (zustand persist hydration)
- screen recording labels the blob with the recorder's real mimetype
- custom frame ids get a random suffix; EditorShell drops the orphan
  focus trap; command list memo no longer rebuilt every render
…and API

High:
- Annotation inline editing: Backspace/Delete while typing no longer
  deletes the annotation (stopPropagation in contentEditable + canvas
  contentEditable guard); shortcuts and arrows no longer fire or move
  frames while editing text
- Single-frame exports (PNG/JPEG/WebP/AVIF/video/worker) now render
  every visible layer's media with per-layer fit/filters/opacity/blend,
  matching the preview's stacked media slot; spin API video scenes get
  their poster frame instead of an empty screen
- spin API: request body is streamed with a hard 40MB cap before
  parsing, non-numeric seeds are rejected, a failed Chromium launch is
  no longer cached forever, the in-page render got a timeout and the
  PNG cache a byte budget
- Shortcut rebinding now conflicts with fixed combos (mod+v, mod+k, r/f)
- HTML export: arrow stroke width scaled to viewBox units, circle
  annotations render as circles, multi-frame videos keep their
  mediaStyle and mediaFit
- blendMode is applied by every export path (canvas, SVG, HTML)
- Screen recording: a second start during the picker dialog can no
  longer leak an orphaned capture session

Medium:
- False "Unsaved" toast on project switch, shared videoRef scrubbing
  the wrong clip, pan gesture hijacking native video controls
- SVG export keeps the screen glare when tilted or landscape-rotated;
  radial background gradient matches the CSS farthest-corner geometry
  across canvas/SVG/preview; watermark paints above annotations
- Safari WebM downloads use the actual container extension; frame
  skins are keyed per instance so materials survive; every focus-trapped
  dialog parks global shortcuts; palette "Add layer" validates files;
  stuck media spinner clears when its layer hides; palette cache is
  LRU-capped; starting an export supersedes a running one and ZIP
  exports are cancellable
…ollapse, tablet nav, multi-delete

- Reset action now returns a blank canvas (buildEmptyScene) instead of
  re-seeding the demo grid; confirm copy updated across locales
- Mobile/tablet (<=980px): undo moves to the bottom tab bar (with step
  count badge), secondary toolbar actions fold behind a '...' overflow
  menu (theme/share/commands/shortcuts/reset/fullscreen); toolbar stays
  one row. The 769-980px band now shares the bottom-sheet navigation
  instead of burying panels below the fold
- ContextMenu: checked option marker + triggerRef so clicking the menu
  button toggles instead of close-reopen
- ControlPanel search filter, ArrangeSection split, PositionSection
  scope labels, ExportDialog backdrop no-op while busy, distinct
  reset/shortcuts icons (from earlier in this pass)
- PreviewCanvas Delete/Backspace removes all selected annotations via
  new one-undo-step removeAnnotations(ids)
- Tests: +15 unit/component (2402 total), stale Layers->Panels e2e
  selectors repaired, axe audit fixed to disableRules (withRules
  narrowed the scan instead of excluding), a11y mobile overflow fallback,
  VRT baselines re-shot (9 pngs)
@vercel

vercel Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
mocksy Ready Ready Preview Sep 4, 2026 9:38pm UTC

Tour dialog title was a heading that broke heading order on mobile
(hidden Controls panel caused an h1->h3 jump); keep the visual style
with a non-heading element since the dialog already carries an
aria-label. Silence the remaining advisory Lighthouse 12 insight
audits alongside their siblings.
- Add deviceChrome.ts: per-frame ChromeSpec metrics (status bar, dock, home
  indicator) for iPhone 14, iPhone 15, iPhone 16 Pro, Pixel 8 Pro, Galaxy 24,
  iPhone SE, iPad, Galaxy Tab, desktop, watch.
- Rewrite screenChrome.ts: superellipse squircle dock icons, spec-driven
  status bar/clock/indicator positioning, lock-screen notification cards
  (Messages/Calendar) with showNotifications toggle.
- Thread frame parameter through all three renderers (mockupRenderer,
  svgMarkup, canvasDrawing) for per-device chrome layout.
- Add showNotifications to ScreenChrome type, normalizeScreenChrome, and
  ScreenControls toggle (en/ru). 2432 tests, typecheck, lint clean.
…nd folders

Android home screen
- editable 4-6 row x 3-5 col app grid (gridCols/gridRows), labeled icon presets
  (google/classic/minimal/none) and per-icon editor (label/color/emoji)
- custom dock icons now honored on Android docks as well (regression fixed)
- folder cells occupy trailing grid slots (stacked-tile stub + label)
- clock/weather widget cards reserve top grid rows and shift icons down

Renderer
- dedup grid geometry into shared androidGridGeom() used by SVG and canvas
- guard short screens (fits=false) instead of negative radii
- NOTIFICATION_APPS is the single default source across store/render/UI

UX
- ScreenControls detail groups moved to the shared Section accordion
  (localStorage-persisted, aria-controls); 6 sections, collapsed by default
- grid editor shows first 4 rows with "Show all 20 icons" reveal
- per-field reset buttons replaced by one conditional "Reset all customizations"
- active grid preset marked via aria-pressed; panel search keywords cover
  folder/widget/grid/dock/notification (en+ru)

i18n: new keys in en+ru, 55 locales synced
- define missing --success token (dark #4ade80 / light #16a34a); used by
  toolbar saved status and EditorToolbar save-state text
- replace undefined token references: --panel-bg → --field-bg
  (notification-row text inputs), --border → --panel-border (Animation/
  Media sections), --text-primary → --text (ContextMenu)
- theme-aware toggle track via var(--range-track) instead of hardcoded
  white overlay (invisible in light theme)
- shared .color-input class replaces duplicated inline COLOR_INPUT_STYLE
  in ScreenControls/BackgroundModeControls and styles previously unstyled
  AnnotationEditor swatches; adds the standard accent focus ring
- shared .color-input-row replaces inline flex/label rows, aligning
  BackgroundModeControls font size with the rest of the panel
- 12x12 stroke icons for the six ScreenControls sections; drop redundant
  .grid-dims-row span override
…onents

- extend tokens: --accent-hover/--accent-contrast, --radius-xs, --overlay,
  --shimmer, --brand-violet, z-index ladder (--z-modal..--z-skip)
- replace hardcoded accent tints/glows with color-mix(var(--accent)) so
  light theme no longer shows dark-theme cyan
- fix light-theme regressions: shimmer visibility, toggle knob contrast,
  skip-link/badge text via --accent-contrast
- normalize radii (4/6/7/9px -> --radius-xs/--radius-sm), disabled opacity
  (0.3/0.35/0.45 -> 0.4), overlay opacities, spinner and modal timings
- migrate ad-hoc overlays to the shared modal system (ShareQrDialog,
  OnboardingTour backdrop, PwaRegister toast) and inline copies to shared
  classes (.toast-status, .field-error, .text-dim-sm, .btn-sm)
- drop dead .btn-ghost, legacy --text-secondary and #4f8cff fallback
- refresh VRT baselines for the normalized visuals
- make the themed range slider the default for all input[type=range];
  .range-wrap (compact) and input.trim-range (dual thumb) keep their variants
- style the native color-input swatch as a themed field chip
  (field-bg/field-border, neutralized webkit/moz swatch chrome)
- cover input[type=url] by the canonical .field input styling and move the
  template-name field to .template-save-row rules instead of inline copies
- define the previously missing .btn-link (ScreenControls text actions) and
  .group-icon-btn; replace copy-pasted button inline styles with
  .btn-compact/.btn-flex/.btn-block modifiers
- background section: gradient linear/radial toggles now use the canonical
  Segmented control, media palette swatches reuse .swatch-btn (hover, focus
  ring, ring-based active state) and preset active state comes from
  aria-pressed CSS instead of an inline 2px border
- unify disabled opacity at 0.4 (ContextMenu) and add .check-label
- update component test for the Segmented gradient-type control
- add missing base definitions for .field-hint (used bare outside its two
  scope wrappers) and style .field textarea via the shared field selector
- global ::placeholder color; drop the inline resize on the text textarea
- fix ScreenControls add-folder button using .btn-sm without the base .btn
- ErrorBoundary fallback reuses .empty-state classes with a .btn.btn-sm retry
- enlarge touch-target thumbs for default range sliders on coarse pointers
- FrameInstanceList layer label reuses .range-label instead of inline copies
…e frame

The Position section's zoom slider sat in the media-scope group (fit/pan/
rotation act on the media inside the device), but it scaled the whole
mockup — a leftover of the frame-zoom model. New semantics, consistent
across every renderer (CSS preview, canvas, SVG, HTML, video):

- layer.zoom scales the media inside the screen about the screen center;
  the device/bezel stays put. Range widened to 80-300% (shared LAYER_ZOOM
  constant used by the slider, pinch gesture and normalizeScene clamp)
- animation presets keep whole-frame motion: useFrameTransform's static
  branch pins identity, sampleVideoTransform('none') returns zoom 1,
  frame-box scaling (computeFrameBox/Instances, shadow) consumes only the
  sampled animation transform
- media slot: new untransformed clipping frame over the screen in
  buildSceneCss (mediaSlotStyle); media elements live inside it so
  rotate/scale can't spill over the bezel — the CSS counterpart of the
  exporters' clipScreen/SVG clip group. Blend moves onto the slot because
  a clip-path slot creates a stacking context and would isolate a child
  mix-blend-mode
- post-zoom pan: transform chain becomes rotate -> translate(offset *
  (zoom - 1) * 50%) -> scale, mirrored in drawMediaSource and svgMarkup,
  so the fitted axis (landscape media on a portrait screen) can pan once
  zoomed; total pan provably stays within cover bounds (no gaps)
- canvas paints RENDER.mediaBackdrop behind the media (matches the CSS
  media background when zoom < 100% or contain fit leaves screen uncovered)
- htmlMarkup/OG wrap media in the slot; pinch-zoom clamps via LAYER_ZOOM
- tests updated for the new model (frame box unchanged by static zoom,
  identity export transform for static scenes, slot geometry assertions)
Regenerated with npm run test:vrt:update against the production build.
Picks up the enlarged preview zoom targets (WCAG target-size fix) and
drops a stale bottom-left artifact baked into the previous captures
that no build renders anymore.
Statuses (toasts, export progress) lived inline between button groups,
shifting actions and wrapping the toolbar. They now render in an
always-mounted middle slot with ellipsis clipping. Undo/redo counters
get a readable badge style, hit targets grow to 32px, and secondary
actions fold behind overflow from 1180px so narrow desktops never clip.
Add per-format hints (en+ru, synced to all locales), a live custom-size
summary with aspect and megapixels, and a notice that platform presets
adjust the canvas aspect. The dialog no longer traps the user while an
export runs: Esc/backdrop/close always dismiss without cancelling,
progress and cancel stay in the toolbar.
…ns on top

Migrate 0-instance legacy scenes to one centered instance on load,
keep overlays above the single-frame device, always show frames list,
and center the first added frame instead of scale-1 offset clone.
Drop the stale centering transform once the card is measured, auto-open
the panel bottom sheet on mobile steps (restoring the prior sheet on
finish), re-measure after the sheet transition and glide the card.
Replace the three scattered canvas-corner chip stacks (upload/clear,
zoom, grid) with one PreviewDockBar pinned to the panel bottom: it never
covers the artwork, never collides on narrow canvases and never shifts
with the scene aspect ratio. Upload stays next to Clear in multi-frame
mode (one-click replace), the zoom % is separated from Fit, and canvas
screenshots stay chrome-free. Below 480px the dock compacts to icons
with a smaller slider so every control stays reachable.
Five labeled tabs never fit the 276-310px side panel and truncated to
stubs. Below 480px of panel width (side panels, phone sheets) tabs show
icons with tooltips; wider sheets keep labels. Explicit aria-labels keep
accessible names stable for tests and screen readers.
Ten undifferentiated accordions now render under four labeled groups
(Content, Device, Adjust, Scene) in novice-friendly order. Section ids,
defaults and prefs are untouched; the filter also matches group labels
so a task-level query opens every member section.
Count badges became aria-hidden with the icon-tab refactor, dropping
e.g. 'Layers, 2' to just 'Layers'. Badges are now referenced via
aria-describedby while names stay stable labels.
The dock toolbar had a single Tab stop pattern missing: arrows now move
between controls (Home/End jump to the ends) while sliders and selects
keep native arrow behavior. Hidden controls are skipped via verified
focus steps; the programmatic-only file input never joins the walk.
Switching aspect ratios left multi-frame scenes tiny since instance
scales belonged to the old ratio. New undoable Fit to canvas action
(Arrange section + command palette) reflows existing instances onto a
grid for the current ratio, preserving frames and layer bindings.
Sheets drop from 72dvh to 52dvh with a lighter backdrop so the preview
stays visible and live while adjusting controls. Below 360px the dock
also hides its steppers and shrinks the slider (iPhone SE fits).
Viewport breakpoints missed narrow panels at wide viewports (landscape
phones, slim desktop columns), overflowing the dock with no scroll
affordance. A .preview-body inline-size container now scopes the
compact/ultra dock rules to the actual panel width; it also stops the
shrink-wrapped dock from blowing the panel wider than its grid track.
Stacked layout below 980px and at least 600px of height uses a fixed
viewport shell with a stretching preview row (no dead void on tablets).
Shorter screens keep the original scrolling rules. Also moves the
preview column min-height from an EditorShell inline style (which beat
every stylesheet rule) into desktop CSS.
Five icon buttons left the project name an unreadable stub. Rename
stays inline; duplicate/export/bundle/delete move behind a row-level
more-actions menu reusing the shared ContextMenu.
Long unbroken template/project names no longer force grid tracks wider
via min-width chains (wrap anywhere / ellipsis hold). Icon rows in
Arrange and frame-card heads wrap instead of clipping. Hidden tooltips
no longer contribute their box to ancestor scrollable overflow.
Video exports on headless/SwiftShader runners died as "Recording produced
no frames." Three stacked causes, each verified by instrumenting the live
recording:

- the 16Mbps intermediate bitrate (126acd3) starved the software VP8
  encoder; back to 2.5Mbps, which it keeps up with on any machine
- a fixed-position capture canvas (semi-transparent or behind the opaque
  page background) received no compositor frames at square/portrait
  sizes; the canvas is now an in-flow, opaque, absolutely positioned
  element behind the app UI
- capture is now explicit: captureStream(0) + requestFrame() per painted
  tick, with a 1px getImageData readback before each snapshot to force
  synchronous 2D rasterization — SwiftShader rasterizes lazily and a bare
  requestFrame() snapshotted a pending buffer, so the encoder produced
  zero data even though pixels provably changed on the canvas
- Clear-media chip moved into the dock bar (dae6ec9), a sibling of
  #preview-canvas — scope the locator to that toolbar
- since the media-slot model (8d53bce) both preview and export paint a
  #0a0a0a backdrop behind media, so the transparent-screen shadow test
  now asserts dark, not background-bright
- parity spec measured the preview box before hiding editor chrome, and
  hiding chips reflows the canvas: the stale clip stretched the preview
  and inflated the diff 0.136 vs 0.06. Measure after the style tag and
  allow 60/channel for the irreducible cross-rasterizer AA residual
- "Undo" matched "Fit to canvas"'s title (undoable); use exact
  Undo (⌘Z)/Redo (⇧⌘Z) names
- tab labels hide in compact icon mode (650a385); assert on the tab
  role's accessible name instead of visible text
…ty guards

- .toast-status now renders as a floating pill anchored to the toolbar:
  the nowrap/overflow-hidden toolbar (4098dfa) collapsed its flexible
  middle slot at 1280px, clipping the toast to zero width and hiding it
  from Playwright while clipboard copy actually succeeded
- component tests: save-toast asserts role/class; lock in the
  dock-bar-outside-#preview-canvas structure the e2e relies on
- unit guards against renderer drift: media backdrop color/rect parity
  with buildSceneCss, and screen-chrome geometry parity between canvas,
  SVG and the CSS cutout fractions
The darwin VRT baselines broke on CI with ~928px of font-rasterization
noise (0.07%) against an 80px budget:

- pin visual-regression and og-image to macos-26: macos-latest migrated
  15→26 mid-2026 and silently invalidates darwin baselines
- VRT thresholds switch to maxDiffPixelRatio (0.005 full-scene, 0.002
  dialogs) — scale-aware and an order of magnitude above AA noise while
  still catching real layout regressions; waitForStable awaits
  document.fonts.ready instead of a blind timeout
- OG freshness job replaces byte-exact git diff with scripts/check-og.mjs
  (zero-dep PNG pixel diff, 0.2% budget): real staleness measures 0.5%,
  cross-version font noise ~0.07%; og-image.png regenerated to match the
  current frame geometry
@4isper
4isper merged commit 6942de0 into main Sep 4, 2026
10 of 14 checks passed

This branch was successfully deployed

1 active deployment
Preview — b9674b9e Deployed Sep 4, 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