The crop tool now exposes a landscape/portrait toggle next to the
device-name label, and the canvas crop frame snaps to the chosen
aspect when toggled. Choosing an orientation that does not match
the target frame's current orientation surfaces a yellow informational
chip — purely informational, no action required, clears as soon as
the user toggles back to the matching orientation (or changes the
frame in Settings).
The chosen orientation rides along on the upload/reprocess request
as a new cropOrientation form field and is persisted on the Image
entity, so the library view and rotation logic can later surface
the same mismatch state for already-uploaded photos. Existing photos
without a stored orientation get null and are unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 304 short-circuit at DeviceImageController only compared image IDs,
so flipping a device between landscape and portrait would not invalidate
the cache: the device kept showing the previously-rendered .bin even
after the user changed orientation in the webapp.
Now the device row tracks currentImageOrientation — set whenever a 200
binary response is sent — and the 304 path requires both image id AND
current orientation to match the device's stored orientation. An
orientation flip naturally falls through to the 200 path on the next
poll, the freshly-rendered portrait .bin is delivered, and the device
redraws.
No firmware change: the existing X-Current-Image-Id header from the
device is sufficient. Existing devices migrate cleanly — null
currentImageOrientation just forces one full re-send on first post-
migration poll, which is harmless.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .bin file for portrait orientation was packed as 480-pixel rows ×
800 rows, but firmware streams blindly at 800×480 (the EPD's native
scan order). Both layouts hit the same 192000-byte total, so the size
guard in epd_draw_image_with_border passed and the row-stride mismatch
showed up on the panel as the photo tiling/repeating.
Renderer now rotates the cropped photo 90° CW before dithering when
orientation is portrait, so the packed bytes always match the EPD's
800-pixel scan order. Firmware stays orientation-unaware (per the
"ESP32 never transforms images" decision in webApp/CLAUDE.md). Preview
decoder rotates -90° on the way out so the in-browser frame preview
stays upright.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Imagick extension on the server (3.7+) requires importImagePixels
to receive an array of ints, not a string blob, so the previous code
500'd. Wrap the raw RGB bytes in a PPM (P6) header and let Imagick
decode it via readImageBlob — same result, no 70 MB-array detour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundle was registered for test env only but its YAML config loaded
unconditionally, which broke `bin/console cache:clear` in dev/prod
when the bundle is enabled but the env doesn't load it. The file now
only contributes to the test container.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Decode the device's rendered 4bpp Spectra-6 .bin into a PNG (cached
next to the .bin) so the home-screen preview matches the dithered
6-color output the e-ink actually displays.
- New endpoint: GET /api/devices/{id}/preview
- Expose currentImageId on device JSON
- HomeView passes preview URL to FrameCard for both single and compact layouts
- Drive-by: fix vite.config.ts to import defineConfig from vitest/config
so the build no longer fails on the unknown `test` property; remove
unused useUploadStore import in HomeView test
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace orientation <select> dropdown on setup/configure with the same
visual two-button picker used in the SPA (SVG frame diagrams, ribbon
indicator, active highlight). Hidden input carries the value on submit.
Firmware: normal_operation() now calls show_setup_qr(mac) on 404 instead
of epd_fill(COLOR_RED) — device shows scannable QR with its own MAC when
not yet registered.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
RepeatedType renders as a wrapper div when called as a single widget,
producing unstyled inputs with no mismatch error handling.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Assets built to public/build/assets/ but index.html referenced /assets/
(no /build/ prefix). Nginx couldn't find them, fell through to Symfony's
catch-all SPA route, which served HTML in place of JS — Vue never loaded.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Traefik terminates TLS and forwards X-Forwarded-Proto: https to Nginx,
which forwards it to PHP-FPM. Without trusted_proxies, Symfony ignored
this header and generated http:// redirect URLs after login/register,
causing session cookie loss on mobile (Secure cookie not sent over HTTP).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Collapse orientation to landscape/portrait (ribbon left = portrait standard)
- Add OrientationPicker component and wire settings sheet in HomeView
- Add password confirmation field to registration form (RepeatedType)
- Build frontend SPA to public/build/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SpaController: injects data-theme on <html> and window.__PF_USER__ before JS
hydrates — theme applied without FOUC; no initial API call needed for user data
- UserApiController: PATCH /api/user/theme validates against 6 allowed theme IDs,
persists to user.theme column, returns {theme}
- useTheme composable: applyTheme() sets html[data-theme], saveTheme() calls API
and falls back with toast on error
- SettingsView: 3-col theme grid with swatch previews, aria-checked radio semantics,
active indicator; Sign out link; signed-in email display
- App.vue: onMounted syncs Pinia theme state with SpaController-stamped html[data-theme]
Verified: data-theme injected on / load; PATCH saves to DB; reload shows persisted theme
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move firmware files from repo root src/ into firmware/ to avoid
collision with Symfony's src/ PHP class directory. Add DDEV
config targeting PHP 8.4 / PostgreSQL 16 / nginx-fpm with
Imagick extension via docker-compose override.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
6 epics, 34 stories, 44/44 FRs covered. Includes party mode review
fixes: sticker canvas split into interaction + state persistence stories,
zero-device upload edge case AC, FrameCard offline/sync-fail states,
and scheduler setup story. All stories have Given/When/Then AC and
no forward dependencies.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace AssetMapper + Stimulus + Turbo with Vue 3 SPA (Vite,
TypeScript strict, SCSS modules, Konva.js). Authenticated app is
now a full SPA served by Symfony catch-all; public flows (provisioning,
email approve/decline) remain Symfony + Twig. Add JSON API controllers
for SPA, SpaController catch-all, updated directory structure, and
revised implementation sequence.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Complete 14-step UX design spec covering core experience, emotional
response, design system (Vue 3 + TypeScript + SCSS + Konva.js), 6
color themes, Direction 5 (Minimal Card), user journeys, component
strategy, UX patterns, and responsive/accessibility requirements.
Includes interactive theme explorer and design direction mockups.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>