Commit Graph

46 Commits

Author SHA1 Message Date
football2801 ad0d6c572c fix(home): preview locks aspect to panel dims + object-fit so it never overflows
CI / test (push) Has been cancelled
The large FrameCard preview let the <img> drive height (`flex: 0 0
auto`, `width: auto`, `max-*: 100%`). On wide-container layouts and on
the new V2 1200×1600 dimensions the image's intrinsic size leaked
past the card, and the max-width/max-height combo can drop aspect
ratio in some browsers.

Now: the preview container locks its `aspect-ratio` to
`panelDims(model, orientation)` — same source of truth that drives the
empty-placeholder shape — and the <img> fills the container with
`object-fit: contain`. Container shape is stable whether or not the
thumbnail has loaded; image always scales to fit, portrait or
landscape device, narrow or wide phone column.

emptyAspectStyle no longer needs to carry aspect (parent already has
it); empty-preview placeholder fills 100% of the parent now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:30:16 -04:00
football2801 82a42011d8 fix(upload): persistent file <input> to survive iOS PWA cold launch
CI / test (push) Has been cancelled
A dynamically-created <input type="file"> that's never attached to the
DOM drops its first `change` event on a cold-launched iOS PWA — the
native photo picker resolves out of the original user-gesture context
and the closure that captured the input is gone. Symptom Matt hit
2026-05-14: first image-pick after hard-close + reopen of the PWA
silently failed to advance to the crop tool; the second attempt worked.

HomeView and LibraryView now keep a hidden <input ref="fileInputEl"
type="file"> live in their templates. onAddPhoto clicks that input
inside the user-gesture context; @change fires reliably even on cold
launches. The picker resets input.value between selections so picking
the same file twice still fires.

Tests updated to query the template input via wrapper.find() instead
of stubbing document.createElement; 347/347 frontend tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:02:26 -04:00
football2801 e57e711fcc chore(build): rebuild bundle for v2 crop fix + tighten Device.model
CI / test (push) Has been cancelled
The crop-aspect fix didn't reach production on the prior deploy because
public/build/ was 5 days stale. Rebuilds the SPA bundle so the
panelDims-driven CropEditor / StickerCanvas / FrameCard ship.

Also makes Device.model required in the TS type (was optional in this
session's first cut to placate test fixtures) and adds `model: 'v1'` to
every test Device fixture. A new device row from the API always has a
model, so the type should reflect that — leaving it optional was a trap
for production code that defensively assumed undefined.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:29:12 -04:00
football2801 081ca83613 fix(v2): preview rotation + crop aspect for 13.3" hardware
CI / test (push) Has been cancelled
Two related bugs that surfaced on the first 13.3" device's first photo:

1) Web-UI portrait preview was 90° sideways. DeviceApiController::
   renderBinToPng rotated whenever the device was Portrait — correct
   for V1 (landscape-native, Portrait => renderer rotated, so preview
   un-rotates) but wrong for V2 (portrait-native — the renderer
   doesn't rotate, so the preview shouldn't either). Now mirrors the
   render-pipeline check: rotate only when `orientation !==
   model->nativeOrientation()`. Two new functional tests pin the V2
   portrait and V2 landscape PNG dimensions to guard against
   regressions.

