Files
football2801 21e9173508 docs: add complete epics and stories breakdown
6 epics, 34 stories, 44/44 FRs covered. Includes party mode review
fixes: sticker canvas split into interaction + state persistence stories,
zero-device upload edge case AC, FrameCard offline/sync-fail states,
and scheduler setup story. All stories have Given/When/Then AC and
no forward dependencies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 22:20:44 -04:00

52 KiB
Raw Permalink Blame History

stepsCompleted, inputDocuments, workflowType, project_name, user_name, date
stepsCompleted inputDocuments workflowType project_name user_name date
1
2
3
prd.md
architecture.md
ux-design-specification.md
epics-and-stories pictureFrame Matt.edholm 2026-04-27

pictureFrame - Epic Breakdown

Overview

This document provides the complete epic and story breakdown for pictureFrame, decomposing the requirements from the PRD and Architecture into implementable stories.

Requirements Inventory

Functional Requirements

FR1: Visitors can register a new account with an email address and password FR2: Registered users can log in to their account FR3: Super admin can view, edit, and delete any user account, device, or image across the system

FR4: Users can register a device to their account via the provisioning setup flow FR5: Users can assign a name to each of their devices FR6: Users can configure display orientation (landscape or portrait) per device FR7: Users can configure the image rotation frequency per device FR8: Users can configure the uniqueness window (number of cycles before an image can repeat) per device FR9: When a device is re-provisioned to a new account, the system atomically purges the prior image history and transfers ownership FR10: Super admin can view, rename, reconfigure, and transfer any device across all accounts

FR11: Users can upload photos to their personal image library FR12: Users can view their library filtered by source: Uploaded vs. Shared FR13: Users can soft-delete images from their library FR14: When an image is shared to a user, it appears in their library as a reference (not a copy) FR15: Super admin can add images to and remove images from a global pre-loaded image pool available to all devices

FR16: Users can approve or decline images for a specific device in their account FR17: Users can share an image from their library to another user FR18: When an image is shared, the recipient receives an email with the image and an approve action; clicking approve opens a device-selection page (no login required) where the recipient chooses which device(s) to add the image to FR19: The approval link works from any email client without requiring account creation or login FR20: Approved images enter the active rotation pool for the selected device(s) FR21: Users can approve all images within a collection for a device in a single action FR22: Users can request a full hard delete of their own image, which enters a super-admin review queue FR23: System sends confirmation to the user when their hard-delete request is fulfilled

FR24: Device enters provisioning mode when the reset button is held for 5 seconds FR25: In provisioning mode, device displays a scannable QR code for joining the provisioning access point FR26: User can enter home WiFi credentials through a captive portal served by the device FR27: On successful WiFi connection, device displays a QR code linking to the account setup page for that specific device FR28: On failed WiFi connection, device displays a failure indicator, reactivates the AP, and redisplays the provisioning QR code for retry FR29: Users can register a new account or log in to an existing account from the device setup page to link the device

FR30: System automatically advances to the next approved image on the configured schedule for each device FR31: System tracks image display history per device to enforce the configured uniqueness window FR32: When the uniqueness window exceeds the count of available approved images, the window is treated as equal to the available image count FR33: System pre-renders images to display-ready format per device model and orientation at the time of upload or approval FR34: Device pulls its next pre-rendered image from the server on each scheduled cycle

FR35: Device displays the current image persistently with no power draw between refresh cycles FR36: Device retains and displays the last successfully transferred image through power loss and WiFi outages FR37: Device only updates the display after a complete, confirmed image transfer FR38: Device renders a yellow border when WiFi is connected but the server sync fails FR39: Device renders a red border when WiFi connectivity is unavailable

FR40: Super admin can view a queue of user-submitted hard-delete requests and fulfill or dismiss them FR41: Super admin can force an immediate hard delete of any image in the system FR42: Super admin can view a device ownership transfer audit log FR43: System automatically hard-deletes soft-deleted images that have no remaining approvals via a scheduled background process FR44: Soft-deleted images with at least one active approval are retained until all approvals are removed

NonFunctional Requirements

NFR1 (Performance): Image pre-rendering completes within 10 seconds of upload or approval trigger NFR2 (Performance): Device image pull endpoint returns the pre-rendered binary asset within 10 seconds on typical home broadband NFR3 (Performance): Web application pages load within 3 seconds on a standard broadband connection NFR4 (Performance): Image rotation fires within ±5 minutes of the configured interval

NFR5 (Security): All device-to-server communication occurs over HTTPS NFR6 (Security): Device image pull requests are authenticated by MAC address; server rejects requests from unregistered MACs NFR7 (Security): Email inline approve/decline actions use single-use authorization links that expire after use or after a configurable TTL NFR8 (Security): User accounts are isolated — a user cannot access another user's images or devices without an explicit sharing action NFR9 (Security): Super admin access is restricted to a single designated account NFR10 (Security): Device ownership transfer requires physical access to the reset button

NFR11 (Reliability): A device must never display a blank screen in normal operation — the last successfully transferred image persists indefinitely NFR12 (Reliability): A display refresh only occurs after a complete, confirmed transfer; a mid-transfer interruption leaves the previous image on screen NFR13 (Reliability): Soft-delete and scheduled cleanup run on a scheduled basis without manual intervention NFR14 (Reliability): Breaking API changes are prohibited in V1

