Commit Graph
394 Commits
Author SHA1 Message Date
SnapOtter 52f355ef8f feat: register transparency-fixer in frontend tool registry 2026-05-05 23:09:15 +08:00
SnapOtter 06d6c0ef1e feat: add transparency-fixer settings component 2026-05-05 23:09:10 +08:00
SnapOtter d46510138f fix: custom logo not showing in sidebar and silent upload failures
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
2026-05-05 21:45:51 +08:00
SnapOtter 1807a43f2d fix: crop tool overflows viewport with tall portrait images
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
2026-05-05 17:51:01 +08:00
SnapOtter e358634f8b fix: production CSP blocking PostHog/Sentry/Scalar and silent failure hardening
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
2026-05-05 17:16:19 +08:00
SnapOtter fa479dcee4 fix: resolve 3 pre-existing issues found during test coverage expansion
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.
2026-05-01 13:44:04 +08:00
SnapOtter d12b1c0fc6 fix: harden all AI tools against proxy timeouts and filename attacks
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
2026-05-01 00:11:03 +08:00
SnapOtter 4900d8a4fe fix: upscale times out behind Cloudflare Tunnel due to blocking HTTP request
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
2026-04-30 23:43:55 +08:00
SnapOtter fc8b549d78 fix: gate captureException on user consent and fix HEIC PII scrubbing
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.
2026-04-29 23:47:19 +08:00
SnapOtter 53343a0836 fix: wire up 7-day consent re-prompt in AuthGuard
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
2026-04-29 14:42:37 +08:00
SnapOtter 4d3e5e9c02 fix: defer PostHog/Sentry loading until user consents to telemetry
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
2026-04-29 14:16:38 +08:00
SnapOtter 6c1f8363bf feat(web): add target file size controls to image-to-pdf settings 2026-04-27 22:10:20 +08:00
SnapOtter fb249faeb2 fix: show queued/installing status for AI tools on main page
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.
2026-04-27 21:27:55 +08:00
SnapOtter 4f81b29fbc fix: AI feature install failures — missing rembg session and Fastify 415 (#102, #103)
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.
2026-04-27 02:38:17 +08:00
SnapOtter c061ad13ce fix: default theme setting not persisting across sessions (#98)
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
2026-04-25 22:39:18 +08:00
SnapOtter bf0307d87d fix: QA sweep — 7 bugs fixed, 17 test corrections
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
2026-04-25 07:23:58 +08:00
SnapOtter 8633dba431 fix: sync API docs, register content-aware-resize, normalize tool counts
- Fix 23 OpenAPI schema discrepancies across 16+ tools (wrong ranges,
  missing fields, incorrect schemas for gif-tools/collage/ocr)
- Add content-aware-resize to canonical TOOLS array and landing BentoGrid
- Normalize tool count to 47 across README, docs, landing, i18n, OpenAPI
- Remove dead "automation" ToolCategory variant
- Add BMP and JPEG XL format decoding via ImageMagick
- Add libopenexr-dev to Docker runtime image
- Update e2e test selectors for current pipeline builder UI
2026-04-24 23:27:08 +08:00
SnapOtter 78fa325380 fix: optimize logos and fix favicon transparency
- 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
2026-04-24 18:55:11 +08:00
SnapOtter 0309e0f680 chore: deploy to Cloudflare Pages and update branding
- 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
2026-04-24 18:06:29 +08:00
ashim-hq 839df466a0 chore: add SnapOtter logo and clean up .gitignore 2026-04-24 15:20:04 +08:00
ashim-hq b6ccb4c6ea feat: generate new SnapOtter favicon and PWA icons from logo.png 2026-04-24 14:47:57 +08:00
ashim-hq d299aa8a72 feat: rebrand user-facing strings from ashim to SnapOtter 2026-04-24 14:47:17 +08:00
ashim-hq 3185827c80 refactor: replace GemLogo with OtterLogo component for SnapOtter rebrand 2026-04-24 14:46:11 +08:00
ashim-hq a062aa9e5f fix: E2E test isolation and analytics consent bugs
- Isolate test web server on port 2349 so E2E tests run alongside Docker
- Use fresh SQLite database per test run via DB_PATH, fixing missing
  roles/audit_log tables from stale migration state
- Fix analytics consent redirect loop when ANALYTICS_ENABLED=false by
  auto-declining consent instead of bare redirect
- Dismiss analytics consent via API in auth setup and RBAC test user
  creation so the consent page never blocks UI tests
- Make Vite port and proxy target configurable via env vars
2026-04-24 01:24:15 +08:00
ashim-hq 2cf53acfa4 fix: show correct user count when MAX_USERS is unlimited
MAX_USERS defaults to 0 (unlimited) but the People section displayed
"1 / 0 users". Now shows "1 user" when no limit is configured.
2026-04-23 22:08:34 +08:00
ashim-hq 8c5945362a refactor: simplify analytics consent page for higher opt-in
Remove two-column bullet lists, vendor names, and fear-priming "NEVER"
list. Replace with a single benefit-led paragraph and privacy note.
2026-04-23 22:08:27 +08:00
ashim-hq b03c428e4f fix: prevent pipeline preview dropzone from clipping at bottom
Dropzone had a hardcoded min-h-[400px] that overflowed the preview
panel's h-[38%] constraint. Add compact prop for constrained contexts.
2026-04-23 22:08:20 +08:00
ashim-hq 7047ce5fae fix: prevent admin escalation when AUTH_ENABLED=false
When auth was disabled, users could log out, reach the login page,
and authenticate with the default admin/admin credentials to gain
full admin privileges — defeating the purpose of AUTH_ENABLED=false.

Defense-in-depth fix across five layers:
- Skip ensureDefaultAdmin() when auth is disabled (no admin user seeded)
- Return 403 from POST /api/auth/login when auth is disabled
- Return synthetic anonymous user from GET /api/auth/session when auth is disabled
- Hide logout button in settings when auth is disabled
- Redirect /login and /change-password to / via AuthGuard when auth is disabled

Closes #90
2026-04-23 14:45:04 +08:00
ashim-hq d6a977e149 fix: use full page reload after consent to avoid AuthGuard race condition 2026-04-23 11:19:31 +08:00
ashim-hq d543141d19 fix: prevent hydration effect from overwriting user consent actions 2026-04-23 11:14:48 +08:00
ashim-hq e46356c4d5 fix: hydrate analytics store from session data on login 2026-04-23 10:08:24 +08:00
ashim-hq 7275e4bfaa fix: AuthGuard checks analytics store for real-time consent state 2026-04-23 10:06:46 +08:00
ashim-hq 6e319efadd feat: add analytics init, identify, search tracking, consent header, settings toggle 2026-04-22 19:14:16 +08:00
ashim-hq f115275796 feat: add analytics consent page and auth flow integration 2026-04-22 19:10:52 +08:00
ashim-hq 544f81c48d feat: add frontend analytics wrapper and consent store 2026-04-22 19:07:50 +08:00
ashim-hq 39b011eccb docs: update privacy policy to reflect analytics 2026-04-22 19:06:01 +08:00
AshimandGitHub 5a45bcbc8f feat: production-grade RBAC with editor role, custom roles, API key scoping, and audit log (#89)
* feat(rbac): add editor role, 3 new permissions, ownership helper

* feat(rbac): add audit_log table, apiKeys.permissions column, editor role to schema

* feat(rbac): wire requirePermission into all routes, add editor role support

* refactor(rbac): replace ad-hoc role checks with permission-based ownership

* feat(rbac): add audit log DB writes + query endpoint

Dual-write audit events to stdout (existing) and SQLite audit_log table.
Add GET /api/v1/audit-log with pagination, action filter, and date range
filtering, gated behind audit:read permission.

* feat(rbac): add API key permission scoping with ceiling enforcement

* feat(rbac): add escalation prevention and last-admin protection

* feat(rbac): add editor role to UI, API key permission scoping in settings

* test(rbac): add full permission matrix integration test

* test(rbac): add editor role E2E tests

* feat(rbac): add custom roles with CRUD API and DB-backed permission lookup

* feat(rbac): add API key expiration

* feat(rbac): add roles management UI and API key expiration to settings

* feat(rbac): add audit log UI to settings

* fix: remove any cast in API key permission validation

* test(rbac): add unit tests for username validation rules

* test(rbac): add unit tests for effective permissions and ownership

* test(rbac): add comprehensive route permission matrix (all routes × all roles)

* test(rbac): add auth route edge case tests (login failures, session expiry, password side effects)

* test(rbac): add escalation prevention tests (register, update, self-demote, last-admin)

* test(rbac): add ownership enforcement tests (files, pipelines, editor access, cross-user isolation)

* test(rbac): add API key edge cases (name validation, delete behavior, key revocation)

* test(rbac): add audit log edge cases (all events, pagination clamping, structure)

* test(rbac): add custom roles edge case tests (validation, CRUD, functional permissions)

* test(rbac): add comprehensive E2E tests (roles UI, audit log, custom role, API key scoping)
2026-04-22 18:10:04 +08:00
AshimandGitHub 2d7a61c18f feat: allow multi-file selection for automation pipeline (#88)
* feat: allow multi-file selection for automation pipeline

Add two ways to import server-stored files into the pipeline:

1. Files page: "Pipeline" bulk action button and "Open in Pipeline"
   button in file details panel — navigates to /automate with selected
   file IDs via React Router state.

2. Automate page: "Import from Library" button opens a modal with
   thumbnails, search, and multi-select checkboxes to pick files from
   the user's server-stored library.

Both paths download the selected files and load them into the existing
useFileStore, reusing the batch pipeline processing infrastructure.

Closes #35

* fix: resolve 8 pre-existing test failures across unit and integration suites

- file-validation.ts: Return valid:false when Sharp fails to read
  metadata for standard formats (PNG, JPEG, BMP) instead of silently
  accepting corrupt buffers. CLI-decoded formats already skip Sharp.

- pipeline.ts: Enforce hard cap of 20 steps via .max() instead of
  relying on MAX_PIPELINE_STEPS env var (default 0 = unlimited).
  Tighten name limit to 100 chars and description to 500 chars to
  match test expectations.

- env.ts: Change MAX_LOGO_SIZE_KB default from 2048 to 500 to match
  the branding upload size limit the tests verify.
2026-04-22 00:02:00 +08:00
AshimandGitHub 6fcf43016d Merge pull request #86 from ashim-hq/fix/issue-72-auth-false-admin
fix: prevent admin escalation when AUTH_ENABLED=false
2026-04-21 23:42:18 +08:00
ashim-hq bf73150301 fix: prevent admin escalation when AUTH_ENABLED=false
When auth was disabled, the backend middleware attached the first admin
user from the database to every request, and the frontend granted all 12
permissions. This gave every unauthenticated visitor full admin access
to user management, settings, teams, branding, and feature installation.

Now both layers use role "user" with user-level permissions so tools,
files, and pipelines still work without login while admin-only routes
correctly return 403.

Closes #72
2026-04-21 23:38:42 +08:00
AshimandGitHub ba26ea4bc7 feat: add AVIF output format support across 6 remaining tools (#85)
Closes #73

AVIF was already supported in the core engine, convert, compress,
optimize-for-web, upscale, erase-object, svg-to-raster, and
pdf-to-image tools. This adds AVIF as an output format option to
the 6 tools that were missing it: split, collage, stitch,
image-to-base64, noise-removal, and red-eye-removal.

For each tool, both the frontend format selector (with quality
slider for AVIF's lossy encoding) and the backend Zod schema +
Sharp .avif() encoding were updated. AVIF defaults: quality from
the user slider, effort 4 (balanced encode speed).

Also fixes pre-existing Biome formatting violations in 5 files
that were blocking a clean lint pass.
2026-04-21 23:34:48 +08:00
ashim-hq 7920fbfd20 chore: fix pre-existing biome formatting issues 2026-04-21 23:25:41 +08:00
ashim-hq afac4b9870 fix: wire up Default Tool View save in General settings
The <select> for Default Tool View was an uncontrolled dead control with
no value binding, no onChange handler, and no save mechanism. This wires
it up end-to-end:

- Add defaultToolView to the Zustand settings store
- Load the persisted value from the settings API on mount
- Bind the <select> with value/onChange
- Add Save Settings button mirroring SystemSection's pattern
- Redirect home page to /fullscreen when defaultToolView is "fullscreen"

Closes #75
2026-04-21 23:25:34 +08:00
ashim-hq c17caa42e0 fix: add missing ConnectionMonitor component
The connection-monitor.tsx component was referenced in App.tsx but the
file was missing, causing TypeScript compilation failure during Docker
build. This component combines the useConnectionMonitor hook with
the ConnectionBanner UI.
2026-04-21 21:25:39 +08:00
AshimandGitHub 8f6dbeca32 Merge pull request #83 from ashim-hq/feat/extended-format-support
feat: extended image format support (JXL, RAW, ICO, TGA, PSD, EXR, HDR)
2026-04-21 10:55:32 +08:00
ashim-hq dc9160746e fix: ICO needs CLI decode, AVIF compress missing options, remove JXL output
- ICO: Sharp cannot decode ICO files. Added ImageMagick-based ICO decoder
  that extracts the largest embedded image. Added ICO to CLI_DECODED_FORMATS
  and SERVER_PREVIEW_EXTENSIONS. Removed from BROWSER_PREVIEWABLE sets.
- AVIF compress: Sharp's AVIF encoder requires effort option. Added
  formatOpts() helper that supplies effort:4 for AVIF format.
- JXL output: Docker's bundled libvips lacks the JXL encoder plugin.
  Removed JXL as a convert output target to avoid guaranteed failures.
  JXL remains fully supported as an input format.
2026-04-21 10:20:08 +08:00
AshimandGitHub 4c9dc6e38e fix: Docker hardening, security, and deployment readiness for V1 (#82)
Phase 1 — Docker Artifact Optimization:
- Replace broad `COPY . .` with targeted frontend source copies (API/Python
  changes no longer bust the frontend build cache)
- Replace build-essential with gcc/g++ (leaner runtime)
- Fix LOG_LEVEL=debug → info for production
- Harden .dockerignore (exclude worktrees, IDE, CI, test artifacts)

Phase 2 — State & Persistence:
- Add PUID/PGID support in entrypoint.sh for bind mount compatibility
- Guard against PUID=0/PGID=0 to prevent accidental root execution
- Evict conflicting system users (e.g. node:1000) before UID remap

Phase 3 — Security:
- Always register @fastify/rate-limit so login brute-force protection
  works even when global rate limit is disabled (RATE_LIMIT_PER_MIN=0)
- Add trustProxy support (TRUST_PROXY env var, default true) so rate
  limiting and audit logs use real client IPs behind reverse proxies
- Strip stack traces from 500 error responses in production
- Fix FSTDEP022 deprecation: maxParamLength → routerOptions
- Add multi-file guard on single-file tool endpoint with clear error
  message pointing to the /batch endpoint

Phase 4 — Graceful Degradation:
- Add consolidated hardware detection startup banner (GPU, rate limit,
  upload limit, proxy status)
- Add ConnectionMonitor component with health polling and reconnecting
  overlay that auto-dismisses when the server comes back

Phase 5 — Deployment Docs:
- Rewrite deployment.md with copy-paste CPU and GPU compose templates
- Add hardware requirements table (minimum, recommended, heavy workloads)
- Add PUID/PGID bind mount documentation
- Add complete env var reference table
- Add reverse proxy guides for Nginx, Nginx Proxy Manager, Traefik,
  and Cloudflare Tunnels
2026-04-21 10:19:08 +08:00
ashim-hq 2aadb66031 feat: add support for JXL, Camera RAW, ICO, TGA, PSD, EXR, HDR image formats
Extends the platform to handle 7 new image format families alongside
the existing AVIF support gap-fill. Uses the established HEIC decoder
pattern (CLI decode → PNG → Sharp) for formats Sharp can't handle
natively: Camera RAW via dcraw_emu/LibRaw, PSD/TGA/EXR/HDR via
ImageMagick. JXL and ICO are Sharp-native. Adds server-side preview
for non-browser-displayable formats and JXL as a new convert output
target. All 27 validateImageBuffer callers updated with filename for
extension-based format detection.
2026-04-21 09:59:57 +08:00
AshimandGitHub fa35f57813 Merge pull request #81 from ashim-hq/worktree-automate-ui-redesign
feat: redesign Automate page — tool palette + pipeline canvas
2026-04-21 09:27:47 +08:00
ashim-hq 2502239d79 feat: redesign Automate page layout — tool palette + pipeline canvas
Restructure the pipeline builder UX from a cramped sidebar to a
spacious two-pane layout:

- Left pane: categorized, searchable Tool Palette (click-to-add)
- Right pane: Pipeline Canvas with sortable steps, inline action bar,
  and collapsible preview panel with before/after slider
- Mobile: full-screen canvas with FAB + bottom sheet for tool selection,
  image preview, and thumbnail strip (previously hidden entirely)
- Fix nested <button> HTML violation in SortableStep
- Fix stray semicolon rendering in step cards
2026-04-21 09:27:17 +08:00