6 epics, 34 stories, 44/44 FRs covered. Includes party mode review fixes: sticker canvas split into interaction + state persistence stories, zero-device upload edge case AC, FrameCard offline/sync-fail states, and scheduler setup story. All stories have Given/When/Then AC and no forward dependencies. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
52 KiB
stepsCompleted, inputDocuments, workflowType, project_name, user_name, date
| stepsCompleted | inputDocuments | workflowType | project_name | user_name | date | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
epics-and-stories | pictureFrame | Matt.edholm | 2026-04-27 |
pictureFrame - Epic Breakdown
Overview
This document provides the complete epic and story breakdown for pictureFrame, decomposing the requirements from the PRD and Architecture into implementable stories.
Requirements Inventory
Functional Requirements
FR1: Visitors can register a new account with an email address and password FR2: Registered users can log in to their account FR3: Super admin can view, edit, and delete any user account, device, or image across the system
FR4: Users can register a device to their account via the provisioning setup flow FR5: Users can assign a name to each of their devices FR6: Users can configure display orientation (landscape or portrait) per device FR7: Users can configure the image rotation frequency per device FR8: Users can configure the uniqueness window (number of cycles before an image can repeat) per device FR9: When a device is re-provisioned to a new account, the system atomically purges the prior image history and transfers ownership FR10: Super admin can view, rename, reconfigure, and transfer any device across all accounts
FR11: Users can upload photos to their personal image library FR12: Users can view their library filtered by source: Uploaded vs. Shared FR13: Users can soft-delete images from their library FR14: When an image is shared to a user, it appears in their library as a reference (not a copy) FR15: Super admin can add images to and remove images from a global pre-loaded image pool available to all devices
FR16: Users can approve or decline images for a specific device in their account FR17: Users can share an image from their library to another user FR18: When an image is shared, the recipient receives an email with the image and an approve action; clicking approve opens a device-selection page (no login required) where the recipient chooses which device(s) to add the image to FR19: The approval link works from any email client without requiring account creation or login FR20: Approved images enter the active rotation pool for the selected device(s) FR21: Users can approve all images within a collection for a device in a single action FR22: Users can request a full hard delete of their own image, which enters a super-admin review queue FR23: System sends confirmation to the user when their hard-delete request is fulfilled
FR24: Device enters provisioning mode when the reset button is held for 5 seconds FR25: In provisioning mode, device displays a scannable QR code for joining the provisioning access point FR26: User can enter home WiFi credentials through a captive portal served by the device FR27: On successful WiFi connection, device displays a QR code linking to the account setup page for that specific device FR28: On failed WiFi connection, device displays a failure indicator, reactivates the AP, and redisplays the provisioning QR code for retry FR29: Users can register a new account or log in to an existing account from the device setup page to link the device
FR30: System automatically advances to the next approved image on the configured schedule for each device FR31: System tracks image display history per device to enforce the configured uniqueness window FR32: When the uniqueness window exceeds the count of available approved images, the window is treated as equal to the available image count FR33: System pre-renders images to display-ready format per device model and orientation at the time of upload or approval FR34: Device pulls its next pre-rendered image from the server on each scheduled cycle
FR35: Device displays the current image persistently with no power draw between refresh cycles FR36: Device retains and displays the last successfully transferred image through power loss and WiFi outages FR37: Device only updates the display after a complete, confirmed image transfer FR38: Device renders a yellow border when WiFi is connected but the server sync fails FR39: Device renders a red border when WiFi connectivity is unavailable
FR40: Super admin can view a queue of user-submitted hard-delete requests and fulfill or dismiss them FR41: Super admin can force an immediate hard delete of any image in the system FR42: Super admin can view a device ownership transfer audit log FR43: System automatically hard-deletes soft-deleted images that have no remaining approvals via a scheduled background process FR44: Soft-deleted images with at least one active approval are retained until all approvals are removed
NonFunctional Requirements
NFR1 (Performance): Image pre-rendering completes within 10 seconds of upload or approval trigger NFR2 (Performance): Device image pull endpoint returns the pre-rendered binary asset within 10 seconds on typical home broadband NFR3 (Performance): Web application pages load within 3 seconds on a standard broadband connection NFR4 (Performance): Image rotation fires within ±5 minutes of the configured interval
NFR5 (Security): All device-to-server communication occurs over HTTPS NFR6 (Security): Device image pull requests are authenticated by MAC address; server rejects requests from unregistered MACs NFR7 (Security): Email inline approve/decline actions use single-use authorization links that expire after use or after a configurable TTL NFR8 (Security): User accounts are isolated — a user cannot access another user's images or devices without an explicit sharing action NFR9 (Security): Super admin access is restricted to a single designated account NFR10 (Security): Device ownership transfer requires physical access to the reset button
NFR11 (Reliability): A device must never display a blank screen in normal operation — the last successfully transferred image persists indefinitely NFR12 (Reliability): A display refresh only occurs after a complete, confirmed transfer; a mid-transfer interruption leaves the previous image on screen NFR13 (Reliability): Soft-delete and scheduled cleanup run on a scheduled basis without manual intervention NFR14 (Reliability): Breaking API changes are prohibited in V1
NFR15 (Accessibility): All primary user journeys complete successfully on iOS Safari (latest) and Android Chrome (latest) NFR16 (Accessibility): Email sharing flows must work without requiring app installation or login on the recipient's device
Additional Requirements
Architecture-derived technical requirements:
- Starter template:
symfony new pictureframe --webapp— first implementation action - DDEV setup: PHP 8.4, nginx-fpm, PostgreSQL 16 — mirror aqua-iq
.ddev/config.yaml; adddocker-compose.imagick.yamlfor Imagick extension - Domain first:
pictureframe.edholm.memust 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_processingqueue,max_retries: 1 - Symfony Scheduler: Required for rotation engine and cleanup jobs
- Storage:
storage/images/directory,STORAGE_PATHenv 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 viaSpaControllercatch-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)