NFR15 (Accessibility): All primary user journeys complete successfully on iOS Safari (latest) and Android Chrome (latest) NFR16 (Accessibility): Email sharing flows must work without requiring app installation or login on the recipient's device

Additional Requirements

Architecture-derived technical requirements:

  • Starter template: symfony new pictureframe --webapp — first implementation action
  • DDEV setup: PHP 8.4, nginx-fpm, PostgreSQL 16 — mirror aqua-iq .ddev/config.yaml; add docker-compose.imagick.yaml for Imagick extension
  • Domain first: pictureframe.edholm.me must be established and Nginx configured on VPS before firmware build constants are set
  • Imagick: Must be installed in DDEV container and on VPS before image processing worker can run
  • Symfony Messenger: Doctrine transport, image_processing queue, max_retries: 1
  • Symfony Scheduler: Required for rotation engine and cleanup jobs
  • Storage: storage/images/ directory, STORAGE_PATH env var, relative paths in DB
  • Git / CI: Repository at git.edholm.me; Gitea Actions CI workflow
  • Critical implementation rule: Device pull endpoint returns 204 (no ready image) vs 404 (unknown MAC) — must never confuse the two
  • Token entity: UUID PK, TokenType enum (ShareApprove, ShareDecline, HardDeleteConfirm), expires_at, used_at
  • Enums: PHP backed enums for RenderStatus, TokenType, Orientation
  • Repository naming: findActive* prefix on all soft-delete-aware methods
  • No OTA firmware: API contract is stable by design; breaking changes require physical reflash
  • Frontend stack: Vue 3 SPA (frontend/ directory), Vite + TypeScript strict, SCSS modules per SFC, Konva.js + Vue-Konva for sticker canvas; authenticated app served via SpaController catch-all

UX-derived requirements:

  • WCAG 2.1 AA compliance across all authenticated app flows
  • Mobile-first: iOS Safari + Android Chrome are primary targets; all flows designed touch-first; 44px minimum touch targets
  • Responsive breakpoints: tablet 640px, desktop 960px; library grid 2→3→4 col; bottom nav replaced by top nav at desktop
  • Theming: 6 user-selectable themes (Warm Craft, Playful Pop, Sage & Cream, Dusty Mauve, Ocean Dusk, Honey & Slate), all shipped in V1; SCSS custom property tokens, selected per account
  • Typography: Nunito variable weight; type scale 1128px
  • Sticker canvas: Snapchat-style interaction (tap-to-place, drag-to-move, pinch-to-resize, tap-× to delete); state stored as { id, type, x, y, scale, rotation }; re-editable at any time; SVG sticker assets in 5 categories
  • Crop editor: Instagram-style crop-first funnel; device aspect ratio border always visible in crop UI
  • Bottom sheet pattern: Shared pattern for DevicePicker, StickerTray, ShareSheet; slides up 250ms ease-out, handle pill, tap-outside dismisses
  • Quiet completion: No success modals; BaseToast (2.5s, no dismiss required) for all completions
  • Accessibility: axe-core in dev; VoiceOver (iOS/macOS) screen reader testing; aria-live="polite" on toast; focus managed on route change and sheet open/close; keyboard fallback for all canvas actions
  • Email public flows: Approve/decline email pages are Symfony Twig — no Vue, no JS required; must work in Gmail/Apple Mail/Outlook with images disabled

FR Coverage Map

FR1: Epic 1 — User registration FR2: Epic 1 — User login FR3: Epic 6 — Super admin cross-tenant user/device/image management FR4: Epic 2 — Register device via provisioning setup flow FR5: Epic 2 — Assign device name FR6: Epic 2 — Configure display orientation FR7: Epic 2 — Configure rotation frequency FR8: Epic 2 — Configure uniqueness window FR9: Epic 2 — Device ownership transfer on re-provisioning FR10: Epic 2 / Epic 6 — Admin device view/manage (basic in E2, full cross-tenant in E6) FR11: Epic 3 — Image upload FR12: Epic 3 — Library filtered view (Uploaded vs. Shared) FR13: Epic 3 — Soft-delete image FR14: Epic 5 — Shared image appears as reference in recipient's library FR15: Epic 6 — Global pre-loaded image pool management FR16: Epic 4 — Per-device image approval FR17: Epic 5 — Share image to another user FR18: Epic 5 — Email share with approve link + device-selection page FR19: Epic 5 — Approval link works from any email client, no login FR20: Epic 4 — Approved images enter rotation pool FR21: Epic 4 — Approve all images in a collection FR22: Epic 5 — Request hard delete (enters admin queue) FR23: Epic 5 — Confirmation email when hard delete is fulfilled FR24: Epic 2 — Reset button triggers provisioning mode FR25: Epic 2 — Provisioning QR code on e-ink FR26: Epic 2 — Captive portal WiFi credential entry FR27: Epic 2 — Success QR → /setup/{mac} page FR28: Epic 2 — Failure indicator, AP re-activation, provisioning QR retry FR29: Epic 2 — Register/login from device setup page FR30: Epic 4 — Scheduled image rotation per device FR31: Epic 4 — Uniqueness window tracking per device FR32: Epic 4 — Uniqueness window capped at available image count FR33: Epic 3 — Pre-render images at upload/approval time FR34: Epic 4 — Device pulls pre-rendered image on scheduled cycle FR35: Epic 4 — Persistent image display with no power draw between cycles FR36: Epic 4 — Last image persists through power loss and WiFi outages FR37: Epic 4 — Display only updates after confirmed complete transfer FR38: Epic 4 — Yellow border on sync failure FR39: Epic 4 — Red border on WiFi unavailable FR40: Epic 6 — Admin hard-delete request queue FR41: Epic 6 — Admin force hard delete FR42: Epic 6 — Device ownership transfer audit log FR43: Epic 6 — Scheduled hard-delete of orphaned soft-deleted images FR44: Epic 6 — Soft-deleted images with active approvals retained

