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>
Iteration after the atmospheric-redesign checkpoint. The earlier A/B/C/D
favicon set was scrapped — generic invented icons (roof, frame+horizon,
skyline, wax seal) that didn't tie to the brand's own glyph. New direction:
the yellow V from the wordmark, presented four ways. V-viewfinder (a V cut
out of navy showing the harbor photo inside) is the chosen path; refinement
3b crops to Camogli's coloured row-houses for a more brand-specific small
size reading.
Also adds three logo-placement mockups (PWA cold-launch splash, library
hero with the full wordmark logo, settings → about page) to give the
wordmark room to live beyond emails and login badges.
Self-contained: assets/ and spa/ copied into the design folder so the
mockups render without depending on neighbouring directories.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-contained HTML/CSS mockups proposing the brand-as-surface direction:
- Camogli harbor photo as the permanent backdrop on every authenticated view
- Six user themes rebuilt as atmospheric "dusks" (tinted overlays + accents)
instead of cream variants
- Frosted-glass cards on top of the photo
- Same type system (Marcellus / Cormorant Garamond italic / Nunito body)
shared between login and in-app
Includes:
- login-cinematic.html: canonical pre-auth example with Ken-Burns + grain
- spa/home.html, library.html, settings.html: in-app views with the new chrome
- spa/_tokens.css + _chrome.css: dusk system and frosted-glass primitives
- favicons/A-D: in-progress icon directions
- README.md with serving instructions and the porting checklist
Saved as a durable design artifact so the iteration survives /tmp wipes
and can be referred back to when porting to Vue.
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>
Login, register, help, token-approve/decline, and setup pages each have
their own <head> (don't extend base.html.twig), so updating base alone
left them without favicon refs. Adds the same four <link> tags after the
viewport meta in each standalone template.
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>
Coordinated with firmware rename of the provisioning AP from
PictureFrame-XXXX to WeVisto-XXXX. Recipients seeing the help page
mid-setup now see the same SSID name their phone WiFi list shows.
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>
Aligns the default sender with the public brand. Production mailer is
currently null:// so no live email is sent, but the From: header is now
consistent with the rest of the rebrand.
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>
When the firmware sends X-Draw-Pending: 1, its drawNeeded NVS flag
survived a power-loss-during-draw — it has the bytes for the previous
image in its cached /img.bin and just needs another chance to finish
painting them. Return the device's current image (no rotation advance),
which lands as a 304 since the device claims the same image-id.
Crucially this overrides the X-Boot-Reason: cold force-resync. The
typical mid-draw-interruption cause IS a reset that turns the next
wake into a cold boot, so without this override force-resync chases
a fresh image every interruption and the device cycles through the
rotation leaving torn frames on the 13.3 panel.
Locked image still wins (user intent overrides recovery). Old firmware
that doesn't send the header is unaffected — branch is gated on the
header being present.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a test for ManageImageSheet's 200ms pendingApproval lock-release
(prevents the toggle becoming permanently disabled on a single tap),
expand SettingsView coverage to exercise the beforeinstallprompt event
path through usePwaInstall (accepted + dismissed outcomes), and add a
first pass of StickerTray tests.
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>
Renders the five design concepts from
_bmad-output/planning-artifacts/library-many-frames-design-ideas.md as
standalone HTML so Matt can compare them in a browser without spinning
up the SPA. Mockups live at /mockups/library/ and reuse the project's
design tokens for visual consistency:
index.html landing page with concept links
current.html the chip-explosion state we're shipping today
concept-a.html photo + status badge → DevicePicker sheet (recommended)
concept-b.html device-first tab
concept-c.html multi-select bulk action bar
concept-d.html device chip filter + photo dots
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>
Tried six experiments tuning saturation, gamma, blur, blue-channel
multiply, BLUE palette target, and WHITE palette target — each had
side effects worse than the original sky→face blue bleed we were
trying to fix (greens lost vibrance, sky went green, shadows got
more blue, etc). The baseline pipeline is the local maximum for now;
the 6-colour Spectra-6 palette is the real ceiling.
Drops RenderCompareCommand and restores RenderImageMessageHandler
verbatim from git tag render-baseline-2026-05-14.
Future render-quality work should attack the problem differently —
Lab-space color matching, semantic preprocessing, or a measured
panel-ink palette — not single-knob RGB tweaks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#5 (BLUE → [8,32,220]) made sky map to WHITE which dithered greenish.
Reverting BLUE to baseline (24,64,192).
#6: warm WHITE from (245,245,240) → (255,248,230). Slight cream shift
pulls warm-toned skin pixels toward WHITE in the nearest-color match
before error diffusion has a chance to drift them toward BLUE. Sky
mapping unchanged (still maps to BLUE). Cost: real off-white pixels
might quantize toward a slightly warmer cream — should be subtle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Experiment #4 (blue×0.95) made shadow regions appear MORE blue, not
less — reducing source blue still leaves positive blue error after a
BLACK mapping, and the dither spends that error on neighbours,
creating blue dither dots in dark regions.
Reverting blue_mul to 1.0. Experiment #5 takes a different attack on
the same problem: shift the BLUE palette mapping target from the
muted (24, 64, 192) to a more saturated (8, 32, 220). Doesn't change
what the panel displays (the blue ink is fixed); it just makes
Euclidean distance from skin tones to "BLUE" larger in the algorithm's
view, so the dither prefers RED/WHITE/YELLOW for borderline pixels.
Render-compare's BASELINE struct now carries its own frozen palette,
so half-A keeps the original (24,64,192) BLUE target while half-B
pulls the shifted palette from the live pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Experiment #3 (sat 115, gamma 1.2, blur 0.6) was a net loss — greens
desaturated, no help on sky→face blue bleed.
Reverting those three to baseline (130, 1.0, 0.0). New experiment #4:
multiply the source's blue channel by 0.95 before dither. Real sky
stays well above the BLUE-vs-WHITE boundary, but borderline-bluish
skin (sky cast on outdoor faces) drops below it and maps to YELLOW /
WHITE / RED instead of feeding the dither's BLUE attractor.
Adds public BLUE_CHANNEL_MUL constant; render-compare's baseline
struct gets blue_mul=1.0 so half-A is still frozen at the original.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Riemersma dither produced visible "ink-spill" Hilbert-curve streaks in
low-contrast regions like skin. Reverting DITHER_METHOD to Floyd-
Steinberg and attacking the original sky-bleeds-into-face problem with
a pre-dither Gaussian blur instead:
DITHER_METHOD RIEMERSMA → FLOYDSTEINBERG (back to baseline)
+ new BLUR_SIGMA = 0.6 (pre-dither softening; baseline 0.0)
Live tunables are now public so RenderCompareCommand can mirror them.
Half-A of app:render-compare is locked to the frozen-baseline set
(sat=130 gamma=1.0 sharpen=0.8 blur=0.0 FS) — what shipped at git tag
render-baseline-2026-05-14. Half-B always tracks the live pipeline, so
each future experiment is automatically the next A/B without touching
the comparison command.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stacks two dither treatments of the same image's top half into one V2
portrait .bin — top half Floyd-Steinberg, bottom half Riemersma —
overwrites the device's current V2 portrait asset, bumps rendered_at
and clears the preview PNG cache.
Usage: bin/console app:render-compare <imageId>
Lets Matt eyeball both methods on a single panel refresh instead of
two re-renders and two waits. One-shot experimental tool; not part of
the live render pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Floyd-Steinberg's row-order error diffusion was flushing residual blue
debt from sky pixels downward into the face below — visible as a blue
hue in skin tones that should have been YELLOW/RED/WHITE in the 6-color
palette. Riemersma uses a Hilbert-curve scan, so error stays local and
isn't biased along any axis.
DITHER_METHOD FLOYDSTEINBERG → RIEMERSMA (gamma 1.2 + saturation 115
from experiment #1 unchanged).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Make the render-pipeline knobs Matt and I are about to iterate on
visible as class constants on RenderImageMessageHandler — single source
of truth, easy diff per change.
First experiment (vs baseline git tag render-baseline-2026-05-14):
SATURATION_PCT 130 → 115 (less risk of ruddy faces / synthetic skies)
GAMMA 1.0 → 1.2 (gentle midtone lift; faces + shadows
climb out of the BLACK cluster after dither)
Sharpening (0.8) and Floyd-Steinberg dithering unchanged this round.
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>
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>
Adds the second panel model alongside V1 (800x480, 7.3"). V2 is
1200x1600 panel-native (tall) — the inverse aspect ratio means
its "natural" orientation is portrait, not landscape:
- DeviceModel::nativeOrientation() — V1 returns Landscape, V2 returns
Portrait. Render rotates the source image 90 CCW only when the user's
orientation differs from the panel's native, so the .bin stays
panel-native scan order without per-model branches.
- DeviceModel::panelId() / fromPanelId() — string mapping for the
firmware's X-Panel-Id header (matches -DPANEL_ID build flag).
- DeviceImageController: on every poll, if X-Panel-Id maps to a known
model and differs from the device's current model, auto-correct.
New Devices are created with the V1 default, so a freshly-claimed
13.3" unit needs this correction before the first image render
produces a wrong-dimension .bin the firmware would reject.
8 new DeviceModel unit tests, 3 new controller tests cover the
header-correction behaviour (different, same, unknown panel-id).
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.
- 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.
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>