The captive-portal Step-2 QR opens pictureframe.edholm.me in Safari,
which is the perfect moment to also offer "pin this to your home
screen" so the recipient gets one-tap access without typing the URL
again. Two pieces:
* Service worker at /sw.js (document root, scope "/"). Minimal —
install/activate calls skipWaiting + clients.claim, fetch is
passthrough. Real offline caching is intentionally out of scope;
we only need the SW to exist so Chrome's PWA-install heuristic
fires.
* Settings → Install app section, hidden when display-mode standalone.
Android Chrome path: native beforeinstallprompt button.
iOS Safari (and any other non-prompt browser): button opens a
modal with step-by-step Share → Add to Home Screen instructions.
usePwaInstall composable handles the singleton lifecycle —
beforeinstallprompt fires once per page load and may fire before the
user navigates to Settings, so we register on module import and stash
the event for later.
Tests cover: install button rendered when not standalone, modal opens
on click without a native prompt, modal close button works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Anchor for the manual QR baked into the firmware Step 1/2 screen
(pictureframe-firmware e089911). One self-contained Twig page,
PUBLIC_ACCESS in security.yaml, /help excluded from the SPA catch-all
regex so it doesn't get swallowed.
Scope is deliberately tight: first-time setup screen by screen, the
captive-portal-didn't-open fallback (manual nav to 192.168.4.1 plus
the iOS lock-screen-scan caveat), the connection-failed retry path,
how to wipe the frame for a new account, photo-not-appearing
diagnosis, and what the yellow/red status borders mean. Anything
beyond that goes in the SPA proper, not here.
No frontend rebuild needed — pure server-rendered Twig + inline CSS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A freshly-claimed device (BOOT-button reset → buyer logs in / registers
→ linkToUser sets noon-daily wakeTimes default) was polling every 15s
per the firmware's FIRST_IMAGE_POLL bootstrap, but the server's
schedule-gating refused to run advance() because we weren't at noon
yet. Result: panel sat dark from claim until the next wakeTime fired,
which could be hours away.
Add a third bypass case in DeviceImageController::image: when the
device sends no X-Current-Image-Id header (i.e. its NVS img_id is
still -1, meaning it has never successfully painted an image),
treat the poll as a bootstrap and advance() regardless of schedule.
Once the panel paints, the next poll carries X-Current-Image-Id and
schedule-gating resumes.
Compatible with all the existing bypass logic:
- Locked image still wins.
- Cold-boot resync (X-Boot-Reason: cold) still bypasses.
- The just-provisioned + stale-binding 204 returns BEFORE this
branch, so a stranger device still can't pull the seller's image.
Test: bootstrap_poll_advances_even_when_schedule_says_not_due — sets
wakeTimes such that schedule says not-due, then polls without the
X-Current-Image-Id header and verifies a new history row was written.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-frame settings is the wrong scope for an account-level action.
The /settings tab still has the primary "Sign out" link, which is the
right place for it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Twig configure page replaced with a redirect: SetupController's index,
register, login, and the legacy /configure route all post-link redirect
to /?setup=<deviceId> for unconfigured devices. The SPA's HomeView
auto-opens its existing settings sheet for that id, with the same
controls everyone uses for live edits — themed to the user's choice,
pre-populated from the device record.
Fixes Matt's report:
- "every 6 hours" lost on save: the configure form posted
rotation_interval_hours but the controller read
rotation_interval_minutes, so the value silently defaulted to
1440 every time. Now the SPA's PATCH flow handles it correctly.
- "old settings still there in live settings": SPA settings sheet
pre-populates from the device's current state via onEdit.
- "uniqueness window in setup but not live settings": removed
from the (now-deleted) Twig form; both surfaces are consistent.
- "color scheme didn't match account": SPA respects the user's
theme natively (data-theme on <html>), so the first-setup screen
looks like the rest of the app.
Also adds a "Sign out of pictureFrame" link at the bottom of the
per-frame settings sheet (the existing /settings tab still has the
primary one). Easy escape hatch from a deeply-nested settings flow.
Tests:
- SetupControllerTest: S-03/04/05/06/08 updated for new redirect
targets, S-CLAIM-03 updated.
- HomeView.test.ts: useRoute now mockable per-test, two new cases
pinning the ?setup=<id> auto-open and its absence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renames the user-facing description of the BOOT-button factory reset
across the codebase. The threshold remains 5 s (RESET_HOLD_MS) but
"hold for 5 seconds" misled users: total wall-clock time-to-visible-
change includes ~20 s of e-ink redraw after the threshold fires, and
a too-short press now wakes the device into a normal poll cycle (a
side effect of the EXT0 wakeup we just added). "Until the screen
starts to flash" matches what the user actually sees.
- Remove-this-frame modal gains a small aside describing the
physical reset for the new owner, with the new terminology and
a callout that a brief tap just refreshes the image.
- CLAUDE.md and the operation.h comment near the EXT0 wake call
use the same phrasing.
- feedback_reset_terminology.md memory locks the rule for future
edits — never write "hold for 5 seconds" in user copy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The inline-expand version (within the bottom sheet) was awkward — the
sheet's content shifted around and the destructive button visually
inherited the same layout as Save. Switched to a centered overlay modal
teleported to <body>:
- Backdrop with semi-transparent dark + subtle blur, click-to-cancel.
- Card scales up slightly on enter, fades out on leave.
- Two-button row: Cancel (neutral) and Yes, remove (red).
- alertdialog role for screen readers.
The Remove button stays in the sheet so the entry point is unchanged;
only the confirmation surface moves out of the sheet's flow.
Tests updated for <Teleport>: HomeView.test.ts queries document
directly for the modal (it lives outside the wrapper's tree). New
case for backdrop-click cancel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three coordinated UX changes touching defaults and the settings sheet.
1. Server defaults: DeviceService::linkToUser now sets timezone =
user.timezone and wakeTimes = [12*60] (noon-daily) when creating a
new Device row OR transferring ownership on takeover. Replaces the
prior "1440-min interval anchored to last-seen-time" default that
could land a recipient's first photo at 3 am.
2. PWA propagation note: now mentions "briefly disconnect and reconnect
the frame's power" as the immediate-refresh gesture. Pairs with the
existing X-Boot-Reason: cold force-resync — the firmware already
honors a power-cycle as a deliberate refresh request, but users had
no way to discover that.
3. Remove-this-frame: replaced the native window.confirm() with an
in-sheet confirmation panel showing the explanatory text. Inline
keeps the gesture inside the existing sheet flow and gives the
destructive button a fixed location, instead of a floating native
dialog that varies per browser. The confirm body explicitly says
"this can't be undone" to match the irreversibility.
Tests:
- DeviceServiceTest: new-device default, takeover-resets-with-default,
UTC fallback when user has empty timezone.
- SetupControllerTest: claim-takes-over-defaults updated to assert
[12*60] wakeTimes.
- HomeView.test: 4 cases covering open-confirm, yes-confirm, cancel,
propagation-note text.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pairs with the firmware change of the same scope. Server-side contract:
- X-Just-Provisioned: 1 + binding older than 5 min → 204 (the device
paints its setup QR) regardless of any approved images on the
seller's account. No image content leaks.
- X-Just-Provisioned: 1 + binding fresher than 5 min → normal
response (200/304/204), with X-Claimed: 1 stamped so the firmware
clears its NVS flag and returns to standard operation.
- No header → no special behavior. Long-stable devices unaffected.
This is what makes the BOOT-5s reset actually safe to use as the
"factory reset" gesture: now it forces a real re-claim cycle, instead
of silently inheriting the prior owner's content because the MAC is
still bound on the server.
3 functional tests: stale-binding 204, fresh-binding 200 + X-Claimed,
and absence-of-header preserves legacy behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
So the public/build/ bundle that ships in the next deploy always matches
the source the tests just verified. Without it, the previous "Remove
this frame" feature shipped source-correct but compiled-stale, and the
button never appeared on the deployed PWA.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous "Remove this frame" feature commit shipped the source but
not the built assets, so the deployed bundle still showed the old
settings sheet. Rebuilds public/build/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pairs with the new claim-on-takeover checkbox: now the seller can purge
their data BEFORE handing the device over, so even if they forget to
hold the BOOT button to wipe NVS, the next owner can't accidentally pull
their photos.
Backend:
- DELETE /api/devices/{id}: owner-only (404 for cross-tenant). Revokes
image-device approvals, drops history rows, removes the Device row
entirely so the MAC is unclaimed. The next poll from that physical
frame returns 404 → setup QR for the next owner.
- DeviceService::deleteDeviceForOwner extracts the cleanup so the
controller stays thin.
- Mercure publish on delete sends {id, deleted: true} so any other
open PWA tabs splice the row out instantly.
Frontend:
- Settings sheet (BaseBottomSheet): "Remove this frame" link below
Save, in danger red with an explanatory hint about when to use it.
- Native window.confirm gate — destructive + irreversible, the
weight of native-confirm is honest. (A bespoke modal would be
polish.)
- useDeviceMercure: handles the {id, deleted: true} sentinel — splices
the device out + closes its own EventSource for that topic.
- useDevicesStore.removeDevice: DELETE + local store filter.
Tests added:
- DeviceApiControllerTest: 4 cases — happy-path delete purges
everything, 404 cross-tenant, anon redirects to login, and
post-delete the device-poll endpoint 404s (fresh-MAC guarantee).
- HomeView.test.ts: confirm-yes calls store + closes sheet,
confirm-cancel does NOT call removeDevice.
- useDeviceMercure.test.ts: deletion sentinel splices the device
out and closes the EventSource.
Coverage: 99.71% lines / 98.21% methods backend, 98.31% lines frontend.
558 tests total via ddev tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Use case: old owner sells the device to a friend. Friend holds the BOOT
button to wipe NVS, joins the device's AP, sets new WiFi. The old
owner's account is still bound to the MAC server-side, so without
explicit consent the friend would silently take over (or, worse, the
old owner's photos would keep displaying until claim).
Flow now:
- GET /setup/{mac} detects MAC bound to anyone and renders a
"Claim this frame as my own" checkbox + a banner explaining what
the takeover wipes. Both register and login panels carry the
checkbox; submitting either form without it bounces back through
the index with a session-flashed error.
- DeviceService::linkToUser now requires allowClaim=true to
transfer ownership. Without it, throws DeviceClaimRequiredException
that the controller catches and turns into the bounce-with-error.
- On a successful claim, the takeover wipes:
* old image-device approvals
* device_image_history rows for the device
* name, wakeTimes, currentImage*, lockedImage, nextPollExpectedAt
so the new owner starts from a fresh slate, not inheriting the
seller's "Living Room / 4:30 AM" preset.
- Already-logged-in user visiting /setup/{mac} for someone else's
device falls through to the form (instead of silently transferring
on page load) so the checkbox is the only path.
Test matrix:
- SetupControllerTest: 5 new functional cases — checkbox renders for
bound MACs, register/login without checkbox bounce + retain old
ownership, register WITH checkbox transfers + purges, logged-in
other-user falls through to form.
- DeviceServiceTest: 3 new unit cases — throw without consent,
isClaimedByAnotherUser true/false matrix, takeover resets device
state.
Coverage: 99.70% lines / 98.19% methods backend, 333 frontend tests
green via ddev tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Started: 89.08% backend / 97.01% frontend lines.
Landed: 99.69% backend / 98.62% frontend.
Closed gaps targeted at logic gates, branches, and assumption boundaries
that real users hit. Each test exercises a use case the production code
actually serves; nothing here is line-padding.
Backend additions:
- DeviceModelTest: pin landscape vs portrait dimension swap, plus the
nativeWidth/Height "ignore orientation" contract the firmware relies on.
- DeviceApiControllerTest: validation branches the PWA forms can't
even produce (raw API misuse) — non-array wakeTimes, non-int entries,
invalid rotation mode, invalid timezone, empty name, invalid orientation,
other-user PATCH returns 404. Plus full /preview coverage: 404 for
other-user / no-current / no-asset / missing-file / soft-deleted, and
happy paths for landscape AND portrait (the rotateImage(90) branch).
- ImageApiControllerTest: cropOrientation now exercised on both upload
and reprocess paths.
- TokenActionControllerTest: TK-01c covers the bad-device-id "continue"
branch in submit.
- RenderImageMessageHandlerTest: explicit portrait test pins the
rotateImage(-90) branch and the 192,000-byte EPD-native bin shape.
- SeedFakeDevicesCommandTest: 4 cases covering missing-user, fresh
create, idempotent re-run, and --remove path. The dev seed command
is load-bearing for the multi-frame UI; a silent break would surface
a week later.
- RerenderAssetsCommandTest: reset + dispatch path, no-assets path.
Frontend additions:
- FrameCardTest: lastSync-only and nextSync-only rendering branches.
- HomeView.test:
* + Add time fallback path when all 9 default candidates are taken.
* Multi-day "in Nd" nextSync formatting (offline / huge-interval case).
* Medium-horizon (5h) nextSync formats as clock-time + day label.
* visibilitychange triggers a silent re-fetch.
* add-photo handler creates input + navigates to /upload after pick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors aqua-iq's pattern but adapted for pictureFrame's stack:
postgres 16, php 8.4, node 22, imagick + pcov via apt extras,
Mercure hub at https://pictureframe.ddev.site/.well-known/mercure,
and four custom commands — `ddev tests`, `ddev coverage`,
`ddev frontend` (vite HMR), `ddev worker`.
Also restores dev deps (DAMA, Doctrine fixtures, symfony/uid) that
got dropped during earlier composer reshuffles, and adds a separate
`db_test` connection in .env.test so DAMA's transactional isolation
doesn't share state with whatever dev is mid-experiment with.
Two test fixes the new env exposed:
- RotationServiceTest::test_prioritize_never_shown_falls_through_when_all_shown
needed uniquenessWindow=2 so the recent-window filter wipes the
set and the fallback restores the full pool — otherwise window=1
excluded the most-recently-served image and the assertion drifted.
- DeviceImageControllerTest::test_locked_image_served_without_rotation_advance
was asserting currentImage stays null on a lock poll, but the
controller intentionally sets currentImage on the lock path so
Home reflects the live frame state. Now asserts both the
currentImage update AND that no DeviceImageHistory row was
written (the actual rotation-bypass guarantee).
Backend coverage (full suite via `ddev coverage`): 89.08% lines /
92.24% methods / 74.36% classes — the first real number we've had.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptom: wakeTimes schedules silently never fire on non-UTC devices.
Reported live by Matt's EDT frame: wakeTimes=[12:30 PM NY] saved,
12:30 came and went, no rotation. Same bug pattern would fire
*every* poll on east-of-UTC tzs.
Root cause: device_image_history.served_at is `timestamp without time
zone`, written by `new DateTimeImmutable()` so it stores UTC
components ("2026-05-08 16:28:50"). The boundary in isDue() was
bound through Doctrine with the device's local tz still attached,
so Doctrine's format() emitted local-tz components ("12:30:00").
Postgres compared the strings literally — for west-of-UTC tzs the
UTC timestamp is numerically larger than the local-tz boundary, so
every same-day row falsely satisfied `servedAt >= :wakeTime` and
isDue returned false.
Fix: $boundary->setTimezone(UTC) before binding. Both sides now
format in UTC components, so Postgres's literal compare is correct.
Regression test ID-TZ-01: device in America/New_York, wakeTimes
[12:30 PM NY], history at 12:00 PM NY (= 16:00 UTC). With the fix
isDue returns true; without it the test falsely-matches and fails.
Skipped before 13:00 NY since the assertion needs the wake slot to
have already passed today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 12-first ordering came from how minutes-to-12-hour conversion
treats midnight (h24 % 12 === 0 → display as 12), but that's a value
mapping, not a list ordering. Listing 1-12 is the obvious natural
order users expect from a clock dropdown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptom: clicking + Add time would insert the new entry sorted into
the list, hiding it among the existing rows. Editing an existing
row's hour/minute/AM-PM moved the row mid-keystroke.
Both behaviors made the user lose track of what they were editing.
The list now only sorts at save time (which the backend already
canonicalizes via setWakeTimes()). New entries land at the end,
edits stay in place. Two regression tests pin this:
- + Add appends; the new row is the last DOM row even when its
minutes-of-day are smaller than an existing entry.
- Editing a row's hour from 9 to 1 keeps the row at the same
index (would have moved to index 0 under the old sort-on-edit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cold-boot polls (firmware sends X-Boot-Reason: cold on UNDEFINED
wakeup cause) are treated as a deliberate "force a refresh" gesture
from the user — unplug → replug to re-pull whatever the web app
queued. Timer wakes still respect the wakeTimes schedule, so the
schedule-gated semantics aren't undermined.
Test: a cold-boot poll between scheduled wake times advances the
rotation and writes a fresh DeviceImageHistory row, while an
otherwise-identical timer-wake poll returns 304 without rotating.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptom: with wakeTimes=[4 AM, 9 PM, 9:15 PM], the frame rotated to a
fresh photo at 10:14 AM when the device was reconnected and polled.
The wakeTimes only governed *when* the device polled (via X-Interval-Ms);
they didn't gate whether the server picked a new image when it did.
Power-on or button-press polls would always rotate.
Fix: move the existing isDue() logic from AdvanceRotationMessageHandler
into RotationService as a public method, and gate
DeviceImageController::image so off-schedule polls return the device's
current image (which 304s when X-Current-Image-Id matches) rather than
calling advance(). The scheduler-driven handler still uses the same
isDue — both code paths now share one source of truth.
Tests:
- DeviceImageControllerTest: new test asserting an off-schedule poll
returns 304 without rotating; existing wakeTimes tests reworked to
use slot lists that always have a past slot regardless of run time.
- AdvanceRotationMessageHandlerTest: existing AR-04 through AR-07
keep covering isDue's semantics — they now go through the service.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Frontend (90.15→95.37 stmts / 91.83→97.01 lines):
- useDeviceMercure: full composable test suite via a fake EventSource —
open/merge/ignore-stale/parse-error/reconnect/dynamic-add/remove/
no-op-when-unconfigured/cleanup-on-unmount.
- HomeView: cover onTimePart's AM/PM and minute branches plus the
nextPollExpectedAt-null fallback paths in the next-update preview.
Backend (no instrumentation before; pcov was already in the image,
just needed a <coverage> block in phpunit.dist.xml):
- RotationService: one test per mode (NewestUpload, Random,
LeastRecentlyShown), one for never-shown sorting first under LRS,
and two for prioritizeNeverShown — narrows when never-shown exists,
falls through to mode otherwise.
- DeviceSerializer: contract test on the wire shape (REST + Mercure
use the same serializer; silent rename here would break live updates
instantly).
- MercurePublisher: topic format + JSON encoding + the swallow-
exceptions guarantee (a flaky hub must not break poll responses).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 2 was doing `curl -sI .../mercure?topic=ping` to verify the hub was
reachable. Over HTTP/2, that HEAD against the SSE endpoint apparently
leaves a connection state on Caddy/Mercure that causes the next subscribe
from the same source to receive zero bytes — making step 6's round-trip
fail every time. Took a careful bisect to find: minimal script worked,
adding step 2 broke it 100%.
The hub-reachable check is redundant anyway: step 5's full publish→
subscribe round-trip is a stronger proof of life. Renumbered to 5 steps,
removed the also-not-using set-uo-pipefail commentary too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Runs after every deploy. Six checks against pictureframe.edholm.me:
1. Frontend reachable (302/200 expected).
2. Mercure hub reachable (200 expected).
3. All pictureframe-* containers Up/healthy.
4. No CRITICAL / Fatal / 5xx in last 5 min of php/worker/nginx logs.
5. Authenticated /api/devices round-trip via testbot.
6. Mercure publish→subscribe round-trip via no-op PATCH.
Catches the class of bug that just bit us today: nginx caching a stale
PHP container IP after `docker compose up -d`, and a silently-dropped
composer dep crash-looping the worker. Neither shows up in unit tests
because they're infra-level.
Per the new "post-deploy smoke check" rule: if a unit test doesn't
cover a change, run this script (or an equivalent cURL) before
declaring the deploy done.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This dep was silently dropped by the earlier `composer require
symfony/mercure-bundle --no-scripts` on the prod host (it ran in
no-dev mode and removed packages not currently referenced in
require/require-dev), which crash-looped the worker when
Symfony\Component\Scheduler\Trigger\CronExpressionTrigger tried to
parse the hourly RunImageCleanupMessage cron.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two settings exposed in the PWA frame-settings sheet:
- rotationMode (enum: random | least_recently_shown | oldest_upload |
newest_upload). Default oldest_upload preserves the legacy
hard-coded sort, so existing devices behave identically until the
user changes it.
- prioritizeNeverShown (bool). When set, the candidate set is narrowed
to never-shown images first (if any exist) before the mode runs —
useful for "burn through new uploads before re-shuffling the catalog."
RotationService pipeline:
1. Pull approved/ready pool.
2. Drop the last `uniquenessWindow` served (existing).
3. If prioritizeNeverShown AND any candidates have never been served,
narrow to those.
4. Apply the selection mode.
Backend: enum, entity columns + accessors, migration, serializer,
PATCH validator. Frontend: types, stores, settings sheet section
(dropdown + checkbox), test fixtures, save-flow test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Subscribe per-device with a Symfony Mercure hub: server publishes a fresh
device payload after every poll (200/304/204), every PATCH, and every
lock/unlock. The frontend opens one EventSource per device topic and
splats inbound JSON straight into the devices store — same shape as
GET /api/devices, so no envelope handling.
Topic: https://pictureframe.edholm.me/devices/{id}
Stack mirrors aqua-iq:
- symfony/mercure-bundle + config/packages/mercure.yaml
- App\Service\MercurePublisher (errors swallowed + logged; a flaky hub
must not break a poll response)
- App\Service\DeviceSerializer extracted as the single source of truth
for the wire shape (REST + Mercure share it)
- Frontend useDeviceMercure() composable: opens/closes EventSources to
match the device list reactively, reconnects on hub-side closes
- SpaController exposes MERCURE_PUBLIC_URL via window.__PF_MERCURE_URL__
Production compose adds a dunglas/mercure container with Traefik labels
for pictureframe.edholm.me/.well-known/mercure (handled separately on
the host since the file isn't in this repo).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>