Epic List

Epic 1: Project Foundation & User Authentication

Users can register, log in, and access a working application. The project scaffold (Symfony + Vue SPA + DDEV + CI) is in place with the theme system and base component library. FRs covered: FR1, FR2 Also covers: Symfony scaffold, Vue SPA (Vite + TypeScript strict), DDEV setup, Gitea CI, 6-theme SCSS token system, base components (BaseButton, BaseInput, BaseBottomSheet, BaseCard, BaseChip, BaseToast, BottomNav), VPS domain + Nginx config

Epic 2: Device Provisioning & Setup

A frame can be provisioned via two-phase QR flow, linked to an account, named, and configured. After this epic the frame is online and ready to display images. FRs covered: FR4, FR5, FR6, FR7, FR8, FR9, FR24, FR25, FR26, FR27, FR28, FR29

Epic 3: Image Library, Upload & Editing

Users can upload photos, crop them to the device's aspect ratio, add stickers, manage their library, and soft-delete images. The pre-rendering pipeline processes images at upload time. FRs covered: FR11, FR12, FR13, FR33 Also covers: CropEditor, StickerCanvas, StickerTray, PhotoThumb, DevicePicker components; sticker state persistence; library grid + search

Epic 4: Device Image Rotation & Display

Approved images enter the rotation pool and appear on the physical frame. The device pull endpoint serves pre-rendered assets; the rotation engine advances the cycle on schedule. Covers all firmware display and status behavior. FRs covered: FR16, FR20, FR21, FR30, FR31, FR32, FR34, FR35, FR36, FR37, FR38, FR39 Also covers: FrameCard component with all status states; HomeView with device-centric layout

Epic 5: Family Sharing & Email Approval

Users can share images with other users who can approve via email (no login required) or in-app. Shared images appear in the recipient's library. FRs covered: FR14, FR17, FR18, FR19, FR22, FR23 Also covers: ShareSheet, ApproveCard components; email template; Twig approve/decline public pages; token system

Epic 6: Admin & Moderation

Super admin manages the platform: cross-tenant user/device/image management, global image pool, delete request queue, force hard delete, device audit log, scheduled cleanup. FRs covered: FR3, FR10, FR15, FR40, FR41, FR42, FR43, FR44

Epic 1: Project Foundation & User Authentication

Users can register, log in, and access a working application. The project scaffold (Symfony + Vue SPA + DDEV + CI) is in place with the theme system and base component library.

Story 1.1: Backend Infrastructure & Project Scaffold

As a developer, I want the Symfony application scaffold, DDEV environment, and VPS configured, So that I have a working, deployable foundation to build on.

Acceptance Criteria:

Given a machine with DDEV installed When the developer runs the setup commands Then ddev start launches PHP 8.4, Nginx-FPM, PostgreSQL 16, and Imagick And Symfony 8.0 scaffold is initialized via symfony new pictureframe --webapp And symfony/stimulus-bundle, symfony/ux-turbo, and AssetMapper are removed from the project And symfony/messenger with Doctrine transport and symfony/scheduler are installed And storage/images/ exists and is gitignored; STORAGE_PATH env var is set And phpunit.xml.dist is configured and php bin/console runs without errors And a Gitea CI workflow at .gitea/workflows/ci.yml runs composer install + php bin/phpunit on push

Given the VPS and DNS are available When the developer configures Nginx and requests a TLS certificate Then https://pictureframe.edholm.me resolves and returns an HTTPS response And the domain is established and ready to be baked into firmware build constants

Story 1.2: Vue SPA Scaffold & Base Component Library

As a developer, I want the Vue 3 SPA integrated with Symfony and a base component library in place, So that all subsequent authenticated UI work builds on a consistent, accessible foundation.

Acceptance Criteria:

Given the Symfony scaffold is in place When the Vue SPA is initialized in frontend/ and Vite is configured Then vite.config.ts outputs to public/build/ and npm run build succeeds without errors And TypeScript strict mode is enabled; frontend/src/types/ directory exists with stub type files And Vue Router and Pinia are configured; frontend/src/router/index.ts and frontend/src/stores/ exist And SpaController in Symfony serves public/build/index.html as a catch-all for authenticated routes And unauthenticated requests to authenticated routes redirect to /login

Given the SPA scaffold is in place When base components are implemented Then BaseButton (primary, secondary, ghost, destructive, icon-pill variants), BaseInput (floating label, error state), BaseBottomSheet (slide-up, handle pill, tap-outside dismiss), BaseCard, BaseChip, BaseToast (2.5s auto-dismiss, aria-live="polite"), and BottomNav (4-tab: Home / Library / Shared / Settings) all render correctly And all components use scoped SCSS with SCSS custom property tokens And all interactive elements have minimum 44px touch targets And focus management is wired: sheet open moves focus to first element; sheet close returns focus to trigger

