--- 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 `` 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)