2) Cropped photo letterboxed on the 13.3" panel. CropEditor /
   StickerCanvas / FrameCard had V1 dimensions hardcoded (1600×960
   = 5:3 aspect). V2 is 4:3 (1200×1600 portrait / 1600×1200
   landscape), so a "full crop" came out the wrong shape and the
   server's white-canvas composite added bars. New `panelDims(model,
   orientation)` helper in @/types is the single source of truth on
   the frontend; matches DeviceModel::width/height on the server.
   Threaded `model` through Device serializer → Device type →
   UploadView → CropEditor / StickerCanvas, and HomeView → FrameCard.
   FrameCard tests updated to cover all four model × orientation
   placeholders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:02:39 -04:00
football2801 2adb07518c feat(account): change-password endpoint + Settings modal
CI / test (push) Has been cancelled
PATCH /api/user/password — verifies the current password, enforces
8-char minimum on the new one, and rehashes via the configured
password hasher. Returns 204 on success, 422 with an `error` body
on every validation failure (wrong current, too-short new, missing
fields).

Settings adds a "Change password" link under the Account section
that opens a modal with current/new/confirm fields and posts to the
new endpoint. Confirm-mismatch and submit-disabled wiring is
client-side; backend errors surface inline.

Tests: 4 new controller tests cover success, wrong-current,
short-new, and missing-fields; success path also re-fetches the
user and checks the hash actually changed.
2026-05-09 15:25:54 -04:00
football2801 bdb717de2e chore(build): drop unused imports + rebuild bundle
CI / test (push) Has been cancelled
vue-tsc -b is stricter than --noEmit; the StickerTray emoji input
ref and the StickerCanvas customAssetUrl import were unused.
2026-05-09 15:18:29 -04:00
football2801 5a0db3cd60 fix(uploader,setup): beta-test polish — crop overlay, sticker delete, emoji keyboard, copy
- crop: invert overlay shading; the destination-out trick on a
  semi-transparent fill was leaving the *inside* of the crop more
  transparent than the outside, so the keep-area read as darker
  than the discard-area. Replace with 4 explicit dim-strips.
- stickers: floating trash handle now glues to the selected
  sticker's top-right corner instead of an off-canvas X that
  testers missed.
- stickers: replace the curated grid with an emoji-keyboard
  picker — recently-used row, custom-sprite row (santa hat as
  inline SVG), then an input that pops the OS emoji keyboard.
  Recents persist in localStorage; legacy stickers fall back to
  the old STICKERS table.
- pwa-install modal: drop "browser chrome" — beta tester read it
  as the literal Chrome browser.
- /setup landing page: tighten "Set up your frame" copy.
2026-05-09 15:17:06 -04:00
football2801 00121aaec9 feat(pwa): installable app — manifest + SW + Settings install button
CI / test (push) Has been cancelled
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>
2026-05-09 13:49:12 -04:00
football2801 f777c790fa chore(home): drop the per-frame settings sheet logout link
CI / test (push) Has been cancelled
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>
2026-05-08 18:53:56 -04:00
football2801 08d0968af0 feat(setup): post-link redirects to SPA so first-setup matches live UI
CI / test (push) Has been cancelled
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>
2026-05-08 18:51:31 -04:00
football2801 ff1ae79824 docs(reset): "hold until the screen starts to flash" terminology
CI / test (push) Has been cancelled
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>
2026-05-08 18:41:33 -04:00
football2801 a1a4537c83 fix(home): remove confirmation is now a centered modal popup
CI / test (push) Has been cancelled
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>
2026-05-08 16:25:11 -04:00
football2801 e4f811581a feat(setup): noon-daily default + force-refresh hint + inline remove confirm
CI / test (push) Has been cancelled
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>
2026-05-08 16:19:51 -04:00
football2801 920de623a0 feat(devices): owner can mark a frame as sold and unlink it pre-emptively
CI / test (push) Has been cancelled
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>
2026-05-08 14:53:51 -04:00
football2801 a9ad014bd1 test: tighten coverage to 99.69% backend / 98.62% frontend
CI / test (push) Has been cancelled
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>
2026-05-08 14:22:46 -04:00
football2801 b0773e686e fix(home): hour dropdown reads 1-12 instead of 12,1-11
CI / test (push) Has been cancelled
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>
2026-05-08 12:28:51 -04:00
football2801 91b148c271 fix(home): wake-time list never reorders mid-edit
CI / test (push) Has been cancelled
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>
2026-05-08 12:25:36 -04:00
football2801 bf9d4ebc58 test: close coverage gaps from the recent rotation + Mercure work
CI / test (push) Has been cancelled
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>
2026-05-07 17:25:25 -04:00
football2801 cf6623de67 feat(rotation): per-device image-selection preferences
CI / test (push) Has been cancelled
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>
2026-05-07 16:37:14 -04:00
football2801 ba9625d45d feat(home): live updates via Mercure — server pushes device state to the PWA
CI / test (push) Has been cancelled
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>
2026-05-07 16:20:21 -04:00
football2801 995445ed9e fix(home): "next sync" must reflect the schedule the device is *on*
CI / test (push) Has been cancelled
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>
2026-05-07 15:52:04 -04:00
football2801 eedd50b95c fix(home): "Next update" preview reflects when settings actually reach the frame
CI / test (push) Has been cancelled
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>
2026-05-07 15:28:07 -04:00
football2801 c9b05a53b2 fix(home): force numeric keypad for the interval-minutes input on mobile
CI / test (push) Has been cancelled
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>
2026-05-07 15:11:23 -04:00
football2801 aff0a5d4b4 fix(home): stop iOS Safari zooming on the interval-minutes input
CI / test (push) Has been cancelled
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>
2026-05-07 15:09:12 -04:00
football2801 d11ddff912 feat(device): replace daily wakeHour with multi-time wakeTimes (minutes)
CI / test (push) Has been cancelled
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>
2026-05-07 14:32:58 -04:00
football2801 8beb7331dd fix(home): preview tracks frame state even with locked images and 304 polls
CI / test (push) Has been cancelled
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>
2026-05-06 19:24:50 -04:00
football2801 2cd558bac3 fix(home): preview reflects what's on the frame, not what's queued
CI / test (push) Has been cancelled
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>
2026-05-06 19:15:14 -04:00
football2801 328ad632d3 feat: pull-to-refresh on Home and Library
CI / test (push) Has been cancelled
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>
2026-05-06 19:09:52 -04:00
football2801 ca4595873d fix(frame-card): cap portrait preview to 40dvh so cards stop dominating
CI / test (push) Has been cancelled
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>
2026-05-06 19:00:43 -04:00
football2801 b0fc07b94e feat(home): landscape-phone layout — horizontal carousel of compact cards
CI / test (push) Has been cancelled
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>
2026-05-06 18:54:48 -04:00
football2801 396d4e941f feat(home): replace horizontal carousel with vertical scroll-snap stack
CI / test (push) Has been cancelled
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>
2026-05-06 18:45:02 -04:00
football2801 78405b644d fix(home): card fills the slide; preview uses photo's natural aspect
CI / test (push) Has been cancelled
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>
2026-05-06 18:39:58 -04:00
football2801 d266770170 fix(home): floor frame-card preview to 50dvh so landscape frames feel hero-sized
CI / test (push) Has been cancelled
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>
2026-05-06 18:32:06 -04:00
football2801 089e317691 feat(home): full-size frame card; horizontal carousel for multi-frame setups
CI / test (push) Has been cancelled
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>
2026-05-06 18:28:49 -04:00
football2801 78ff21fb98 fix(home): shrink frame card, three-state status, draggable sheet, label overlap
CI / test (push) Has been cancelled
- 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>
2026-05-06 18:23:35 -04:00
football2801 5fcfb806be feat: ship as a real iOS-installable PWA, restructure bottom nav, fix safe-area
CI / test (push) Has been cancelled
- 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>
2026-05-06 18:07:16 -04:00
football2801 cbb5bb1ff3 feat: surface orientation-mismatch warning in the library
CI / test (push) Has been cancelled
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>
2026-05-06 16:25:55 -04:00
football2801 d31698e7b3 fix: thread cropOrientation into StickerCanvas (was using device orientation)
CI / test (push) Has been cancelled
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>
2026-05-06 15:05:31 -04:00
football2801 52e85703f7 feat: orientation toggle and mismatch indicator in crop editor
CI / test (push) Has been cancelled
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>
2026-05-06 14:45:59 -04:00
football2801 fc0111a18e feat: show currently selected image on home screen frame card
CI / test (push) Has been cancelled
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>
2026-05-06 12:45:06 -04:00
football2801 12245759ac chore: stage all in-progress work before repo split
CI / test (push) Has been cancelled
Web app: new entities (Image, RenderedAsset, SharedImage, Token,
DeviceImageHistory), enums, repositories, controllers, message handlers,
migrations, tests, frontend upload/library/sticker UI, Vue components.

Firmware: EPD background screen binaries + gen scripts, setup_bg header.

Infra: ddev config, test bundle, gitignore coverage dir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:11:31 -04:00
football2801 ff087ee461 fix: set vite base to /build/ so asset paths match the actual serve location
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>
2026-05-04 21:21:51 -04:00
football2801 6c891d6fad feat: orientation model, password confirm, frontend build
- 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>
2026-05-04 16:59:03 -04:00
football2801 6c7c7a1a6f feat(story-2.4): home screen device list with FrameCard component
- FrameCard: large (single device, 5:3 preview + Add Photo CTA) and
  compact (52px thumb + name + count + icon pill) variants; WCAG-
  compliant offline/sync-fail status (color + text, never color alone)
- devices Pinia store: fetchDevices() → GET /api/devices
- HomeView: 0 devices → dashed empty-state card; 1 device → large
  FrameCard; 2+ → compact stack; add-photo wired (Epic 3 stub)
- Fix Device type: rotationInterval → rotationIntervalHours to match API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 00:50:46 -04:00
football2801 15bab87998 feat(story-1.5): theme selection and persistence
- 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>
2026-04-28 00:37:59 -04:00
football2801 a55b3bd187 feat(story-1.2): Vue 3 SPA scaffold, base component library, User entity, SpaController
Frontend:
- Vue 3 + Vite + TypeScript strict in frontend/; builds to public/build/
- Vue Router (hash-history, requiresAuth guard → /login) and Pinia
- Global SCSS design tokens with 6 full themes (Warm Craft, Playful Pop, Sage & Cream, Dusty Mauve, Ocean Dusk, Honey & Slate)
- Base components: BaseButton (5 variants), BaseInput (floating label, error state),
  BaseBottomSheet (slide-up, focus trap, tap-outside dismiss), BaseCard, BaseChip,
  BaseToast (2.5s auto-dismiss, aria-live polite), BottomNav (4 tabs, hides at 960px)
- Type stubs for all API shapes: User, Device, Image, StickerLayer, RenderedAsset, Token

Backend:
- SpaController catch-all serves public/build/index.html; excludes api/setup/token/login/register
- User entity (email+password+roles+theme); UserRepository with PasswordUpgrader
- SecurityController with /login, /logout, /register stubs; Twig login form
- security.yaml: form_login firewall, remember_me, role_hierarchy, access_control
- Migration: create user table

Verified: npm run build succeeds, GET / → 302 /login (unauthenticated)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 23:21:29 -04:00