Story 1.3: User Registration

As a visitor, I want to create an account with my email and password, So that I can access the pictureFrame app.

Acceptance Criteria:

Given a visitor navigates to /register When they submit a valid email and password Then an account is created with the password stored as a bcrypt hash And the user is logged in automatically and redirected to the home screen (Vue SPA)

Given a visitor submits a registration form When the email address already exists in the system Then an inline error displays below the email field: "An account with this email already exists" And no account is created

Given a visitor submits a registration form When the password does not meet the minimum length requirement Then an inline error displays below the password field describing the requirement And validation fires on blur, not on keystroke

Story 1.4: User Login

As a registered user, I want to log in to my account, So that I can access my frames and photo library.

Acceptance Criteria:

Given a registered user navigates to /login When they enter their correct email and password and submit Then they are authenticated and redirected to the home screen (Vue SPA) And a remember_me session cookie is set to persist the session across browser restarts

Given a user is on the login screen When they enter an incorrect email or password Then an inline error displays: "Incorrect email or password" And no information is disclosed about whether the email exists

Given a logged-in user taps Logout in Settings When the logout action completes Then the session is invalidated server-side And the user is redirected to /login

Story 1.5: Theme Selection & Persistence

As a logged-in user, I want to choose my preferred visual theme, So that the app reflects my personal style.

Acceptance Criteria:

Given a logged-in user navigates to Settings When they select one of the 6 available themes Then the selected theme applies immediately across the entire app without a page reload And the theme is saved to the user's account on the server

Given a user has previously selected a theme When they reload the app or log in on a different browser Then their previously selected theme is applied on load

Given the theme system is implemented When any of the 6 themes (Warm Craft, Playful Pop, Sage & Cream, Dusty Mauve, Ocean Dusk, Honey & Slate) is active Then all text/background combinations meet WCAG AA 4.5:1 contrast ratio And the SCSS custom properties (--color-primary, --color-surface, --color-text, etc.) are updated on the <html> element

Epic 2: Device Provisioning & Setup

A frame can be provisioned via two-phase QR flow, linked to an account, named, and configured. After this epic the frame is online and ready to display images.

Story 2.1: Firmware Phase 1 — AP Mode & Captive Portal

As a frame recipient, I want to connect my new frame to WiFi by scanning a QR code, So that the frame can reach the internet without any technical knowledge.

Acceptance Criteria:

Given the frame is powered on for the first time (or after a 5-second button hold) When it enters provisioning mode Then the e-ink display shows a QR code encoding the AP SSID (PictureFrame-{mac_suffix}) And the device broadcasts a WiFi access point with that SSID

Given the user scans the QR code and joins the AP When their phone opens the captive portal Then a simple page prompts for home WiFi SSID and password only — no account, no server call And tapping Connect attempts to join the provided network

Given the home WiFi credentials are submitted When the ESP32 successfully connects in STA mode Then the e-ink display shows a success message and a new QR code encoding https://pictureframe.edholm.me/setup/{mac} And the AP is deactivated

Given the home WiFi credentials are submitted When the ESP32 cannot connect to the provided network Then the e-ink display fills red And the AP reactivates automatically And the provisioning QR code redisplays — no user action required to retry (FR28)

Story 2.2: Device Setup Page & Account Linking

As a user who has connected their frame to WiFi, I want to link the frame to my account by scanning the setup QR code, So that the frame is registered to me and ready to display photos.

Acceptance Criteria:

Given the user scans the Phase 2 QR code When their browser opens /setup/{mac} Then the page renders as a Symfony Twig page (no Vue dependency, works with JS disabled) And the page shows registration and login options

Given a new user completes registration on the setup page When the form is submitted successfully Then their account is created and they are logged in And the device MAC is linked to their account (FR4) And they are redirected to the device naming step

Given an existing user logs in on the setup page When authentication succeeds Then the device MAC is linked to their account And they are redirected to the device naming step

Given the device MAC is already linked to another account When a new user completes setup Then the MAC→account mapping is atomically updated to the new account And the prior image history for that device is purged (FR9)

Story 2.3: Device Naming & Initial Configuration

As a user who has linked a frame, I want to name my frame and set its display preferences, So that I can tell my frames apart and control how photos rotate.

Acceptance Criteria:

Given a user has just linked a device When they complete the device naming step Then they can assign a human-readable name (e.g. "Margaret's Frame") to the device (FR5) And they can set display orientation: landscape or portrait (FR6) And they can set rotation frequency (e.g. every 6 hours, daily, weekly) (FR7) And they can set the uniqueness window — number of cycles before an image repeats (FR8) And the device record is saved and the frame reboots into normal operation

Given a user saves device settings When they later navigate to the device detail screen in the app Then they can edit the name, orientation, frequency, and uniqueness window at any time And changes take effect on the next rotation cycle

Story 2.4: Home Screen Device List (FrameCard)

As a logged-in user, I want to see my frames on the home screen with a clear action to add photos, So that managing my frames feels immediate and obvious.

Acceptance Criteria:

Given a logged-in user with one linked device navigates to Home When the home screen loads Then a large FrameCard shows the device name, current photo preview (or empty state if none), and a prominent "+ Add Photo" button

Given a logged-in user has two or more linked devices When the home screen loads Then each device is shown as a compact stacked FrameCard with its name, photo count, and "+ Add" pill button

