Mockup vs live diff revealed:
- Side rail brand area: mockup has italic '— home/library/settings' subtitle
below WeVisto, live had none. Added via reactive activeSub computed.
- Side rail footer: mockup has 'a frame, gifted · v 0.4' italic at the
bottom of the rail. Added top-nav__foot element; hidden in horizontal
layouts, shown in v2 side rail via design-v2.scss
- Side rail mark size: bumped from 36px to 44px to match mockup
- Theme swatch harbor preview wasn't winning the cascade fight against
Vue scoped styles (equal-specificity tie, Vue cascades later). Adding
!important on the few preview properties — v2 is opt-in so this is
appropriate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v2 tokens were duplicated: in design-v2.scss for the SPA, inlined in
login.html.twig for Twig. Two places to keep in sync.
Now: one shared /public/css/wevisto-design.css loaded by every Twig
standalone template AND linked from the SPA index.html. It contains:
- Brand constants (yellow / navy / fonts)
- v2 tokens with per-theme dusk overrides
- v2 base body bg + editorial typography defaults
- v2 overrides for the .card / .btn / .field-error / .logo-badge
patterns used across all Twig templates
The SPA's design-v2.scss now holds only SPA-specific composition:
side rail at desktop, frame card, theme swatch harbor preview,
settings polish. No token duplication.
Result: changing a v2 color in one file flows to every surface in both
worlds. Adding v2 to another Twig template only requires the existing
shared CSS link (already wired up to all 11 standalone templates).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v2 was 'harbor backdrop everywhere + glass cards', but the approved
mockups at _design/atmospheric-redesign/ use solid navy with subtle
gradient, harbor selectively (theme swatches, frame heroes), left
side rail at desktop, and editorial Marcellus/Cormorant typography.
This rewrite:
- Drops the full-page harbor backdrop; body is now solid navy with
a single radial gradient highlight
- Loads Marcellus, Cormorant Garamond, DM Mono via Google Fonts
- Editorial type recipes: h1/h2/h3 + frame card name + settings title
use Marcellus. settings__hint becomes italic Cormorant. Section
labels become DM Mono caps with 0.28em letterspacing
- TopNav restyled at desktop (≥960px) into a left-fixed side rail:
240px wide, vertical stack of nav items, active item shows inset
yellow rule + surface bg. Body gets 240px padding-left to shift
content right.
- Theme swatches reuse the harbor.jpg inside their preview area,
tinted to each dusk's color — matches the mockup exactly
- Per-dusk surface colors made opaque (was rgba 0.55) so cards are
fully readable
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iOS / Chrome use background_color to fill any padding when displaying
the install icon. Cream (#fdf6ee) was leaving a visible 'thick white
border' around the V-viewfinder when the browser auto-padded the icon
on the install-preview screen. Setting background to navy (#0e2740) —
matching the icon's outer fill — makes any auto-padding invisible.
Theme_color also updated for consistency on the install splash.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The V-viewfinder was approved during the favicon-v2 picker at
/v2/, then deployed prematurely (e7b9756) and reverted (81effca) after
the 'design pick is not a deploy command' lesson. Deploying it now with
explicit go-ahead.
Files: yellow V outline with the Camogli harbor visible inside, navy
field outside. Replaces the split-W (two Vs forming a W) across:
- favicon-16/32/64
- apple-touch-icon (180)
- icon-192 + icon-512 manifest icons
- icon-512-maskable (V at 65% safe zone)
- favicon.svg vector
- favicon.ico multi-res
- root-level apple-touch-icon{,-precomposed}.png for iOS fallback paths
Cache-bust query bumped to ?v=20260515-vviewfinder so browsers refetch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iOS Safari caches the apple-touch-icon per origin and ignores byte-level
changes on the same URL. Adding a version query forces a refetch on
fresh visits without renaming the source files. Buttressed across all
standalone Twig templates and the SPA index plus the manifest icons so
Chrome desktop also refetches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Active-theme-only override made all six swatches show the same amber tint
in v2. Per-swatch rules via aria-label now give each its own dusk preview.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First-cut v2 had transparent cards (alpha 0.55) that disappeared into the
harbor backdrop, plus the v1 cream chrome leaked through.
This pass:
- Bumps --color-surface / surface-2 to 0.85 alpha across every dusk so
cards stay readable on top of the photo
- Darkens backdrop with a vignette + bumps --color-bg overlay to 0.70
- Adds [data-design=v2] chrome rules: top-nav/bottom-nav glass, theme
swatches use dusks tokens, design-toggle cards become glass, install
button keeps accent fg color, frame-card gets inset highlight
- Brightens --color-text-muted in each dusk for legibility
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets users opt into the new atmospheric design without affecting users on v1.
Adds a beta-flag toggle in Settings → Design. Server-side preference persists
across devices; a cookie mirrors it so unauthenticated Twig pages do correct
first-paint without an extra DB roundtrip.
Backend:
- User.designVersion column (nullable VARCHAR(10); null defaults to 'v1')
- Migration Version20260515120000
- PATCH /api/user/design endpoint accepting 'v1'|'v2', sets wevisto_design cookie
- SpaController injects data-design on <html> + refreshes the cookie on every
SPA load (keeps cross-device pref in sync)
- Twig templates (base, login, register, help, setup, token-*) read the
cookie via {{ app.request.cookies.get('wevisto_design')|default('v1') }}
so login/setup pages also respect the user's design choice
Frontend:
- design-v2.scss — opt-in overlay scoped under [data-design="v2"]. Overrides
--color-* tokens to dusk variants per theme (warm-craft → amber, ocean-dusk
stays, etc.), adds harbor photo backdrop via body::before with theme tint
via body::after. Glass-card blur on existing surfaces. v1 untouched.
- harbor.jpg shipped as a public asset (270KB, single-fetch, cached)
- User type gains designVersion ('v1' | 'v2')
- SettingsView toggle (Original / Atmospheric) calls the API, updates the
data-design attribute optimistically, reverts on failure
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The existing PWA layout was mobile-first only: BottomNav hides at ≥960px
with no replacement, leaving desktop users with zero navigation and views
that stretch to viewport width. Fixes both:
- New TopNav.vue mirrors BottomNav (Home / Library / Settings) but renders
as a top horizontal app bar at ≥960px only. Includes the wordmark + mark.
- App.vue includes <TopNav v-if="!route.meta.hideNav" /> alongside BottomNav
so upload-flow hideNav: true still hides both.
- HomeView, LibraryView, SettingsView get desktop max-widths (820 / 1100 /
720 respectively) so content centers instead of stretching to 1440+.
Same cream/terracotta theme tokens, no aesthetic change — just gives v1
proper desktop chrome. Prep for v2 opt-in landing next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picked 3a from the favicons-and-logo-v2 iteration: a yellow V cut out of
navy with the harbor photo visible inside — "you are looking at a
photograph framed by the V". The brand's own glyph rather than initials.
What changed:
- favicon-16 / -32 / -64 / icon-192 / icon-512 / apple-touch-icon (180):
V at 86% of canvas, navy outside, full center-cropped harbor inside,
yellow stroke-outlined border proportional to size.
- icon-512-maskable: V at 65% of canvas (inside the Android safe zone),
navy in the outer 35% so circle/squircle launcher masks crop navy
pixels, not the V.
- favicon.svg: lightweight vector — yellow V outline on navy, no embedded
photo (kept under 300 bytes so it's fast even before the build cache).
- favicon.ico: multi-resolution 16/32/64 for legacy clients.
Root-level fallbacks (public/apple-touch-icon.png + -precomposed +
favicon.{svg,ico}) updated in lock-step so iOS's Add-to-Home-Screen
probes pick up the new icon without falling back to a cached old one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iOS Safari's Add-to-Home-Screen flow probes /apple-touch-icon.png at the
site root in addition to the <link rel> on the page. Those root paths
currently 302 through Symfony's auth firewall to /login, so iOS gets HTML
where it expects a PNG and falls back to whatever it cached from earlier
installs (the 1 KB placeholder icon). Dropping the real PNG (and the
-precomposed alias) directly in public/ makes nginx serve them as static
files, ahead of the firewall.
Also adds favicon.svg and a multi-size favicon.ico at the root for
browsers/bots that probe / paths instead of reading <link>, and adds
sizes="180x180" to every apple-touch-icon link so iOS doesn't have to
guess.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New mark: solid "W" glyph color-split left=white / right=yellow over the
Camogli harbor photo from logo.svg; right half reads as a "V" so the W
alone communicates "WeVisto" at icon scale where the wordmark is illegible.
- PWA icons (192, 512, apple-touch 180) rendered full-bleed; maskable
variant shrinks the W to the inner 65% so circle/squircle launcher masks
crop sky and harbor pixels, not the glyph.
- Adds favicon-16/32/64 PNGs and replaces the old purple-star favicon.svg
with a lightweight vector split-W on solid navy.
- Wires the new favicons into both the SPA (frontend/index.html) and the
Symfony Twig base (templates/base.html.twig), replacing the Symfony
default "sf" emoji data-URL placeholder on the login page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- frontend/public/logo.svg: Camogli photo with We[V]isto knockout wordmark
(yellow V accent), embedded base64 so the SVG is self-contained
- brand/: raw source (15.7MB Camogli original) + 900x900 crop used in the
SVG, plus a short README documenting both
- Login, register, setup index/configure, help: linked logo badge above
the page heading
- Email template: logo bumped to 64x64 (was 30 tall — wordmark unreadable)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds frontend/public/logo.svg as a placeholder (rendered at /build/logo.svg
after Vite build). Email template share_notification.html.twig swaps the
text "WeVisto" header for an <img> referencing /build/logo.svg via
absolute_url, so dropping in the final design swaps one file with no
template change.
bin/smoke.sh HOST now defaults to wevisto.com — legacy host still smoke-
testable via HOST=pictureframe.edholm.me bin/smoke.sh under the dual-
domain coexistence (Option C).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updated: SPA <title>, PWA manifest name/short_name, iOS web-app title,
"Install"/"Pin to home screen" copy, HomeView empty state, all Twig page
titles (login/register/setup/token/help), and the share-notification
email header. Left alone: the firmware-broadcast SSID PictureFrame-XXXX
(coordinated firmware change needed) and internal code/comment refs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mercure topic identifiers updated in lockstep across PHP publisher + TS
subscriber (and their tests). Help-page setup instructions now point to
wevisto.com. Traefik already serves both hosts; this aligns the in-app
references with the public brand.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Old copy: "Switch the frame in Settings to display this crop."
That only surfaces one of the two ways out, and the less common one.
New copy: "Use the tool above to recrop for the current frame
orientation, or switch the frame in Settings to display this crop."
Recrop-here is the cheaper and usually-correct fix; settings-flip is
the fallback when the user really does want the other orientation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matt called out the row was confusing: lock pill said "Rotate" (sounds
like a verb), and the toggle's purpose wasn't obvious.
- Drop the "Rotate" word entirely. Lock pill is icon-only when
unlocked, shows "Locked" + closed padlock when locked.
- Hide the lock pill entirely when the photo isn't approved on the
frame (instead of rendering a disabled one) — keeps the row clean
and reinforces that locking requires approval first.
- Add a tiny "Show" / "Hidden" label above the toggle so the meaning
reads before the user taps. Toggle is now the visual primary on
the row.
- Re-label aria-text from "Add/Remove" to "Show/Hide" to match the
visible copy.
Test "disables the lock pill when not approved" → "hides the lock pill
when not approved". 358/358 still passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Library was rendering one approval chip per device per photo PLUS one
lock chip per approved device. That's O(photos × devices) buttons —
fine at one or two frames, breaks at four+ (see
_bmad-output/.../library-many-frames-design-ideas.md).
Concept A from the design memo:
- Each photo card stays a square thumb + a single "Manage" row.
- Manage row summarises state: "3/5 frames · 🔒 Mom's Place".
- A corner-lock badge sits on the thumb itself when any frame has the
image locked, so the lock status is glanceable from the grid.
- Tapping Manage opens the new ManageImageSheet bottom sheet, which
lists every frame with an approve toggle + per-frame lock pill.
Lock pill is disabled until the frame is approved.
Per-photo widgets drop from O(photos × devices) to O(photos). Works
identically at 1 or 50 frames. Curation principle stays "manage photos
TO the frame" — same store calls (imagesStore.setApproval,
devicesStore.lockImage/unlockImage), just routed through the sheet
instead of inline chip rows.
10 new ManageImageSheet unit tests + LibraryView tests rewritten to
cover the sheet-open + event-forwarding flow. 358/358 frontend tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Library thumbs were locked to 4:3 with object-fit: cover, so portrait
photos got their top and bottom crop-fitted off. Switching to 1:1
cells with object-fit: contain — full photo always visible, grid still
uniform, portrait and landscape both get symmetric letterbox bars.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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.
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>
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>
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>
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>
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>
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>
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>
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>