---
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 11–28px
- **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 1–3 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)
