1. Export dialog (Ctrl+Shift+S): replaced react-hotkeys-hook handler with
a capture-phase keydown listener on window so the browser's native
"Save Page As" dialog is intercepted before it can fire.
2. Text tool: created useTextTool hook that spawns an inline textarea
overlay on canvas click, commits the text as a Konva Text object on
blur/Enter, and wired it into the useActiveToolHandlers dispatcher.
3. Undo (Ctrl+Z): removed the 500ms debounce from zundo's handleSet.
The debounce caused a race where calling undo before the timer fired
would discard the future-states stack, making undo appear to do
nothing. The equality function (keyed on _historyVersion) already
prevents intermediate states from being recorded, so the debounce
was redundant.
Users can no longer customize the app name or logo. The branding API
endpoints, permission, frontend UI, env vars (APP_NAME, MAX_LOGO_SIZE_KB),
and all related tests are removed. Includes a migration to clean up
branding data from existing databases.
CRITICAL fixes:
- #14: Render source image on canvas via Konva Image + use-image hook
- #3: Wire move tool handlers (onClick, onDragEnd, onTransformEnd, draggable)
to all CanvasObjectRenderer shapes
- #4: Implement image object rendering for fill/gradient output
- #2: Show fallback text in histogram panel when no imageData provided
- #1: Forward all args in zundo handleSet debounce wrapper
HIGH fixes:
- #5: Track raw screen cursor position for brush overlay instead of
using canvas-space coordinates
- #6: Export dialog uses Konva stage.toDataURL via module-level ref
instead of DOM querySelector for correct export at any zoom/pan
- #7: Add _historyVersion increment to setAdjustment, resetAdjustments,
toggleFilter, and setFilterParam for undo tracking
- #8: Include lastAction in partialize so history labels display correctly
MEDIUM fixes:
- #10: Move useEditorShortcuts from EditorCanvas to EditorPage with
save/export callbacks
- #11: Remove _historyVersion increment from updateObject to prevent
brush strokes from flooding undo history
- #12: Apply Konva filters (Brighten, Contrast, HSL, Blur, Grayscale,
Sepia, Invert, Pixelate, Emboss, Posterize, Noise, Solarize,
Threshold, Kaleidoscope) to source image node based on store state
The content-aware-resize tool had a full backend implementation (API route,
caire binary, seam-carving bridge) but was missing from the frontend
toolRegistry Map. Navigating to /content-aware-resize showed "Tool not found"
because ToolPage could not resolve a registry entry for the tool ID.
Added a dedicated ContentAwareResizeSettings component and registered it in
the tool registry with side-by-side display mode. Also added a guard test
that verifies every tool in the shared TOOLS[] array has a matching registry
entry, preventing this class of bug from recurring.
Closes#131
- Show desktop-recommended message on mobile viewports (<768px)
- Add LoadingOverlay component for canvas loading/progress states
- Add ARIA tablist/tab/aria-selected to right panel tabs
- Add "Open in Editor" link in review panel after tool processing
Implements Agent 2 deliverables for the image editor: move tool with Konva
Transformer and smart guide snapping, rectangular/elliptical marquee and
lasso selection with marching ants animation, magic wand flood fill, crop
tool with darkened overlay and rule-of-thirds grid, free transform with
numeric inputs, context menu with z-ordering and clipboard operations,
rulers with drag-to-create guides, smart alignment guides, and canvas/image
resize dialogs. Also creates the shared editor types and Zustand store with
full layer management, z-ordering, clipboard, guide, and document operations.
Implement Agent 7 features for the image editor:
- History panel with undo/redo list, action icons, and state jumping
- 51+ keyboard shortcuts via react-hotkeys-hook (tools, modifiers, clipboard)
- Export dialog with PNG/JPEG/WebP format, quality, resize, clipboard copy
- Navigator minimap with viewport rectangle, drag-to-pan, zoom slider
- Autosave/recovery system with localStorage and recovery banner
- Project save/load (.snapotter JSON format)
- Unsaved changes warning via beforeunload
- Paste from system clipboard support
Implements the layer management panel for the image editor (Features 16, 40):
- Blend mode dropdown (16 modes) and opacity slider for active layer
- Layer list with visibility/lock toggles, thumbnails, and inline rename
- Pointer-based drag reorder (not HTML5 drag API)
- Right-click context menu: Duplicate, Merge Down, Flatten All, Delete
- Layer effects section: Drop Shadow, Inner Shadow, Outer Glow, Stroke
- Each effect with enable/disable toggle and full parameter controls
- Wired into EditorRightPanel as the "Layers" tab content
- Fix resize 20% failure rate: add Zod refine requiring at least one
dimension, enforce integer/max constraints, clamp percentage scaling
to minimum 1px, and guard against missing metadata in withoutEnlargement
- Fix PostHog init race condition: move consent check before async import
so frontend events (search, pageview) are no longer silently dropped
- Fix identify() passing nested $set/$set_once wrappers instead of flat
properties, so version person property now appears on PostHog profiles
- Add error_code and error_message to failed tool_used analytics events
for debugging tool failures from PostHog
Create the four-zone editor layout shell (toolbar, options bar, status
bar, right panel), welcome screen with drag-and-drop and new document
dialog, and the main editor page with clipboard paste and URL loading.
Add complete type system for the editor (ToolType, CanvasObject, EditorLayer,
EditorState, etc.) and the central Zustand store with zundo undo/redo middleware,
50-entry history limit, 500ms throttle, and O(1) _historyVersion equality checks.
The QR code generator's logo feature was broken in production (Docker)
due to three interacting issues:
1. The CSP connect-src directive did not include data:, so the
qr-code-styling library's internal XHR to convert logo data URLs to
blobs was silently blocked. The library has no onerror handler, so the
render promise hung forever after the container was already cleared.
2. crossOrigin: "anonymous" was unnecessarily set on imageOptions for
data URLs, which can cause canvas taint issues.
3. The logo options used a conditional spread that omitted the image key
when no logo was set. The library's update() deep-merges options, so
removing the logo preserved the stale data URL and the QR stayed
broken even after logo removal.
Closes#121
The desktop sidebar never rendered the custom logo because only mobile
views used the customLogo state. Added a logo section at the top of
the desktop sidebar that displays the custom logo (or the default
OtterLogo when none is set).
The upload handler used raw fetch() without checking response.ok, so
HTTP 4xx errors (e.g. file too large) were silently ignored and the UI
falsely reported success. Now checks response status and surfaces the
server error message.
Closes#125
The crop area flex container lacked min-h-0, preventing it from shrinking
below its content's intrinsic height. For tall images, react-image-crop's
CSS (max-height: inherit at specificity 0-1-1) overrides the Tailwind
max-h constraint, and without min-h-0 the flex item refuses to shrink,
causing viewport overflow.
Closes#122
The production CSP had connect-src/script-src/font-src set to 'self' only,
silently blocking all analytics and error reporting in production while
working fine in dev (where CSP is not applied).
CSP fixes:
- Add PostHog ingest + assets origins to connect-src and script-src
- Add Sentry ingest origin to connect-src
- Add Scalar fonts origin to font-src for API docs pages
- Extract CSP construction into testable buildCsp() function
Silent failure hardening:
- Settings/features stores now set loadError flag and allow retry on
subsequent fetch() calls instead of permanently caching failed state
- Analytics init no longer sets initialized=true before the try block,
allowing retry on failure
- Settings dialog Tools section disables save button when settings
failed to load, preventing accidental config wipe
- Branding logo storage moved from process.cwd() to FILES_STORAGE_PATH
so logos persist across Docker container recreation
Test coverage:
- 16 CSP directive tests covering all external service domains
- Store retry-on-error behavior tests for settings and features stores
- Analytics init retry-after-failure test
1. passport-photo 404 vs 501: add base route at /api/v1/tools/passport-photo
that returns 501 FEATURE_NOT_INSTALLED when the AI bundle is missing,
matching other AI tools. The /generate sub-route is Sharp-only (no
sidecar) so it correctly skips the isToolInstalled guard.
2. AuthGuard analytics consent race: don't evaluate shouldShowConsent()
until analyticsConfig has been fetched (guard on analyticsConfig !== null).
Prevents redirect to /analytics-consent before config is loaded.
3. Fragile sidebar Settings selector: add openSettings(page) helper to
E2E helpers that checks sidebar visibility with fallback to button role.
Replace all 134 occurrences of page.locator("aside").getByText("Settings")
across 18 test files.
Convert all 9 AI tool routes (colorize, restore-photo, remove-background,
enhance-faces, blur-faces, red-eye-removal, erase-object, noise-removal,
upscale) to async 202 processing so none are vulnerable to proxy
connection timeouts.
Also fixes:
- Replace basename() with sanitizeFilename() in all AI tool routes
(prevents double-extension attacks and adds length truncation)
- Add UUID format validation for clientJobId field
- Fix missing filename sanitization in noise-removal (was using raw
user-supplied filename with zero sanitization)
- Remove em dash from error message in use-tool-processor
The upscale route held the HTTP connection open for the full duration of
Python sidecar processing (30-300s). Behind proxies with connection
timeouts (Cloudflare Tunnel: 100s), this caused HTTP 524 errors.
The route now returns 202 Accepted immediately after upload validation
and processes in the background. The result (downloadUrl, sizes, etc.)
is delivered via the existing SSE progress channel. The frontend detects
the 202 and waits for the SSE completion event instead of reading the
XHR response body. A reconnect-safe completion store ensures results
survive brief SSE disconnects.
Closes#106
captureException now checks isRequestOptedIn before forwarding errors
to Sentry, closing a gap where server errors leaked to an external
service even when no user had consented. The PII scrubbing regex is
also fixed: he[ic]f? failed to match .heic due to word-boundary
behavior and is replaced with hei[cf]? which correctly covers .heic,
.heif, and .hei.
Adds 88 new analytics tests across unit, integration, and e2e layers
proving PostHog/Sentry are never invoked when analytics is disabled or
users have not consented, plus full 7-day reminder lifecycle coverage.
The shouldShowConsent function in the shared package had the correct
logic for checking analyticsConsentRemindAt, but the AuthGuard never
used it. The inline check only redirected to the consent page when
both analyticsEnabled and analyticsConsentShownAt were null, which
is never true after "remind later" since shownAt gets set.
- Destructure analyticsConsentRemindAt from useAuth session
- Hydrate remindAt into the analytics store instead of hardcoding null
- Replace inline redirect check with shouldShowConsent from shared pkg
- Read analyticsConfig from the store inside AuthGuard for serverEnabled
PostHog SDK was initialized on app mount based only on the server-level
config flag, ignoring user consent. This caused network requests to
us-assets.i.posthog.com (config.js, web-vitals.js, dead-clicks-autocapture.js)
even when the user had not opted in or had explicitly declined telemetry.
- Replace static imports of posthog-js and @sentry/react with dynamic
import() so the SDK bundles are not downloaded until consent is granted
- Gate initAnalytics on analyticsConsent.analyticsEnabled === true,
not just server config.enabled
- Add consent re-check after each await import() to handle revocation
during the async load
- Add shutdownAnalytics() that calls opt_out_capturing() + reset()
for mid-session consent revocation
- setAnalyticsConsent(false) now triggers full SDK shutdown automatically
- Rewrite analytics test suite with 44 tests covering init gating,
shutdown lifecycle, consent toggle, race conditions, and Sentry callbacks
Closes#98
Main page tool cards and home page tool lists only subscribed to
server-side bundle state, which only reflects the actively downloading
feature. Queued features appeared as plain download icons instead of
showing their queued/installing status. Now subscribes to client-side
installing and queued state from the features store, matching the
settings page behavior.
Register custom BiRefNet-matting ONNX session in install_feature.py so
rembg.new_session("birefnet-matting") no longer raises ValueError during
on-demand installs. The session was already registered in remove_bg.py
(runtime) and download_models.py (build-time) but was missed in the
install path, causing background-removal bundle installs to always fail.
Send JSON body on install/uninstall POST requests to avoid Fastify 5's
strict content-type parser rejecting body-less POSTs with 415.
Fix error message extraction to preserve structured {"error": ...} JSON
from the Python script and filter out pthread_setaffinity_np noise.
Three disconnected systems caused the theme to never apply from server
settings: the DEFAULT_THEME env var was parsed but never seeded to the
database, the settings store ignored defaultTheme from the API, and the
settings dialog wrote to the DB without updating the active theme store.
- Seed DEFAULT_THEME and DEFAULT_LOCALE env vars into the settings table
on first startup (ensureDefaultSettings in index.ts)
- Add applyServerDefault() to theme store that applies the server's
default theme only when the user hasn't made an explicit choice
- Extract defaultTheme from the settings API response and apply it on
fresh sessions (no localStorage preference)
- Apply theme immediately when admin saves settings
- Allow "system" as a valid DEFAULT_THEME env var value
Code fixes:
- Sidebar state bleed: reset file store on HomePage mount
- restore-photo: raise error instead of silently skipping colorize
when DDColor model missing
- PaddleOCR OOM: cap input images to 2048px before OCR inference
- Torch CPU optimization: use --index-url .../whl/cpu on CPU nodes
Test fixes:
- upscale: add exact:true to scale factor button locators
- smart-crop: add exact:true to "Pad to square" locator
- colorize: use regex for model button names (Best/Balanced/Fast)
- enhance-faces: use .first() for ambiguous percentage display
- passport-photo: fix DPI locator, .or() compound, generate fallback
- people: update maxUsers assertions for unlimited (0) default
- automate: "Save Pipeline" → "Save" matching actual button text
- tools.test: add resize to Sharp mock chain for OCR tests
- Regenerate all favicons with transparent background
- Resize logos from 768x768 (456KB) to 128x128 (29KB) for web use
- Regenerate PWA icons (192, 512) with transparency
- Update social preview with word cloud and light theme
- Add Cloudflare Pages deployment for landing page (snapotter.com) and
docs (docs.snapotter.com)
- Create deploy-landing.yml and update deploy-docs.yml workflows
- Update CI to ignore apps/landing/** paths
- Fix logo transparency (remove white background) across all apps
- Recreate social-preview.png with SnapOtter branding
- Update all docs URLs from GitHub Pages to docs.snapotter.com
- Update VitePress config: light theme default, fix llms.txt paths
- Add .vitepress/cache/ and .env.* to gitignore