Given a logged-in user has no linked devices When the home screen loads Then a single card-shaped empty state displays: "Set up your first frame" with a QR setup CTA

Given a user taps "+ Add Photo" on a specific FrameCard When the upload funnel opens Then that device is pre-selected throughout the funnel

Given a device has a known status state When the FrameCard renders Then an offline state shows a red border + "Offline" label (no WiFi) And a sync-fail state shows a yellow border + "Sync issue" label (WiFi up, server unreachable) And status is communicated by both color and text — never color alone (WCAG requirement) And in Epic 13 these states render correctly as placeholders; Epic 4 wires live device status data

Story 2.5: Reset Button & Re-Provisioning

As a user who needs to reset their frame, I want to hold the reset button to return to provisioning mode, So that I can reconfigure WiFi or transfer the frame to someone else.

Acceptance Criteria:

Given the frame is in normal operating mode When the physical reset button is held for 5 seconds (FR24) Then the device returns to Phase 1 provisioning mode And the e-ink display shows the AP provisioning QR code And any previously stored WiFi credentials are cleared

Given a device in provisioning mode is claimed by a new account When the setup is completed by a different user Then the server atomically purges the prior image history and links the device to the new owner (FR9) And the previous owner's images are no longer served to that device

Epic 3: Image Library, Upload & Editing

Users can upload photos, crop them to the device's aspect ratio, add stickers, manage their library, and soft-delete images. The pre-rendering pipeline processes images at upload time.

Story 3.1: Image Upload & Pre-Rendering Pipeline

As a user, I want to upload a photo to my library, So that I have images available to add to my frames.

Acceptance Criteria:

Given a logged-in user taps "+ Add Photo" from any context When the system photo picker opens and they select a photo Then the photo is uploaded to the server and stored as an original at storage/images/{id}/original.{ext} And ProcessImageMessage is dispatched via Symfony Messenger for each of the user's device models and orientations And the image appears in the user's library with RenderStatus::Pending while processing

Given ProcessImageMessageHandler consumes the message When Imagick resizes and dithers the image to the device's resolution and 6-color palette Then the pre-rendered binary asset is stored at storage/images/{id}/{device_model}_{orientation}.bin And RenderedAsset.status is set to RenderStatus::Ready

Given a user uploads a photo and has no linked devices When the upload completes Then no ProcessImageMessage is dispatched — rendering is deferred until the image is approved for a specific device And the image appears in the library with no render status indicator

Given image processing fails When the handler throws an exception after max retries (1 retry) Then RenderedAsset.status is set to RenderStatus::Failed And the failure is visible to the super admin

Story 3.2: Crop Editor

As a user uploading a photo, I want to crop my photo to fit my frame's shape, So that the image fills the display without awkward letterboxing.

Acceptance Criteria:

Given a user has selected a photo and the crop screen opens When the crop UI renders Then the device's aspect ratio (800×480 landscape or 480×800 portrait) is shown as a visible frame border And the destination device's name ("Margaret's Frame") is shown in the corner if launched from a specific FrameCard And the user can pinch and drag to fit the photo within the frame boundary

Given a user adjusts the crop When they tap Next Then the crop parameters are stored and the sticker screen opens And the cropped region is preserved for the final render

Given a user's device supports both orientations When they tap the orientation toggle on the crop screen Then the frame border switches between landscape and portrait aspect ratios And the crop is reset to fit the new orientation

Story 3.3: Sticker Canvas — Interaction

As a user editing a photo, I want to place fun sticker overlays on my image and manipulate them with touch gestures, So that I can personalize photos before adding them to a frame.

Acceptance Criteria:

Given a user is on the sticker screen after cropping When they tap the sticker tray icon Then a bottom sheet slides up showing sticker categories (Seasonal · Holidays · Fun · Family · Nature) And stickers scroll horizontally within each category And tapping the canvas or swiping the sheet down dismisses the tray

Given a user taps a sticker from the tray When the sticker is placed Then it appears centered on the Konva.js canvas And the user can drag it to reposition, pinch to resize, and tap × to delete it And multiple stickers can be placed simultaneously

Given a user is on a desktop browser When they interact with the sticker canvas Then drag-to-move is functional with a mouse And scroll-to-resize is available as a fallback for pinch-to-resize And a visible × button on each sticker serves as keyboard-accessible delete

Story 3.4: Sticker Canvas — State Persistence & Re-editing

As a user who has placed stickers on a photo, I want my sticker composition saved and re-editable at any time, So that I can refine my edits without losing work.

Acceptance Criteria:

Given a user places stickers and proceeds past the sticker step When the upload funnel completes Then sticker state ({ id, type, x, y, scale, rotation }) is persisted to the database separately from the rendered output And the PhotoThumb for this image shows a sticker badge indicator in the library

Given a user returns to a previously stickered photo in the library When they open the edit view Then all previously placed stickers are restored to their saved positions, scales, and rotations on the Konva canvas And the user can add, move, resize, or delete existing stickers

Given a user edits and saves a stickered photo When the re-edit is confirmed Then ProcessImageMessage is dispatched to re-render the updated composition for all approved devices And the new rendered asset replaces the previous one in storage/images/{id}/

Story 3.5: Add to Frame (Device Picker)

As a user who has finished editing a photo, I want to choose which frame to add it to, So that the photo enters rotation on the right device.

Acceptance Criteria:

