The card's "next sync" was computed locally as `lastSeenAt + interval`,
which broke the moment the user PATCHed a new interval: the device is
still asleep on whatever schedule was active at its last poll, but the
local record now has the new interval, so we'd display a misleading
"in 2m" after a 5→3 min change.
Fix: server stamps `nextPollExpectedAt` on every poll (200/304/204),
PWA reads it directly. The timestamp doesn't move when settings are
edited — only when the device actually polls and picks up a new
schedule. Same field also drives the settings-sheet "Next update"
preview, which had the same flaw.
Side effects:
- `markSeen()` now flushes on the 204 paths too — they previously
set lastSeenAt without flushing (latent bug for devices with no
approved images / missing assets).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The frame is asleep on whatever schedule was active at its last poll —
saving new settings here does NOT reach it until that next scheduled
sync. The preview was claiming the *new* schedule's next slot, which
was misleading: setting "at 4 AM" while the frame is on every-1-min
should preview "in ~1 min" (next existing poll), not "at 4 AM".
Now compute the next sync from the device's CURRENT saved schedule
(lastSeenAt + interval, or next saved wakeTime in tz). Falls back to
"when the frame next connects" for never-seen devices and "any moment"
for already-overdue ones.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
type="number" alone shows a regular keyboard with a number row on iOS;
inputmode="numeric" + pattern="[0-9]*" tells the OS to surface the
numeric keypad instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iOS auto-zooms <input> elements when their font-size is below 16px. The
"every X minutes" number field was using --text-sm (13px), so tapping it
zoomed the page — unwanted on a PWA. Bumped to 16px to suppress the zoom.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Frame settings now offer two update-frequency modes: "at specific times" or
"every X minutes". Times are stored as an int[] of minutes-since-midnight,
allowing multiple slots per day at minute granularity. Backend computes the
earliest upcoming slot for X-Interval-Ms and uses the most-recent-past slot
as the rotation-due boundary. PWA settings sheet has hour/minute/AM-PM
dropdowns with + Add / trash, a live "next update" preview, and a note
that changes only take effect at the device's next sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets the firmware verify integrity end-to-end and discard a corrupt
transfer before painting the panel — pairs with the firmware-side hash
check that lands in the same series of changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three problems were stacked:
1. The 200 serving path didn't set currentImage when a locked image was
served (RotationService.advance bypassed). The frame got the locked
photo; the DB kept the previous one; Home showed the old one.
2. The 304 path didn't flush at all. lastSeenAt (markSeen) was lost on
every no-change poll, and any drift in currentImage couldn't self-heal.
For a frame that's been locked for a while, polls cycle as 304 forever
and the DB stays wrong indefinitely.
3. Pull-to-refresh fetched via fetchDevices(), which flips loading=true
and replaces the cards with "Loading…" mid-fetch. The PTR spinner was
working but users couldn't see the result of their refresh.
Fixes:
- Both 200 and 304 paths now set currentImage = $image and flush. The
304 path becomes self-healing for any device whose currentImage drifted
from reality (e.g., from before the 200-path fix).
- fetchDevices / fetchImages take an optional { silent: true } that
skips toggling loading.value. PTR refresh callbacks pass silent so
the cards stay visible during background refresh.
- HomeView also listens on visibilitychange and silently re-fetches when
the PWA returns to foreground, so reopening the app shows current
state without a manual pull.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both the backend preview endpoint and the frontend cache-buster were
preferring lockedImage over currentImage. Locking is a queued override
that doesn't take effect until the device's next poll, so showing it on
Home before the device has actually pulled it lied about the frame's
state. Always use currentImage now.
Also: add a primary "+ Add Photo" button at the top of the Library page
so users can upload without bouncing back to Home; updates the empty-
state copy to point at the new button.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iOS standalone PWAs don't get Safari's native pull-to-refresh, so add
our own. New <PullToRefresh> component handles the gesture: dampened
drag past an 80px threshold triggers an async onRefresh; below that it
springs back. Swipe direction is locked to the first 6px of movement,
so horizontal carousel swipes (landscape Home) don't accidentally fire
PTR. The arrow icon rotates from 0° to 180° as the pull approaches the
threshold and turns primary-color when ready; during refresh a CSS
spinner replaces it.
- HomeView refreshes the device list (and sync status with it)
- LibraryView refreshes images, pending-share count, devices, and the
active shared sub-tab page when it's the one in view
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Portrait frames (3:5 aspect) at full card width were rendering 600px tall
and pushing the body well off-screen. Cap preview max-height at 40dvh and
switch the img sizing to max-width/max-height: 100% with auto width/height
— photo scales to fit inside the capped preview at its native aspect, with
narrow grey side bars filling the leftover horizontal space. Landscape
frames are short enough that the cap never engages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the PWA is rotated on a phone, vertical space is too tight for the
full-bleed vertical stack. Detect landscape phones via
@media (orientation: landscape) and (max-height: 600px) and:
- Flip the stack to a horizontal scroll-snap carousel
- Shrink each slide to min(320px, 70vw) so 2-3 cards are visible at a time
- Restructure the card body to a single row: name + status on the left,
Add button on the right; sync line is dropped to keep things tight
- Constrain the photo to fill card height (object-fit: contain) instead
of card width, so it never overflows the short viewport
Manifest also updated to orientation: any so iOS doesn't lock the
standalone PWA back to portrait.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spawns five fake devices on a user account with varied lastSeenAt and
wakeHour configurations so every status state (online / sync issue /
offline / never-seen / daily-wake) can be exercised at once. MACs use a
reserved AA:BB:CC:DD:EE:** range so the command sweeps prior fakes on
re-run without touching real hardware. --remove cleans up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For multi-frame setups, switch from side-swipe carousel + dot indicators
to a vertical scroll-snap stack of full-size cards. Each frame gets its
own page-height slide; flicking up/down moves between frames with the
same snap-stop feel as the horizontal version. Removes ~30 lines of
carousel scroll-tracking JS and the dot navigation.
Single-frame layout unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vitest occasionally writes its .vite cache to a top-level node_modules/
when invoked from the wrong cwd. The cache landed in 78405b6; remove it
and ignore the path so it can't sneak in again.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two complaints, one root cause: the FrameCard was floating in the slide with
a min-height-padded preview, so (1) photos got top/bottom gray bars instead
of fitting their container, and (2) there was a fat empty gap between the
card body and the bottom nav.
Restructured the large card to flex-fill its slide:
- preview hugs the photo's intrinsic aspect ratio (img with width:100%
height:auto); no min-height, no aspect-ratio override → no letterbox
- card body has flex:1, info pinned at top, Add Photo button pinned at
bottom via margin-top:auto and width:100%
- HomeView main / single-card / carousel all flex:1 down through the
layout so the slide gets the full available height
- empty-state placeholder still reserves the device's aspect so the
card doesn't jump while images load
Result: the photo fills its container left/right with no bars; the body
absorbs all remaining space below, with the action button always sitting
just above the bottom nav.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The natural 5:3 aspect ratio renders landscape devices as a ~200px-tall strip
on a phone — too small now that the carousel gives each slide the full
viewport. Set min-height: 50dvh so landscape preview is at least half the
screen tall, with object-fit: contain letterboxing the photo. Portrait
frames (3:5) still drive their height from the natural aspect ratio, since
that's already taller than the 50dvh floor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reverts the 240px preview cap — frames render at their natural device aspect
again. Single-frame layout unchanged.
For multi-frame setups, replaces the compact stack with a horizontal
scroll-snap carousel: one large card per slide, full-bleed to the viewport
edges, with dot navigation below that tracks the active slide and supports
tap-to-jump. Native CSS scroll-snap drives the swipe gesture; no extra JS
gesture library.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- HomeView clears the bottom nav so + Add Photo isn't covered.
- Cap large frame-card preview to min(240px, 30dvh) so portrait frames
no longer dominate the screen at full mobile width.
- Three-state device status — green/Online (recent sync), yellow/Sync
issue (one window missed), red/Offline (two+ windows missed). Window
is rotationIntervalMinutes for interval-mode devices, 24h for daily
wakeHour-mode devices.
- Show last-sync ("synced 2h ago") and next-expected-sync line on the
large card. wakeHour devices show local-hour ("next sync ~4 AM
tomorrow") in the device's configured timezone.
- BaseBottomSheet drag-to-dismiss on the handle. Touch and pointer
events; releases past 80px close the sheet. Snaps back below.
- BaseInput floating label rewrite — taller field, label re-anchors
to top: 8px when filled/focused so it sits cleanly above the value
instead of overlapping it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add manifest.webmanifest with standalone display + warm-craft theme colors,
apple-touch-icon, and 192/512/512-maskable icons (frame-with-sunset glyph).
- Add PWA meta tags + viewport-fit=cover so add-to-home-screen produces a
true standalone app on iOS instead of a Safari bookmark.
- Drop the Shared bottom-nav tab; the in-page sub-tabs already cover that.
Three nav tabs total (Home / Library / Settings); pending-share badge
moves to the Library tab. Predicate-based isActive() now correctly
disambiguates /library vs /library?tab=shared.
- Safe-area handling: bottom nav, bottom sheet, upload overlay, and #app
respect env(safe-area-inset-*); sticky Library tabs anchor below the
iPhone status bar. Introduces --bottom-nav-height token consumed by
Settings, Library, and the toast.
- LibraryView reactively follows route.query.tab so deep-linking
/library?tab=shared lands on the right sub-tab.
- Theme-color meta syncs client-side via useTheme.applyTheme so the
user's chosen theme follows them into Android Chrome's chrome bar.
Test suite expanded to 278 tests / 100% line coverage (99.84% statements,
99.78% branches). Remaining gaps are unreachable defensive code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
contrastStretchImage's parameters are quantum-range intensity thresholds
in IM7, not pixel counts as the original code assumed. Empirically any
arg >= 1 collapsed low-tonal-range photos to pure white — verified by
probing image 16's mid-photo pixel which goes from (80,69,59) to
(255,255,255) with cs(100,100), cs(1382,1382), or cs(3840,3840) alike.
normalizeImage() uses the image's actual histogram percentiles (default
2% black/white clip) and produces gentle, correct stretching: same
pixel goes (80,69,59) to (89,72,59) — a small contrast bump that
preserves the photo's tonal information instead of obliterating it.
Existing on-disk bins were rendered with the broken stretch; running
app:rerender-assets after this deploy regenerates them with the new
pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compositing the white letterbox bars before contrastStretch meant the
bars (pure white, often 60%+ of the canvas for an aspect mismatch) were
included in the per-channel histogram. The "lightest 1% to clip to white"
threshold therefore landed inside the bars themselves, raising the
effective white point so the photo's lighter tones got over-clipped to
white and the photo ended up washed out.
Reordering: thumbnail → contrastStretch/modulate/sharpen on the photo
alone → composite onto white canvas → rotate. The contrastStretch's
percentage now uses the photo's actual pixel count too, not the full
canvas, so the 1% clip is honest about how many pixels of real photo
content it's looking at.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cropThumbnailImage was filling the target box by aspect-cover — for a
portrait crop served to a landscape device (or vice versa), that meant
slicing a thin band through the photo, which the user correctly called
out as unacceptable on the screen.
Switching to thumbnailImage(... bestfit=true) + composite onto a white
canvas of exact target dims means the photo always shows up upright and
recognizable: matching aspect renders byte-identically to before (no
padding, no zoom change), mismatched aspect shows the photo fit-to-box
with white bars instead of a cropped slice.
Adds an app:rerender-assets console command to reset every Ready asset
to Pending and re-dispatch its render message — needed once after this
deploy so existing bins (rendered with the old cropThumbnail logic) get
regenerated with the letterbox pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each thumbnail now shows a yellow warning triangle in its action stack
when at least one approved device's orientation does not match the
photo's crop orientation. Tap opens the edit flow with that device set
as the crop context, so the existing in-crop-tool indicator can guide
the re-crop. Photos without a stored cropOrientation fall back to
inferring it from the saved cropParams aspect, so older uploads aren't
left blind.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After re-cropping an image, the renderer regenerates the .bin and
advances the asset's rendered_at, but the device's 304 short-circuit
still matched on (image_id, orientation) only — so the device kept
serving the old upside-down/stale bytes from its local cache despite
the server having freshly-rendered correct ones.
Adds device.current_rendered_at, populated whenever a 200 response is
served, and tightens the 304 condition to require all three (image id,
orientation, rendered_at) to match. The asset lookup now happens before
the 304 check so its rendered_at is in scope for the comparison.
No firmware change — this is server-side cache logic. Existing devices
get null current_rendered_at after the migration; their next poll falls
through 304 and re-fetches once, then the cache is in sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first rotation pass picked CW server-side / CCW preview-side based on
"ribbon on left" → user rotates frame 90° CCW. On hardware the photo came
out upside down, which means the user's physical rotation is the opposite
of what was assumed: 90° CW from landscape native, putting the ribbon to
the left from the user's POV but to the right from the EPD's reference
frame. The two rotation signs always need to stay opposite — flipping
both keeps the webapp preview upright while fixing the device.
Also drops the temporary upload debug log; the cropOrientation persistence
issue resolved on its own once Doctrine's metadata cache was cleared.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
StickerCanvas was being passed contextOrientation (the target device's
orientation), so the final composited.jpg was always sized to the device's
aspect — even when the user toggled the crop tool to a different orientation.
A landscape crop on a portrait device would produce a 1600x960 cropped
blob, then the StickerCanvas would re-render it into a 960x1600 frame,
visibly stretching the image into portrait dimensions and saving it that
way.
UploadView now derives an effectiveOrientation that prefers the user's
chosen crop orientation (uploadStore.cropOrientation) and falls back to
the device's orientation only before the crop step has run. The
StickerCanvas honors that.
Also adds a temporary debug log in the upload controller to verify the
cropOrientation form field is arriving and being persisted — recent
uploads have NULL cropOrientation despite the frontend sending it, and
this log will make the next upload's payload visible. Will remove once
diagnosed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>