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

1040 lines
52 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
stepsCompleted: [1, 2, 3]
inputDocuments: ['prd.md', 'architecture.md', 'ux-design-specification.md']
workflowType: 'epics-and-stories'
project_name: 'pictureFrame'
user_name: 'Matt.edholm'
date: '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)