Given a user taps "Add to Frame" at the end of the upload funnel When the DevicePicker bottom sheet opens Then all of the user's linked devices are shown by name with a thumbnail of their current photo And the device the user launched from (if any) is pre-selected

Given a user selects one or more devices and taps Done When the action completes Then the image is approved for those devices and enters the rotation pool (status: ready) And a BaseToast briefly confirms: "Photo added to [Frame Name]" And the funnel closes and the user is returned to the home screen

Given a user has no linked devices When the DevicePicker opens Then an empty state shows: "You don't have any frames yet" with a link to provisioning

Story 3.6: Image Library View

As a logged-in user, I want to browse and manage my photo library, So that I can see what I've uploaded, find specific photos, and remove ones I no longer want.

Acceptance Criteria:

Given a user navigates to the Library tab When the library loads Then photos are displayed in a responsive grid (PhotoThumb components: 2 cols mobile, 3 tablet, 4 desktop) And photos with saved sticker compositions show a badge indicator And the library is filterable by tab: All / Mine / Shared

Given a user types in the library search field When their query matches photo metadata Then the grid filters in real time (300ms debounce) And a "× Clear" affordance appears inline in the search field

Given a user soft-deletes a photo (FR13) When they confirm the deletion Then the photo is immediately removed from their library view And Image.deleted_at is set; the image is excluded from findActive* queries And if the image has active approvals on any device, those approvals are retained and the image remains in rotation on those devices until the last approval is removed (FR44)

Epic 4: Device Image Rotation & Display

Approved images enter the rotation pool and appear on the physical frame. The device pull endpoint serves pre-rendered assets; the rotation engine advances the cycle on schedule. Covers all firmware display and status behavior.

Story 4.1: Per-Device Image Approval

As a logged-in user, I want to approve or decline images for a specific frame, So that I control which photos appear in rotation on each device.

Acceptance Criteria:

Given a user has images in their library When they open an image and tap "Add to Frame" Then the DevicePicker bottom sheet shows their linked devices by name And selecting a device and tapping Done approves the image for that device (FR16) And the image enters the rotation pool for that device with RenderStatus::Ready

Given a user wants to remove an image from a device When they open the image detail and select "Remove from [Frame Name]" Then the approval is revoked and the image is removed from that device's rotation pool And if the image has no remaining approvals and is soft-deleted, it becomes eligible for cleanup

Given a user taps "Approve All" on a collection (FR21) When the action completes Then all images in the collection are approved for the selected device in a single operation And ProcessImageMessage is dispatched for any images not yet rendered for that device model/orientation

Story 4.2: Rotation Engine & Uniqueness Window

As a frame owner, I want my frame to automatically cycle through approved photos on a schedule, So that the frame stays fresh without any ongoing effort from me.

Acceptance Criteria:

Given a device has approved images with RenderStatus::Ready When the RotationSchedule fires at the device's configured interval (FR30) Then RotationService selects the next image that has not appeared within the uniqueness window (FR31) And the device's current_image pointer is advanced to the selected image

Given the uniqueness window is larger than the count of available approved images When the rotation engine selects the next image Then the window is treated as equal to the available image count (FR32) And rotation continues without error

Given a device has no approved images with RenderStatus::Ready When the rotation engine fires Then the current_image pointer is unchanged And no error state is triggered

Story 4.3: Device Pull Endpoint

As an ESP32 device, I want to pull my next pre-rendered image from the server on each scheduled cycle, So that the frame displays a fresh photo without performing any image processing on-device.

Acceptance Criteria:

Given the device sends GET /api/device/{mac}/image When the MAC is registered and a RenderStatus::Ready image is available Then the server returns 200 OK with the binary asset as application/octet-stream (FR34) And the response is served within 10 seconds on typical home broadband (NFR2)

Given the device sends GET /api/device/{mac}/image When the MAC is registered but no RenderStatus::Ready image is currently due Then the server returns 204 No Content — never 404 (FR34, architecture critical rule)

Given the device sends GET /api/device/{mac}/image When the MAC is not registered in the system Then the server returns 404 Not Found And the firmware treats this as a permanent error state (device not configured)

Story 4.4: Firmware Image Display & Atomic Transfer

As a frame recipient, I want photos to appear reliably on my frame and never show a blank screen, So that the frame always looks good regardless of network conditions.

Acceptance Criteria:

Given the device receives a 200 response with a binary asset When the full transfer completes and is confirmed Then the e-ink display refreshes to show the new image (FR37) And the new image is written to persistent storage as the last-known-good image

Given the device is mid-transfer and loses power or WiFi When power or connectivity is restored Then the previous successfully transferred image remains on the display (FR36) And the partial transfer is discarded — no corrupted display state

Given the device has never successfully received an image When it powers on Then the display shows a placeholder or blank e-ink state — a blank screen is acceptable only in this initial state (FR35, NFR11)

Story 4.5: Border Status Indicators & Offline Recovery

As a frame recipient, I want my frame to show me when something is wrong, So that I know whether to troubleshoot or just wait.

Acceptance Criteria:

Given the device wakes for a scheduled pull cycle When WiFi is unavailable (no network connectivity) Then the e-ink display renders a red border around the current image (FR39) And the device returns to deep sleep and retries on the next scheduled cycle

Given the device wakes for a scheduled pull cycle When WiFi is connected but the server is unreachable or returns an error Then the e-ink display renders a yellow border around the current image (FR38) And the device returns to deep sleep and retries on the next scheduled cycle

Given a device previously showing a red or yellow border When the next scheduled pull succeeds and a 200 or 204 is returned Then the border is removed from the display And if 200, the new image is displayed; if 204, the current image remains (no blank screen)

Given a device's pull endpoint returns an error or times out When the device's status is reported back to the server (or inferred from last-seen timestamp) Then the FrameCard in the Vue app shows the sync-fail state (yellow border + "Sync issue" label) And when the device successfully pulls again, the FrameCard returns to its normal state And the offline state (red border + "Offline") is shown when the device has not been seen beyond a configurable threshold

Story 4.6: Scheduler Setup — Rotation & Cleanup

As a developer, I want the Symfony Scheduler configured with rotation and cleanup schedules, So that image rotation and orphaned asset cleanup run automatically without manual intervention.

Acceptance Criteria:

Given the Symfony Scheduler component is installed When the developer runs php bin/console debug:scheduler Then RotationSchedule appears registered, firing at the per-device configured interval (FR30) And ImageCleanupSchedule appears registered, firing on its configured periodic interval (FR43)

Given the worker process is running (php bin/console messenger:consume) When a device's rotation interval elapses Then RotationService advances the current_image pointer for that device And the scheduler does not fire rotation for devices with no ready images — no error is thrown

Given the cleanup schedule fires When it finds soft-deleted images with no remaining approvals Then it hard-deletes those images and their storage assets (FR43) And images with at least one active approval are skipped (FR44)

Epic 5: Family Sharing & Email Approval

Users can share images with other users who can approve via email (no login required) or in-app. Shared images appear in the recipient's library.

Story 5.1: Share an Image to Another User

As a logged-in user, I want to share a photo from my library with another person, So that they can add it to their frame.

Acceptance Criteria:

Given a user is viewing a photo in their library When they tap Share Then the ShareSheet bottom sheet opens with a search field auto-focused And the field searches connected family members by name and email simultaneously And matching results appear as tappable rows with avatar initial and name

Given a user selects a recipient and taps Send When the share action completes Then the server creates a Token of type ShareApprove and ShareDecline with a configurable TTL And a share notification email is sent to the recipient containing the image preview and an Approve button (FR17) And a BaseToast confirms: "Photo shared with [Name]"

Given a user attempts to share a photo When the recipient's email is not in the system Then the user can enter an email address directly to invite them And the share email is sent to that address with a registration prompt alongside the approve link

Story 5.2: Email Approve Flow (No Login Required)

As a photo recipient, I want to approve a shared photo by tapping a link in my email, So that I can add it to my frame without needing to log in or install anything.

Acceptance Criteria:

Given a recipient receives a share email When they tap the Approve button in any email client Then their browser opens /token/{uuid}/approve — a Symfony Twig page with no Vue, no JS required (FR19) And the page renders correctly with images disabled and is screen reader accessible (WCAG AA)

Given the recipient is on the device-selection page When the token is valid and unused Then their linked devices are shown by human name — no technical identifiers (FR18) And they can select one or more devices and tap Done without logging in

Given the recipient taps Done on the device-selection page When the token is consumed Then the token's used_at is set, marking it as single-use And the image enters the approved rotation pool for the selected device(s) (FR20) And ProcessImageMessage is dispatched for any device models not yet rendered And a confirmation message is shown: "Photo added to [Frame Name]"

Given a recipient taps the Approve link after the token has expired or already been used When the page loads Then a friendly message explains the link is no longer valid And no approval action is taken

Story 5.3: Email Decline Flow

As a photo recipient, I want to decline a shared photo from my email, So that I can keep unwanted photos off my frame without logging in.

Acceptance Criteria:

Given a recipient taps Decline in the share email When their browser opens /token/{uuid}/decline Then the page renders as a Symfony Twig page with no login required And the token is consumed and used_at is set

Given a user declines a photo When the decline is processed Then the image is not added to any device And the image does not appear in the recipient's library

Given a token has already been used or expired When the decline link is tapped Then a friendly message explains the link is no longer valid

Story 5.4: In-App Approval & Shared Library Tab

As a logged-in user, I want to approve or decline shared photos from within the app, So that I can manage incoming shares without checking my email.

Acceptance Criteria:

Given a user has pending shared photos When they navigate to the Library → Shared tab Then each pending photo is shown as an ApproveCard with Approve and Decline actions inline And the Shared tab in BottomNav shows a numeric badge for the count of pending approvals

Given a user taps Approve on an ApproveCard When the DevicePicker bottom sheet opens and they select a device Then the image enters the approved rotation pool — identical outcome to the email approve flow (FR16, FR20) And the ApproveCard is removed from the Shared tab immediately

Given a user taps Decline on an ApproveCard When they confirm the inline confirmation Then the image is removed from the Shared tab And it does not appear on any device

Given a shared photo is approved via the email link When the user later opens the Shared tab Then the approved photo no longer appears as pending — the two approval paths are in sync

Story 5.5: Shared Image in Recipient's Library

As a user who has received and approved a shared photo, I want to see it in my library, So that I can manage it like my own photos.

Acceptance Criteria:

Given a user approves a shared image (via email or in-app) When they view their library Then the image appears in the Shared tab filtered view (FR12, FR14) And it is stored as a reference to the original — not a copy And it shows the sharer's name as the source

Given a user views a shared image in their library When they open the image detail Then they can add it to additional devices, re-edit stickers, or remove it from a device And they can request a hard delete if they want it permanently removed (FR22)

Story 5.6: Hard Delete Request

As a user, I want to request permanent deletion of one of my photos, So that it is fully removed from the server and all devices.

Acceptance Criteria:

Given a user opens an image detail in their library When they tap "Request permanent deletion" Then an inline confirmation asks them to confirm the irreversible action And on confirmation, a hard-delete request is created and enters the super admin review queue (FR22) And a BaseToast confirms: "Deletion request submitted"

Given a super admin fulfils the hard delete request When the deletion is processed Then the system sends a confirmation email to the requesting user (FR23) And the image is removed from all device rotation pools and from storage

Given a hard delete request exists for an image When the image still has active approvals on devices Then the admin is shown which devices still have the image approved before confirming And the force delete removes all approvals and the file from storage (FR41)

Epic 6: Admin & Moderation

Super admin manages the platform: cross-tenant user/device/image management, global image pool, delete request queue, force hard delete, device audit log, scheduled cleanup.

Story 6.1: Super Admin User & Device Management

As a super admin, I want to view and manage all user accounts and devices across the system, So that I can support users and maintain the health of the platform.

Acceptance Criteria:

Given the super admin navigates to the admin panel When the Users section loads Then all registered user accounts are listed with email, account creation date, and device count And the super admin can view, edit, and delete any user account (FR3)

Given the super admin views a user's account When they navigate to that user's devices Then all devices linked to that account are shown with name, MAC address, orientation, and rotation config And the super admin can rename, reconfigure, or transfer any device to another account (FR10)

Given the super admin transfers a device to a new account When the transfer is confirmed Then the device's MAC→account mapping is updated atomically And the device's image history is purged And the transfer is recorded in the device ownership audit log (FR42)

Story 6.2: Device Ownership Transfer Audit Log

As a super admin, I want to view a history of device ownership transfers, So that I can track which accounts have owned each device over time.

Acceptance Criteria:

Given the super admin navigates to a device's detail page When they open the audit log Then all ownership transfer events are listed with timestamp, previous account, and new account (FR42)

Given a device is re-provisioned by a user via the physical reset button When the new account claims the device Then a transfer event is automatically recorded in the audit log And it is attributed to the physical reset action

Story 6.3: Global Pre-Loaded Image Pool

As a super admin, I want to manage a global pool of images available to all devices, So that new frames have content to display before family members have uploaded photos.

Acceptance Criteria:

Given the super admin navigates to the Global Images section When they upload an image Then the image is stored in the global pool and pre-rendered for all active device models and orientations (FR15) And ProcessImageMessage is dispatched for each device model/orientation combination

Given the super admin removes an image from the global pool When the removal is confirmed Then the image is removed from the global pool and from all device rotation pools that sourced it from the global pool And pre-rendered assets are cleaned up from storage

Given a device has no user-approved images in its rotation pool When the rotation engine selects the next image Then global pool images are eligible as fallback content

Story 6.4: Hard Delete Request Queue

As a super admin, I want to review and action user-submitted hard delete requests, So that I can permanently remove images users no longer want on the server.

Acceptance Criteria:

Given the super admin navigates to the Delete Requests section When the queue loads Then all pending hard delete requests are listed with the requesting user, image preview, submission date, and current approval status across devices (FR40)

Given the super admin reviews a request and clicks Fulfil When there are no remaining active approvals on any device Then the image binary assets and original are deleted from storage/images/{id}/ And the Image record is hard-deleted from the database And a confirmation email is sent to the requesting user (FR23, FR41)

Given the super admin reviews a request and clicks Fulfil When the image still has active approvals on one or more devices Then the admin is shown which devices still have the image approved And confirming removes all approvals before hard-deleting (FR41)

Given the super admin dismisses a request When the dismissal is confirmed Then the request is removed from the queue And the image is not deleted — it remains in the user's library

Story 6.5: Scheduled Image Cleanup

As the system, I want to automatically hard-delete orphaned soft-deleted images, So that no unused assets accumulate on disk without manual intervention.

Acceptance Criteria:

Given the ImageCleanupSchedule fires on its configured interval When it finds images where deleted_at IS NOT NULL and no active approvals remain (FR43) Then the image binary assets and originals are deleted from storage And the Image record is hard-deleted from the database

Given a soft-deleted image still has at least one active approval on a device When the cleanup job runs Then the image is not deleted — it is retained until the last approval is removed (FR44)

Given the cleanup job encounters an error deleting a specific image When the error occurs Then the job logs the failure and continues processing remaining images And the failed deletion is retried on the next scheduled run

Story 6.6: Super Admin Image Moderation

As a super admin, I want to view and force-delete any image across all accounts, So that I can remove harmful or inappropriate content from the platform.

Acceptance Criteria:

Given the super admin navigates to the Images section When they search or browse across all accounts Then all images in the system are listed with owner, upload date, approval count, and render status (FR3)

Given the super admin selects an image and clicks Force Hard Delete When they confirm the action Then all approvals for that image are revoked across all devices And the image is removed from all rotation pools immediately And the binary assets and original are deleted from storage (FR41) And no confirmation email is sent for admin-initiated force deletes (only user-requested deletes trigger FR23)