Compare commits
20 Commits
663e39b2af
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4002ff9fbf | |||
| dd0970ed7c | |||
| 3740331b5b | |||
| c842a1028f | |||
| d852fe60ae | |||
| 4a871a1161 | |||
| fe416f223e | |||
| 6bce4822e7 | |||
| 2e5ef7fe78 | |||
| f2af2de36f | |||
| d5a7849fbd | |||
| 1ec5364de4 | |||
| d6c21659f0 | |||
| 85363e98bd | |||
| a957c5cdd0 | |||
| a0d39e1c47 | |||
| 01274f5cb7 | |||
| 21e9173508 | |||
| 94dae685e2 | |||
| 2a2c8ae343 |
@@ -0,0 +1,15 @@
|
||||
name: pictureframe
|
||||
type: symfony
|
||||
docroot: public
|
||||
php_version: "8.4"
|
||||
webserver_type: nginx-fpm
|
||||
database:
|
||||
type: postgres
|
||||
version: "16"
|
||||
composer_version: "2"
|
||||
webimage_extra_packages:
|
||||
- php8.4-imagick
|
||||
- php8.4-pcov
|
||||
hooks:
|
||||
post-start:
|
||||
- exec: composer install --no-interaction 2>/dev/null || true
|
||||
@@ -0,0 +1,17 @@
|
||||
# editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[{compose.yaml,compose.*.yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,54 @@
|
||||
# In all environments, the following files are loaded if they exist,
|
||||
# the latter taking precedence over the former:
|
||||
#
|
||||
# * .env contains default values for the environment variables needed by the app
|
||||
# * .env.local uncommitted file with local overrides
|
||||
# * .env.$APP_ENV committed environment-specific defaults
|
||||
# * .env.$APP_ENV.local uncommitted environment-specific overrides
|
||||
#
|
||||
# Real environment variables win over .env files.
|
||||
#
|
||||
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
|
||||
# https://symfony.com/doc/current/configuration/secrets.html
|
||||
#
|
||||
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
|
||||
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
|
||||
|
||||
###> symfony/framework-bundle ###
|
||||
APP_ENV=dev
|
||||
APP_SECRET=
|
||||
APP_SHARE_DIR=var/share
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> symfony/routing ###
|
||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||
DEFAULT_URI=http://localhost
|
||||
###< symfony/routing ###
|
||||
|
||||
###> doctrine/doctrine-bundle ###
|
||||
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
|
||||
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
|
||||
#
|
||||
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
|
||||
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
|
||||
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
|
||||
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
###> symfony/messenger ###
|
||||
# Choose one of the transports below
|
||||
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
|
||||
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
|
||||
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
||||
###< symfony/messenger ###
|
||||
|
||||
###> symfony/mailer ###
|
||||
MAILER_DSN=null://null
|
||||
MAILER_SENDER=noreply@pictureframe.edholm.me
|
||||
###< symfony/mailer ###
|
||||
|
||||
###> pictureframe ###
|
||||
SHARE_TOKEN_TTL_DAYS=7
|
||||
HARD_DELETE_TOKEN_TTL_DAYS=30
|
||||
###< pictureframe ###
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
###> symfony/framework-bundle ###
|
||||
APP_SECRET=a9a22e6c71fe44f77e8c8534c5595905
|
||||
###< symfony/framework-bundle ###
|
||||
@@ -0,0 +1,7 @@
|
||||
# define your env variables for the test env here
|
||||
KERNEL_CLASS='App\Kernel'
|
||||
APP_SECRET='$ecretf0rt3st'
|
||||
MESSENGER_TRANSPORT_DSN=in-memory://
|
||||
SHARE_TOKEN_TTL_DAYS=7
|
||||
HARD_DELETE_TOKEN_TTL_DAYS=30
|
||||
MAILER_SENDER=noreply@test.example
|
||||
@@ -0,0 +1,46 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: php:8.4-cli
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: app
|
||||
POSTGRES_PASSWORD: app
|
||||
POSTGRES_DB: app_test
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
apt-get update -q && apt-get install -yq git unzip libpq-dev libmagickwand-dev \
|
||||
&& docker-php-ext-install pdo pdo_pgsql \
|
||||
&& pecl install imagick && docker-php-ext-enable imagick
|
||||
|
||||
- name: Install Composer
|
||||
run: |
|
||||
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
DATABASE_URL: postgresql://app:app@postgres:5432/app_test?serverVersion=16
|
||||
run: php bin/phpunit
|
||||
@@ -1,24 +1,35 @@
|
||||
# AI tool output (planning artifacts are excluded from git)
|
||||
_bmad-output/
|
||||
###> symfony/framework-bundle ###
|
||||
/.env.local
|
||||
/.env.local.php
|
||||
/.env.*.local
|
||||
/config/secrets/prod/prod.decrypt.private.php
|
||||
/public/bundles/
|
||||
/var/
|
||||
/vendor/
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> phpunit/phpunit ###
|
||||
/phpunit.xml
|
||||
/.phpunit.cache/
|
||||
###< phpunit/phpunit ###
|
||||
|
||||
# PlatformIO build artifacts
|
||||
.pio/
|
||||
firmware/.pio/
|
||||
|
||||
# Environment overrides
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Symfony runtime
|
||||
var/
|
||||
# User-uploaded image storage
|
||||
storage/images/
|
||||
|
||||
# Composer
|
||||
vendor/
|
||||
|
||||
# macOS
|
||||
# macOS / IDE
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# Frontend
|
||||
/frontend/node_modules/
|
||||
/frontend/coverage/
|
||||
|
||||
# Python
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
# pictureFrame
|
||||
|
||||
*Project description goes here.*
|
||||
Handcrafted e-ink digital picture frame ecosystem — built as a meaningful gift. ESP32 firmware pulls pre-rendered images from a Symfony web app over WiFi; the companion web app handles image management, device configuration, and family sharing.
|
||||
|
||||
## Project goal
|
||||
|
||||
*What does this project do and why?*
|
||||
Build gifted e-ink frames that stay personal and current over time, with no ongoing effort required from the recipient. One image per configured interval, cycling through a curated pool of family uploads and shared photos. Setup: scan two QR codes. Ongoing: nothing unless the recipient wants it.
|
||||
|
||||
## Stack
|
||||
|
||||
*Languages, frameworks, key libraries.*
|
||||
- **Firmware:** PlatformIO + Arduino framework (C/C++), ESP32 dev board
|
||||
- **Web app:** Symfony 8.0 (PHP 8.4+), PostgreSQL 16, Nginx-FPM
|
||||
- **Local dev:** DDEV — mirrors `~/src/aqua-iq` setup
|
||||
- **Git:** git.edholm.me (self-hosted Gitea/Forgejo)
|
||||
- **Domain:** pictureframe.edholm.me
|
||||
|
||||
## Hardware
|
||||
|
||||
*If applicable — describe the target hardware.*
|
||||
### Dev / V1 hardware (in hand)
|
||||
- ESP32 dev board (`esp32dev`), dual-core 240MHz, 4MB flash
|
||||
- Waveshare 7.3" 6-color e-ink (800×480)
|
||||
- SPI pinout: SCK=18, MOSI=23, CS=5, DC=17, RST=16, BUSY=4
|
||||
- 4bpp packed, palette: BLACK=0x0, WHITE=0x1, YELLOW=0x2, RED=0x3, BLUE=0x5, GREEN=0x6 (same map as Spectra 6; 0x4 unused)
|
||||
|
||||
### Target / V2 hardware (on order)
|
||||
- Waveshare ESP32-S3-ePaper-13.3E6 — 13.3" **Spectra 6** (6-color), ESP32-S3 onboard
|
||||
- Spectra 6 palette: same as above — BLACK=0x0, WHITE=0x1, YELLOW=0x2, RED=0x3, BLUE=0x5, GREEN=0x6
|
||||
- Battery or plugin at recipient's choice
|
||||
|
||||
## Design decisions
|
||||
|
||||
*Key architectural or UX choices.*
|
||||
- Server pre-renders all images to display-ready 4bpp per device model/orientation — ESP32 never transforms images
|
||||
- Device pull model: `GET /api/device/{mac}/image` → 200 (binary), 204 (no ready image), 404 (unknown MAC)
|
||||
- Atomic image write: display only refreshes after complete confirmed transfer; last good image persists through outages
|
||||
- Deep sleep between pull cycles (see ESP32 deep sleep memory)
|
||||
- Status via border color: yellow = sync fail, red = no WiFi
|
||||
- 5-second button hold triggers re-provisioning (config wipe + AP mode)
|
||||
- Two-phase provisioning: AP mode (WiFi credentials) → STA mode (QR to account setup page)
|
||||
- Async image processing: Symfony Messenger (Doctrine transport), `max_retries: 1`
|
||||
- Image storage: `storage/images/{id}/{model}_{orientation}.bin`, relative paths in DB
|
||||
- PHP 8.1 backed enums for `RenderStatus`, `TokenType`, `Orientation`
|
||||
- Imagick for Floyd-Steinberg dithering (not GD)
|
||||
- No OTA firmware updates in V1 — API contract must not break without reflash
|
||||
|
||||
## Infrastructure reference
|
||||
|
||||
For Docker/DDEV config, server location, and SSH details: `~/src/aqua-iq`
|
||||
|
||||
## Full spec
|
||||
|
||||
*Link to or describe the full specification.*
|
||||
See `_bmad-output/planning-artifacts/` — PRD, architecture, epics all complete as of 2026-04-27. Symfony web app scaffold + Vue SPA frontend scaffolded. Firmware fully rewritten with WiFi, provisioning, and deep sleep (`firmware/src/`).
|
||||
|
||||
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,820 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=800">
|
||||
<title>AP Provisioning Screen — pictureFrame mockup</title>
|
||||
<style>
|
||||
/*
|
||||
HARDWARE CONSTRAINTS: 800×480px. Six colors only.
|
||||
#1a1a1a BLACK
|
||||
#f5f5f0 WHITE
|
||||
#f0d000 YELLOW
|
||||
#c03020 RED
|
||||
#1840c0 BLUE
|
||||
#10a040 GREEN
|
||||
|
||||
No gradients. No drop shadows. No anti-aliasing. No other colors.
|
||||
No fonts below ~16px.
|
||||
This file is a design mockup — open at 100% zoom in browser.
|
||||
*/
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #888;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
/* Outer frame — the physical bezel of the device */
|
||||
.device-bezel {
|
||||
width: 840px;
|
||||
height: 520px;
|
||||
background: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* The e-ink display surface — exactly 800×480 */
|
||||
.display {
|
||||
width: 800px;
|
||||
height: 480px;
|
||||
background: #f5f5f0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
YELLOW STATUS BAR — signals AP mode / action required
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
|
||||
.status-bar {
|
||||
width: 800px;
|
||||
height: 52px;
|
||||
background: #f0d000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-bar-label {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.status-bar-network {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* Monospaced network name chip */
|
||||
.network-chip {
|
||||
display: inline-block;
|
||||
background: #1a1a1a;
|
||||
color: #f0d000;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
MAIN BODY — three-column layout
|
||||
LEFT: header + instructions
|
||||
CENTER: orientation diagrams
|
||||
RIGHT: QR code
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* ── LEFT PANEL ───────────────────────────────────── */
|
||||
.panel-left {
|
||||
width: 310px;
|
||||
flex-shrink: 0;
|
||||
padding: 24px 20px 20px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
border-right: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.main-heading {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.main-heading em {
|
||||
font-style: normal;
|
||||
color: #1a1a1a;
|
||||
/* Underline in yellow: simulate with border since no CSS effects */
|
||||
border-bottom: 3px solid #f0d000;
|
||||
}
|
||||
|
||||
.step-list {
|
||||
list-style: none;
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #1a1a1a;
|
||||
color: #f0d000;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 15px;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.35;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.step-text strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.left-divider {
|
||||
height: 2px;
|
||||
background: #1a1a1a;
|
||||
margin: 16px 0 14px;
|
||||
}
|
||||
|
||||
.footnote {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 13px;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.4;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── CENTER PANEL — orientation diagrams ──────────── */
|
||||
.panel-center {
|
||||
width: 196px;
|
||||
flex-shrink: 0;
|
||||
border-right: 2px solid #1a1a1a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.orient-label {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: #1a1a1a;
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.orient-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Landscape: wide rect + bottom ribbon */
|
||||
.orient-landscape-frame {
|
||||
width: 110px;
|
||||
height: 66px;
|
||||
border: 3px solid #1a1a1a;
|
||||
background: #f5f5f0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Corner tick marks to suggest the physical frame */
|
||||
.orient-landscape-frame::before,
|
||||
.orient-landscape-frame::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.orient-landscape-ribbon {
|
||||
width: 110px;
|
||||
height: 10px;
|
||||
background: #1a1a1a;
|
||||
/* The power ribbon / cable notch at bottom */
|
||||
}
|
||||
|
||||
/* Portrait: tall rect + left ribbon */
|
||||
.orient-portrait-frame {
|
||||
width: 64px;
|
||||
height: 106px;
|
||||
border: 3px solid #1a1a1a;
|
||||
background: #f5f5f0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.orient-portrait-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.orient-portrait-ribbon {
|
||||
width: 10px;
|
||||
height: 106px;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
/* Active/current orientation highlight */
|
||||
.orient-active .orient-landscape-frame,
|
||||
.orient-active .orient-portrait-frame {
|
||||
border-color: #f0d000;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.orient-active .orient-landscape-ribbon,
|
||||
.orient-active .orient-portrait-ribbon {
|
||||
background: #f0d000;
|
||||
}
|
||||
|
||||
.orient-active .orient-label {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* Tiny check mark for active orientation */
|
||||
.active-badge {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #f0d000;
|
||||
border: 2px solid #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
color: #1a1a1a;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.orient-divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: #1a1a1a;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.orient-section-title {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.25em;
|
||||
text-transform: uppercase;
|
||||
color: #1a1a1a;
|
||||
opacity: 0.55;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ── RIGHT PANEL — QR code ────────────────────────── */
|
||||
.panel-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.qr-instruction {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #1a1a1a;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-wrapper {
|
||||
width: 196px;
|
||||
height: 196px;
|
||||
background: #f5f5f0;
|
||||
border: 3px solid #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Yellow corner brackets — frame the QR */
|
||||
.qr-wrapper::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
right: -3px;
|
||||
bottom: -3px;
|
||||
border: 3px solid #f0d000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.qr-sub {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 12px;
|
||||
color: #1a1a1a;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
opacity: 0.65;
|
||||
max-width: 190px;
|
||||
}
|
||||
|
||||
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
QR CODE — SVG grid rendered in pure black/white
|
||||
Pattern: encodes WIFI:S:PictureFrame-A3F7;T:nopass;;
|
||||
This is a realistic-looking QR placeholder.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="device-bezel">
|
||||
<div class="display">
|
||||
|
||||
<!-- STATUS BAR -->
|
||||
<div class="status-bar">
|
||||
<span class="status-bar-label">Setup Mode — Step 1 of 2</span>
|
||||
<span class="status-bar-network">
|
||||
Broadcasting: <span class="network-chip">PictureFrame-A3F7</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- BODY -->
|
||||
<div class="body">
|
||||
|
||||
<!-- LEFT: instructions -->
|
||||
<div class="panel-left">
|
||||
<div>
|
||||
<div class="main-heading">Connect to<br><em>WiFi</em></div>
|
||||
|
||||
<ul class="step-list">
|
||||
<li class="step-item">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-text">
|
||||
Scan the QR code →<br>
|
||||
Your phone joins <strong>PictureFrame-A3F7</strong>
|
||||
</div>
|
||||
</li>
|
||||
<li class="step-item">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-text">
|
||||
A WiFi setup page opens automatically in your browser
|
||||
</div>
|
||||
</li>
|
||||
<li class="step-item">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-text">
|
||||
Enter your <strong>home WiFi</strong> credentials and tap Connect
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="left-divider"></div>
|
||||
<div class="footnote">
|
||||
Page didn't open? Navigate to<br>
|
||||
<strong>192.168.4.1</strong> in any browser.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CENTER: orientation diagrams -->
|
||||
<div class="panel-center">
|
||||
<div class="orient-section-title">Frame orientation</div>
|
||||
|
||||
<!-- Landscape (active) -->
|
||||
<div class="orient-block orient-active">
|
||||
<div class="orient-label">Landscape</div>
|
||||
<div class="orient-landscape-frame"></div>
|
||||
<div class="orient-landscape-ribbon"></div>
|
||||
<div class="active-badge">✓</div>
|
||||
</div>
|
||||
|
||||
<div class="orient-divider"></div>
|
||||
|
||||
<!-- Portrait -->
|
||||
<div class="orient-block">
|
||||
<div class="orient-label">Portrait</div>
|
||||
<div class="orient-portrait-wrapper">
|
||||
<div class="orient-portrait-ribbon"></div>
|
||||
<div class="orient-portrait-frame"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: QR code -->
|
||||
<div class="panel-right">
|
||||
<div class="qr-instruction">Scan to connect</div>
|
||||
|
||||
<div class="qr-wrapper">
|
||||
<!-- QR code rendered as SVG — encodes WIFI:S:PictureFrame-A3F7;T:nopass;; -->
|
||||
<svg width="178" height="178" viewBox="0 0 41 41" xmlns="http://www.w3.org/2000/svg"
|
||||
shape-rendering="crispEdges">
|
||||
<rect width="41" height="41" fill="#f5f5f0"/>
|
||||
|
||||
<!-- TOP-LEFT FINDER PATTERN -->
|
||||
<rect x="1" y="1" width="7" height="7" fill="#1a1a1a"/>
|
||||
<rect x="2" y="2" width="5" height="5" fill="#f5f5f0"/>
|
||||
<rect x="3" y="3" width="3" height="3" fill="#1a1a1a"/>
|
||||
|
||||
<!-- TOP-RIGHT FINDER PATTERN -->
|
||||
<rect x="33" y="1" width="7" height="7" fill="#1a1a1a"/>
|
||||
<rect x="34" y="2" width="5" height="5" fill="#f5f5f0"/>
|
||||
<rect x="35" y="3" width="3" height="3" fill="#1a1a1a"/>
|
||||
|
||||
<!-- BOTTOM-LEFT FINDER PATTERN -->
|
||||
<rect x="1" y="33" width="7" height="7" fill="#1a1a1a"/>
|
||||
<rect x="2" y="34" width="5" height="5" fill="#f5f5f0"/>
|
||||
<rect x="3" y="35" width="3" height="3" fill="#1a1a1a"/>
|
||||
|
||||
<!-- TIMING PATTERNS (horizontal and vertical) -->
|
||||
<rect x="9" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="11" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="13" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="15" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="19" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="23" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="25" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="29" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="31" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<rect x="6" y="9" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="11" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="13" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="15" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="17" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="19" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="21" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="23" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="25" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="27" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="29" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="31" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- DATA MODULES — WiFi QR payload simulation -->
|
||||
<!-- Row 9 -->
|
||||
<rect x="9" y="9" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="11" y="9" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="9" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="16" y="9" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="9" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="25" y="9" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="9" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="31" y="9" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 10 -->
|
||||
<rect x="9" y="10" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="13" y="10" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="15" y="10" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="19" y="10" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="22" y="10" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="26" y="10" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="30" y="10" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 11 -->
|
||||
<rect x="8" y="11" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="10" y="11" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="15" y="11" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="11" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="11" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="24" y="11" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="28" y="11" width="3" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 12 -->
|
||||
<rect x="9" y="12" width="4" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="12" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="12" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="22" y="12" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="26" y="12" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="29" y="12" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="33" y="12" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 13 -->
|
||||
<rect x="8" y="13" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="12" y="13" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="13" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="18" y="13" width="4" height="1" fill="#1a1a1a"/>
|
||||
<rect x="24" y="13" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="29" y="13" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="31" y="13" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 14 -->
|
||||
<rect x="9" y="14" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="11" y="14" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="16" y="14" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="20" y="14" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="23" y="14" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="14" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="32" y="14" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 15 -->
|
||||
<rect x="8" y="15" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="13" y="15" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="15" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="19" y="15" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="24" y="15" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="15" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="30" y="15" width="3" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 16 -->
|
||||
<rect x="9" y="16" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="13" y="16" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="16" y="16" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="16" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="25" y="16" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="30" y="16" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="34" y="16" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 17 -->
|
||||
<rect x="8" y="17" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="10" y="17" width="4" height="1" fill="#1a1a1a"/>
|
||||
<rect x="15" y="17" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="19" y="17" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="22" y="17" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="17" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="29" y="17" width="4" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 18 -->
|
||||
<rect x="9" y="18" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="18" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="18" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="18" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="24" y="18" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="28" y="18" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="32" y="18" width="3" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 19 -->
|
||||
<rect x="8" y="19" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="12" y="19" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="16" y="19" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="18" y="19" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="22" y="19" width="4" height="1" fill="#1a1a1a"/>
|
||||
<rect x="28" y="19" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="30" y="19" width="3" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 20 (center) -->
|
||||
<rect x="9" y="20" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="11" y="20" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="15" y="20" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="20" y="20" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="23" y="20" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="20" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="32" y="20" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 21 -->
|
||||
<rect x="8" y="21" width="4" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="21" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="18" y="21" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="23" y="21" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="25" y="21" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="29" y="21" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="31" y="21" width="3" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 22 -->
|
||||
<rect x="9" y="22" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="13" y="22" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="16" y="22" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="20" y="22" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="24" y="22" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="29" y="22" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="33" y="22" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 23 -->
|
||||
<rect x="8" y="23" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="10" y="23" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="15" y="23" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="23" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="22" y="23" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="26" y="23" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="28" y="23" width="4" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 24 -->
|
||||
<rect x="9" y="24" width="4" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="24" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="18" y="24" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="24" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="26" y="24" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="30" y="24" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="32" y="24" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 25 -->
|
||||
<rect x="8" y="25" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="12" y="25" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="25" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="19" y="25" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="23" y="25" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="26" y="25" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="31" y="25" width="3" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 26 -->
|
||||
<rect x="9" y="26" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="11" y="26" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="15" y="26" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="19" y="26" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="22" y="26" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="26" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="31" y="26" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="33" y="26" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 27 -->
|
||||
<rect x="8" y="27" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="13" y="27" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="16" y="27" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="27" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="25" y="27" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="28" y="27" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="33" y="27" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 28 -->
|
||||
<rect x="9" y="28" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="13" y="28" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="18" y="28" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="28" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="24" y="28" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="28" y="28" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="30" y="28" width="4" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 29 -->
|
||||
<rect x="8" y="29" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="10" y="29" width="4" height="1" fill="#1a1a1a"/>
|
||||
<rect x="16" y="29" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="20" y="29" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="25" y="29" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="29" y="29" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="33" y="29" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 30 -->
|
||||
<rect x="9" y="30" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="30" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="30" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="20" y="30" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="24" y="30" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="29" y="30" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="32" y="30" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 31 -->
|
||||
<rect x="8" y="31" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="12" y="31" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="16" y="31" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="31" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="23" y="31" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="31" width="4" height="1" fill="#1a1a1a"/>
|
||||
<rect x="33" y="31" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Alignment pattern bottom-right (5×5) -->
|
||||
<rect x="28" y="28" width="5" height="5" fill="#1a1a1a"/>
|
||||
<rect x="29" y="29" width="3" height="3" fill="#f5f5f0"/>
|
||||
<rect x="30" y="30" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Format info strips -->
|
||||
<rect x="8" y="8" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Extra data density rows 33–39 (below bottom-left finder) -->
|
||||
<rect x="9" y="34" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="13" y="34" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="15" y="34" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="20" y="34" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="24" y="34" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="34" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<rect x="8" y="35" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="13" y="35" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="35" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="19" y="35" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="24" y="35" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="28" y="35" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<rect x="9" y="36" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="11" y="36" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="36" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="36" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="36" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="23" y="36" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="36" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<rect x="8" y="37" width="4" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="37" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="18" y="37" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="23" y="37" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="25" y="37" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="29" y="37" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<rect x="9" y="38" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="13" y="38" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="18" y="38" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="38" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="25" y="38" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="30" y="38" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<rect x="8" y="39" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="10" y="39" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="15" y="39" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="19" y="39" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="22" y="39" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="26" y="39" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="28" y="39" width="3" height="1" fill="#1a1a1a"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="qr-sub">
|
||||
Encodes: <strong>WIFI:S:PictureFrame-A3F7;T:nopass;;</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.body -->
|
||||
|
||||
</div><!-- /.display -->
|
||||
</div><!-- /.device-bezel -->
|
||||
|
||||
<!--
|
||||
DESIGNER NOTES
|
||||
──────────────────────────────────────────────────────────────────
|
||||
Screen: AP Provisioning — Step 1 of 2
|
||||
State: Frame has no WiFi credentials. Broadcasting open AP.
|
||||
Accent: YELLOW — signals "action required," unconnected state.
|
||||
|
||||
Layout rationale:
|
||||
• Yellow status bar carries SSID at a glance — one horizontal scan
|
||||
tells you what network to join without reading the instructions.
|
||||
• Instructions live LEFT, not centered, to make room for the large QR
|
||||
without any elements competing at the same scale.
|
||||
• Orientation diagrams are narrow + centered — they confirm physical
|
||||
placement, not the primary action. They don't compete.
|
||||
• QR panel is RIGHT and large — the single action. Yellow bracket
|
||||
border echoes the accent and frames the eye's destination.
|
||||
• Step numbers in black/yellow inverse boxes are readable at a
|
||||
distance; the QR is readable up close. Both jobs done together.
|
||||
• "Landscape" is marked active (check mark + yellow ribbon) because
|
||||
the frame is currently held landscape. Portrait shown as the alt.
|
||||
• Footnote handles the "captive portal didn't open" edge case.
|
||||
Firmware MUST handle this — the screen already does.
|
||||
|
||||
Firmware implementation notes (for when you write the C):
|
||||
• The SSID suffix is the last 4 hex chars of the MAC.
|
||||
• The QR encodes the WIFI: string for auto-join on iOS/Android.
|
||||
• The 192.168.4.1 fallback is the ESP32 SoftAP default gateway.
|
||||
──────────────────────────────────────────────────────────────────
|
||||
-->
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,971 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=800">
|
||||
<title>Setup QR Screen — pictureFrame mockup</title>
|
||||
<style>
|
||||
/*
|
||||
HARDWARE CONSTRAINTS: 800×480px. Six colors only.
|
||||
#1a1a1a BLACK
|
||||
#f5f5f0 WHITE
|
||||
#f0d000 YELLOW
|
||||
#c03020 RED
|
||||
#1840c0 BLUE
|
||||
#10a040 GREEN
|
||||
|
||||
No gradients. No drop shadows. No anti-aliasing. No other colors.
|
||||
No fonts below ~16px.
|
||||
This file is a design mockup — open at 100% zoom in browser.
|
||||
*/
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #888;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
/* Outer frame — the physical bezel */
|
||||
.device-bezel {
|
||||
width: 840px;
|
||||
height: 520px;
|
||||
background: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* The e-ink display surface — exactly 800×480 */
|
||||
.display {
|
||||
width: 800px;
|
||||
height: 480px;
|
||||
background: #f5f5f0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
GREEN STATUS BAR — signals connected / almost done
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
|
||||
.status-bar {
|
||||
width: 800px;
|
||||
height: 52px;
|
||||
background: #10a040;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-bar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* WiFi connected indicator — pixelated bars (no border-radius) */
|
||||
.wifi-icon {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.wifi-bar {
|
||||
background: #f5f5f0;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.wifi-bar-1 { height: 8px; }
|
||||
.wifi-bar-2 { height: 13px; }
|
||||
.wifi-bar-3 { height: 18px; }
|
||||
.wifi-bar-4 { height: 22px; }
|
||||
|
||||
.status-bar-label {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #f5f5f0;
|
||||
}
|
||||
|
||||
.status-bar-right {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #f5f5f0;
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.ip-chip {
|
||||
display: inline-block;
|
||||
background: #f5f5f0;
|
||||
color: #10a040;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 3px 9px;
|
||||
}
|
||||
|
||||
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
MAIN BODY
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* ── LEFT PANEL ───────────────────────────────────── */
|
||||
.panel-left {
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
padding: 24px 20px 20px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
border-right: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.main-heading {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.main-heading em {
|
||||
font-style: normal;
|
||||
border-bottom: 3px solid #10a040;
|
||||
}
|
||||
|
||||
.sub-heading {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 15px;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.5;
|
||||
margin-top: 14px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.step-list {
|
||||
list-style: none;
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #10a040;
|
||||
color: #f5f5f0;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 15px;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.35;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.step-text strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* URL hint bar */
|
||||
.url-bar {
|
||||
margin-top: 18px;
|
||||
background: #1a1a1a;
|
||||
padding: 8px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.url-bar-label {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: #10a040;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.url-bar-value {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 12px;
|
||||
color: #f5f5f0;
|
||||
word-break: break-all;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Progress tracker */
|
||||
.progress-track {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: #1a1a1a;
|
||||
opacity: 0.45;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.progress-steps {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.prog-step {
|
||||
height: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.prog-step.done {
|
||||
background: #10a040;
|
||||
}
|
||||
|
||||
.prog-step.active {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.prog-step.todo {
|
||||
background: #1a1a1a;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.prog-step-labels {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.prog-step-label {
|
||||
flex: 1;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 10px;
|
||||
color: #1a1a1a;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.prog-step-label.done {
|
||||
color: #10a040;
|
||||
opacity: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.prog-step-label.active {
|
||||
opacity: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── CENTER PANEL — orientation diagrams ──────────── */
|
||||
.panel-center {
|
||||
width: 164px;
|
||||
flex-shrink: 0;
|
||||
border-right: 2px solid #1a1a1a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 16px 10px;
|
||||
}
|
||||
|
||||
.orient-section-title {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.25em;
|
||||
text-transform: uppercase;
|
||||
color: #1a1a1a;
|
||||
opacity: 0.45;
|
||||
text-align: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.orient-label {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: #1a1a1a;
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.orient-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Landscape: wide rect + bottom ribbon */
|
||||
.orient-landscape-frame {
|
||||
width: 100px;
|
||||
height: 60px;
|
||||
border: 3px solid #1a1a1a;
|
||||
background: #f5f5f0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Inner screen hatching — suggests image content */
|
||||
.orient-landscape-frame::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px; left: 6px; right: 6px; bottom: 6px;
|
||||
border: 1px solid #1a1a1a;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.orient-landscape-ribbon {
|
||||
width: 100px;
|
||||
height: 9px;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
/* Portrait: tall rect + left ribbon */
|
||||
.orient-portrait-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.orient-portrait-ribbon {
|
||||
width: 9px;
|
||||
height: 96px;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.orient-portrait-frame {
|
||||
width: 58px;
|
||||
height: 96px;
|
||||
border: 3px solid #1a1a1a;
|
||||
background: #f5f5f0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.orient-portrait-frame::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px; left: 6px; right: 6px; bottom: 6px;
|
||||
border: 1px solid #1a1a1a;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
/* Active state — green accent for connected/done feeling */
|
||||
.orient-active .orient-landscape-frame,
|
||||
.orient-active .orient-portrait-frame {
|
||||
border-color: #10a040;
|
||||
}
|
||||
|
||||
.orient-active .orient-landscape-ribbon,
|
||||
.orient-active .orient-portrait-ribbon {
|
||||
background: #10a040;
|
||||
}
|
||||
|
||||
.orient-active .orient-label {
|
||||
color: #10a040;
|
||||
}
|
||||
|
||||
.active-badge {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #10a040;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
color: #f5f5f0;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.orient-divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: #1a1a1a;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/* ── RIGHT PANEL — QR code ────────────────────────── */
|
||||
.panel-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.qr-instruction {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #1a1a1a;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-wrapper {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: #f5f5f0;
|
||||
border: 3px solid #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Green corner accent — signals connected state */
|
||||
.qr-wrapper::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
right: -3px;
|
||||
bottom: -3px;
|
||||
border: 3px solid #10a040;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.qr-sub {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 12px;
|
||||
color: #1a1a1a;
|
||||
text-align: center;
|
||||
line-height: 1.45;
|
||||
opacity: 0.6;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* MAC address chip */
|
||||
.mac-chip {
|
||||
display: inline-block;
|
||||
background: #1a1a1a;
|
||||
color: #f5f5f0;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 3px 9px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="device-bezel">
|
||||
<div class="display">
|
||||
|
||||
<!-- STATUS BAR -->
|
||||
<div class="status-bar">
|
||||
<div class="status-bar-left">
|
||||
<!-- WiFi icon: 3 arcs + dot -->
|
||||
<div class="wifi-icon">
|
||||
<div class="wifi-bar wifi-bar-1"></div>
|
||||
<div class="wifi-bar wifi-bar-2"></div>
|
||||
<div class="wifi-bar wifi-bar-3"></div>
|
||||
<div class="wifi-bar wifi-bar-4"></div>
|
||||
</div>
|
||||
<span class="status-bar-label">WiFi Connected — Step 2 of 2</span>
|
||||
</div>
|
||||
<span class="status-bar-right">
|
||||
IP: <span class="ip-chip">192.168.1.47</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- BODY -->
|
||||
<div class="body">
|
||||
|
||||
<!-- LEFT: instructions + progress -->
|
||||
<div class="panel-left">
|
||||
<div>
|
||||
<div class="main-heading">Almost<br><em>ready.</em></div>
|
||||
<div class="sub-heading">
|
||||
Scan the QR code to give this frame a name and link it to your account.
|
||||
</div>
|
||||
|
||||
<ul class="step-list">
|
||||
<li class="step-item">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-text">
|
||||
Scan the QR code with your phone's camera
|
||||
</div>
|
||||
</li>
|
||||
<li class="step-item">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-text">
|
||||
Sign in or create an account at <strong>pictureframe.edholm.me</strong>
|
||||
</div>
|
||||
</li>
|
||||
<li class="step-item">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-text">
|
||||
Name the frame. Choose orientation. Done.
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="url-bar">
|
||||
<span class="url-bar-label">URL</span>
|
||||
<span class="url-bar-value">pictureframe.edholm.me/setup/1C:C3:AB:D1:91:F8</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- Progress tracker: WiFi done / Account next / Frame ready todo -->
|
||||
<div class="progress-track">
|
||||
<div class="progress-label">Setup progress</div>
|
||||
<div class="progress-steps">
|
||||
<div class="prog-step done"></div>
|
||||
<div class="prog-step active"></div>
|
||||
<div class="prog-step todo"></div>
|
||||
</div>
|
||||
<div class="prog-step-labels">
|
||||
<div class="prog-step-label done">WiFi</div>
|
||||
<div class="prog-step-label active">Account</div>
|
||||
<div class="prog-step-label todo">Frame ready</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CENTER: orientation diagrams -->
|
||||
<div class="panel-center">
|
||||
<div class="orient-section-title">Frame orientation</div>
|
||||
|
||||
<!-- Landscape (active) -->
|
||||
<div class="orient-block orient-active">
|
||||
<div class="orient-label">Landscape</div>
|
||||
<div class="orient-landscape-frame"></div>
|
||||
<div class="orient-landscape-ribbon"></div>
|
||||
<div class="active-badge">✓</div>
|
||||
</div>
|
||||
|
||||
<div class="orient-divider"></div>
|
||||
|
||||
<!-- Portrait -->
|
||||
<div class="orient-block">
|
||||
<div class="orient-label">Portrait</div>
|
||||
<div class="orient-portrait-wrapper">
|
||||
<div class="orient-portrait-ribbon"></div>
|
||||
<div class="orient-portrait-frame"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: QR code -->
|
||||
<div class="panel-right">
|
||||
<div class="qr-instruction">Scan to finish</div>
|
||||
|
||||
<div class="qr-wrapper">
|
||||
<!-- QR code — encodes https://pictureframe.edholm.me/setup/1C:C3:AB:D1:91:F8 -->
|
||||
<svg width="182" height="182" viewBox="0 0 41 41" xmlns="http://www.w3.org/2000/svg"
|
||||
shape-rendering="crispEdges">
|
||||
<rect width="41" height="41" fill="#f5f5f0"/>
|
||||
|
||||
<!-- TOP-LEFT FINDER -->
|
||||
<rect x="1" y="1" width="7" height="7" fill="#1a1a1a"/>
|
||||
<rect x="2" y="2" width="5" height="5" fill="#f5f5f0"/>
|
||||
<rect x="3" y="3" width="3" height="3" fill="#1a1a1a"/>
|
||||
|
||||
<!-- TOP-RIGHT FINDER -->
|
||||
<rect x="33" y="1" width="7" height="7" fill="#1a1a1a"/>
|
||||
<rect x="34" y="2" width="5" height="5" fill="#f5f5f0"/>
|
||||
<rect x="35" y="3" width="3" height="3" fill="#1a1a1a"/>
|
||||
|
||||
<!-- BOTTOM-LEFT FINDER -->
|
||||
<rect x="1" y="33" width="7" height="7" fill="#1a1a1a"/>
|
||||
<rect x="2" y="34" width="5" height="5" fill="#f5f5f0"/>
|
||||
<rect x="3" y="35" width="3" height="3" fill="#1a1a1a"/>
|
||||
|
||||
<!-- TIMING H -->
|
||||
<rect x="9" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="11" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="13" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="15" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="19" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="23" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="25" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="29" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="31" y="6" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- TIMING V -->
|
||||
<rect x="6" y="9" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="11" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="13" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="15" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="17" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="19" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="21" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="23" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="25" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="27" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="29" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="6" y="31" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- DATA — URL QR pattern (unique from AP screen) -->
|
||||
<!-- Row 9 -->
|
||||
<rect x="8" y="9" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="12" y="9" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="9" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="19" y="9" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="23" y="9" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="26" y="9" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="30" y="9" width="3" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 10 -->
|
||||
<rect x="9" y="10" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="10" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="16" y="10" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="20" y="10" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="25" y="10" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="29" y="10" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="32" y="10" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 11 -->
|
||||
<rect x="8" y="11" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="11" y="11" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="16" y="11" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="18" y="11" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="23" y="11" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="11" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="30" y="11" width="3" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 12 -->
|
||||
<rect x="9" y="12" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="13" y="12" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="12" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="12" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="24" y="12" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="29" y="12" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="33" y="12" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 13 -->
|
||||
<rect x="8" y="13" width="4" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="13" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="16" y="13" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="13" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="25" y="13" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="28" y="13" width="4" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 14 -->
|
||||
<rect x="9" y="14" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="12" y="14" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="16" y="14" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="19" y="14" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="23" y="14" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="28" y="14" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="31" y="14" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 15 -->
|
||||
<rect x="8" y="15" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="12" y="15" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="15" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="22" y="15" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="26" y="15" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="30" y="15" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="33" y="15" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 16 -->
|
||||
<rect x="9" y="16" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="16" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="18" y="16" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="16" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="24" y="16" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="29" y="16" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="33" y="16" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 17 -->
|
||||
<rect x="8" y="17" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="11" y="17" width="4" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="17" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="17" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="26" y="17" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="29" y="17" width="3" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 18 -->
|
||||
<rect x="9" y="18" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="13" y="18" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="15" y="18" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="20" y="18" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="24" y="18" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="18" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="32" y="18" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 19 -->
|
||||
<rect x="8" y="19" width="4" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="19" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="18" y="19" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="22" y="19" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="19" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="31" y="19" width="3" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 20 -->
|
||||
<rect x="9" y="20" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="12" y="20" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="20" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="20" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="24" y="20" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="28" y="20" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="31" y="20" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="33" y="20" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 21 -->
|
||||
<rect x="8" y="21" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="12" y="21" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="21" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="18" y="21" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="23" y="21" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="21" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="32" y="21" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 22 -->
|
||||
<rect x="9" y="22" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="22" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="16" y="22" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="20" y="22" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="23" y="22" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="26" y="22" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="30" y="22" width="3" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 23 -->
|
||||
<rect x="8" y="23" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="11" y="23" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="15" y="23" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="20" y="23" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="25" y="23" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="28" y="23" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="32" y="23" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 24 -->
|
||||
<rect x="9" y="24" width="4" height="1" fill="#1a1a1a"/>
|
||||
<rect x="15" y="24" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="18" y="24" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="22" y="24" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="26" y="24" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="31" y="24" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 25 -->
|
||||
<rect x="8" y="25" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="12" y="25" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="25" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="25" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="24" y="25" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="28" y="25" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="31" y="25" width="3" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 26 -->
|
||||
<rect x="9" y="26" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="12" y="26" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="15" y="26" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="20" y="26" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="25" y="26" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="29" y="26" width="4" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 27 -->
|
||||
<rect x="8" y="27" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="13" y="27" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="27" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="19" y="27" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="23" y="27" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="28" y="27" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="32" y="27" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 28 -->
|
||||
<rect x="9" y="28" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="13" y="28" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="16" y="28" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="20" y="28" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="23" y="28" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="26" y="28" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="31" y="28" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="33" y="28" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 29 -->
|
||||
<rect x="8" y="29" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="10" y="29" width="4" height="1" fill="#1a1a1a"/>
|
||||
<rect x="15" y="29" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="18" y="29" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="23" y="29" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="29" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="30" y="29" width="3" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 30 -->
|
||||
<rect x="9" y="30" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="30" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="30" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="30" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="26" y="30" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="30" y="30" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="33" y="30" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Row 31 -->
|
||||
<rect x="8" y="31" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="12" y="31" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="31" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="20" y="31" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="24" y="31" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="31" width="4" height="1" fill="#1a1a1a"/>
|
||||
<rect x="33" y="31" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Alignment pattern (bottom-right) -->
|
||||
<rect x="28" y="28" width="5" height="5" fill="#1a1a1a"/>
|
||||
<rect x="29" y="29" width="3" height="3" fill="#f5f5f0"/>
|
||||
<rect x="30" y="30" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<rect x="8" y="8" width="1" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Data rows below bottom-left finder (rows 34–39) -->
|
||||
<rect x="9" y="34" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="12" y="34" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="16" y="34" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="34" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="24" y="34" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="28" y="34" width="3" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<rect x="8" y="35" width="4" height="1" fill="#1a1a1a"/>
|
||||
<rect x="14" y="35" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="17" y="35" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="35" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="26" y="35" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="29" y="35" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<rect x="9" y="36" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="13" y="36" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="18" y="36" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="21" y="36" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="25" y="36" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="30" y="36" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<rect x="8" y="37" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="11" y="37" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="15" y="37" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="20" y="37" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="23" y="37" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="37" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="30" y="37" width="3" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<rect x="9" y="38" width="4" height="1" fill="#1a1a1a"/>
|
||||
<rect x="15" y="38" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="18" y="38" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="22" y="38" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="38" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="31" y="38" width="2" height="1" fill="#1a1a1a"/>
|
||||
|
||||
<rect x="8" y="39" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="12" y="39" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="15" y="39" width="2" height="1" fill="#1a1a1a"/>
|
||||
<rect x="19" y="39" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="22" y="39" width="3" height="1" fill="#1a1a1a"/>
|
||||
<rect x="27" y="39" width="1" height="1" fill="#1a1a1a"/>
|
||||
<rect x="29" y="39" width="4" height="1" fill="#1a1a1a"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="qr-sub">
|
||||
<span class="mac-chip">1C:C3:AB:D1:91:F8</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.body -->
|
||||
|
||||
</div><!-- /.display -->
|
||||
</div><!-- /.device-bezel -->
|
||||
|
||||
<!--
|
||||
DESIGNER NOTES
|
||||
──────────────────────────────────────────────────────────────────
|
||||
Screen: Setup QR — Step 2 of 2
|
||||
State: Frame joined home WiFi. Waiting for account link via scan.
|
||||
Accent: GREEN — signals progress, success, completion imminent.
|
||||
|
||||
Layout rationale:
|
||||
• Green bar immediately contrasts with AP screen's yellow — user
|
||||
registers "something changed, I'm further along." The WiFi
|
||||
icon in the bar confirms the network is live.
|
||||
• "Almost ready." is deliberately casual and warm — not "Device
|
||||
Provisioning Step 2/2." The sub-heading does the explaining.
|
||||
• URL bar at the bottom of instructions answers the fallback question
|
||||
(what if the QR doesn't scan?) without cluttering the main steps.
|
||||
• Progress track — three segments: WiFi done (green), Account
|
||||
(solid black = active), Frame ready (faint = todo) — gives the user
|
||||
a map without requiring them to read it.
|
||||
• Center panel reuses the orientation diagram pattern from screen 1,
|
||||
maintaining visual language across the two screens. The active
|
||||
orientation is green this time, consistent with the accent shift.
|
||||
• QR panel: green bracket border, MAC chip below the code. The MAC
|
||||
is shown because the URL contains it — the user may need to verify
|
||||
their device if they have multiple frames. Also tells the builder
|
||||
(Matt) what he's looking at during development.
|
||||
|
||||
Physical observation: if the frame is landscape (current mockup),
|
||||
the ribbon connector sits at the bottom of the frame, behind the
|
||||
stand or mount. The orientation diagram ribbon is bottom for
|
||||
landscape, left for portrait — this matches physical reality.
|
||||
|
||||
Firmware implementation notes:
|
||||
• This screen appears after STA mode connection confirmed.
|
||||
• The QR encodes the /setup/{mac} URL exactly.
|
||||
• The IP shown is the DHCP-assigned address — confirm in real code
|
||||
that the display format matches (colons in MAC, not dashes).
|
||||
• Frame should stay on this screen until the web app confirms
|
||||
account linkage — then reboot into image-cycling mode.
|
||||
──────────────────────────────────────────────────────────────────
|
||||
-->
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 10 KiB |
@@ -72,7 +72,7 @@ symfony new pictureframe --webapp
|
||||
|
||||
**Language & Runtime:** PHP 8.4+, Symfony 8.0
|
||||
|
||||
**Templating:** Twig with Stimulus and UX Turbo (Hotwire) — lightweight interactivity without a JS build step
|
||||
**Templating:** Twig — used only for public flows (provisioning setup, email approve/decline pages). Authenticated app is a Vue 3 SPA; Twig is not used there.
|
||||
|
||||
**ORM & Database:** Doctrine ORM, PostgreSQL 16 (DDEV local), migrations via `doctrine/migrations`
|
||||
|
||||
@@ -80,17 +80,19 @@ symfony new pictureframe --webapp
|
||||
|
||||
**Email:** Symfony Mailer — handles sharing flows and hard-delete confirmations
|
||||
|
||||
**Build Tooling:** AssetMapper (no Webpack/Node build step required)
|
||||
**Build Tooling:** Vite (Vue SPA, outputs to `public/build/`). AssetMapper not used — no Stimulus, no Turbo, no `importmap.php`.
|
||||
|
||||
**Testing:** PHPUnit via `symfony/test-pack`
|
||||
|
||||
**Code Organization:** Standard Symfony structure — `src/Entity`, `src/Controller`, `src/Repository`, `src/Service`, `templates/`
|
||||
**Code Organization:** Standard Symfony structure — `src/Entity`, `src/Controller`, `src/Repository`, `src/Service`, `templates/` (public flows only) + `frontend/` (Vue SPA source)
|
||||
|
||||
**Additional packages required:**
|
||||
- `symfony/messenger` + Doctrine transport — async image processing worker
|
||||
- `symfony/scheduler` — rotation engine and scheduled cleanup
|
||||
- Image processing library (TBD step 4: GD vs Imagick)
|
||||
|
||||
**Post-scaffold cleanup:** After `symfony new pictureframe --webapp`, remove `symfony/stimulus-bundle`, `symfony/ux-turbo`, and AssetMapper. Initialize the Vue SPA in `frontend/` with `npm create vite@latest frontend -- --template vue-ts`.
|
||||
|
||||
**Local Dev:** DDEV configured to mirror aqua-iq (PHP 8.4, Nginx-FPM, PostgreSQL 16)
|
||||
|
||||
**Note:** Project initialization using this command is the first implementation story.
|
||||
@@ -141,7 +143,9 @@ symfony new pictureframe --webapp
|
||||
|
||||
**Device API:** Single endpoint — `GET /api/device/{mac}/image` — returns raw binary (`application/octet-stream`). This is the only machine-to-machine API surface. No versioning scheme beyond URL stability guarantee (no breaking changes in V1).
|
||||
|
||||
**Web Controllers:** Standard Symfony controllers returning Twig responses. No JSON API for the web application.
|
||||
**Web Controllers (authenticated app):** Symfony controllers return JSON responses for all authenticated app API calls under the `/api/` prefix. No Twig rendering for authenticated routes — Symfony serves the SPA shell only. Controllers use `JsonResponse` or Symfony Serializer.
|
||||
|
||||
**Web Controllers (public flows):** `/setup/{mac}`, `/token/{uuid}/approve`, `/token/{uuid}/decline` return Twig responses. These are the only controllers that render HTML directly.
|
||||
|
||||
**Email:** Symfony Mailer. Transactional emails: image share notification (with approve link), hard-delete confirmation. Authorization links embedded as tokenized URLs pointing to Symfony routes.
|
||||
|
||||
@@ -153,13 +157,26 @@ symfony new pictureframe --webapp
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
**Templating:** Twig. Identical pattern to aqua-iq.
|
||||
**Authenticated App — Vue 3 SPA:**
|
||||
All authenticated routes are served by a Vue 3 SPA built with Vite + TypeScript strict mode. Vue Router handles client-side navigation; Pinia manages shared state (current user, device list, upload funnel state). SCSS modules scoped per SFC for component styles. Konva.js + Vue-Konva for the sticker canvas editor. No Stimulus, no Turbo, no AssetMapper.
|
||||
|
||||
**Interactivity:** Stimulus controllers + Turbo Drive (Hotwire). No SPA, no build step required.
|
||||
Symfony serves the SPA shell (`public/build/index.html`) via a catch-all route for all authenticated paths. Vue Router takes over client-side navigation from that point.
|
||||
|
||||
**Forms:** Symfony Form component.
|
||||
**Public Flows — Symfony + Twig:**
|
||||
Three routes remain as Symfony Twig pages with no Vue dependency:
|
||||
- `/setup/{mac}` — device provisioning setup (post-QR scan)
|
||||
- `/token/{uuid}/approve` — email approve page (no login required)
|
||||
- `/token/{uuid}/decline` — email decline page (no login required)
|
||||
|
||||
**Assets:** AssetMapper (no Webpack/Node).
|
||||
These must work with images disabled, CSS disabled, and screen reader only.
|
||||
|
||||
**TypeScript:** Strict mode. `frontend/src/types/` holds interfaces mirroring every Symfony API response shape: `Device`, `Image`, `StickerLayer`, `RenderedAsset`, `Token`. The compiler surfaces API contract drift before it reaches deployed devices.
|
||||
|
||||
**SCSS:** Modules scoped per SFC (`<style scoped lang="scss">`). Global tokens in `frontend/src/styles/global.scss`. No Tailwind, no utility CSS framework, no pre-built component library.
|
||||
|
||||
**Build:** Vite outputs to `public/build/`. `vite.config.ts` sets `outDir: '../public/build'`. The Symfony catch-all controller renders `public/build/index.html` directly.
|
||||
|
||||
**Forms:** Symfony Form component is not used for authenticated app forms — Vue handles all form logic. Symfony Form is only used in Twig public flows if needed.
|
||||
|
||||
### Infrastructure & Deployment
|
||||
|
||||
@@ -178,14 +195,15 @@ symfony new pictureframe --webapp
|
||||
### Decision Impact Analysis
|
||||
|
||||
**Implementation Sequence:**
|
||||
1. DDEV setup + Symfony scaffold (`symfony new pictureframe --webapp`)
|
||||
2. Add Messenger, Scheduler, Imagick
|
||||
3. Domain + Nginx config on VPS
|
||||
4. Core entities (User, Device, Image, RenderedAsset, Token)
|
||||
5. Image processing worker
|
||||
6. Device pull endpoint
|
||||
7. Web application features (library, approval, sharing, admin)
|
||||
8. Firmware (after domain + API contract confirmed)
|
||||
1. DDEV setup + Symfony scaffold (`symfony new pictureframe --webapp`); remove Stimulus/Turbo/AssetMapper
|
||||
2. Vue SPA scaffold in `frontend/` (`npm create vite@latest frontend -- --template vue-ts`); configure `vite.config.ts` to output to `public/build/`
|
||||
3. Add Messenger, Scheduler, Imagick to Symfony
|
||||
4. Domain + Nginx config on VPS
|
||||
5. Core entities (User, Device, Image, RenderedAsset, Token)
|
||||
6. Image processing worker
|
||||
7. Symfony JSON API endpoints + SpaController catch-all
|
||||
8. Vue SPA features (library, upload funnel, sticker editor, approvals, admin)
|
||||
9. Firmware (after domain + API contract confirmed)
|
||||
|
||||
**Cross-Component Dependencies:**
|
||||
- Firmware cannot be finalized until `pictureframe.edholm.me` is live and API endpoint format is confirmed
|
||||
@@ -302,13 +320,13 @@ Mandatory rules for all code in this project:
|
||||
|
||||
### Requirements to Structure Mapping
|
||||
|
||||
**User & Account Management** → `src/Controller/SecurityController.php`, `src/Entity/User.php`, `src/Repository/UserRepository.php`, `templates/security/`
|
||||
**User & Account Management** → `src/Controller/SecurityController.php`, `src/Controller/Api/UserApiController.php`, `src/Entity/User.php`, `src/Repository/UserRepository.php`
|
||||
|
||||
**Device Management** → `src/Controller/Device/`, `src/Entity/Device.php`, `src/Service/DeviceService.php`, `src/Repository/DeviceRepository.php`, `templates/device/`
|
||||
**Device Management** → `src/Controller/Api/DeviceApiController.php`, `src/Entity/Device.php`, `src/Service/DeviceService.php`, `src/Repository/DeviceRepository.php`, `frontend/src/views/HomeView.vue`
|
||||
|
||||
**Image Library** → `src/Controller/Image/`, `src/Entity/Image.php`, `src/Entity/RenderedAsset.php`, `src/Service/ImageService.php`, `src/Service/ImageProcessingService.php`, `templates/image/`
|
||||
**Image Library** → `src/Controller/Api/ImageApiController.php`, `src/Entity/Image.php`, `src/Entity/RenderedAsset.php`, `src/Service/ImageService.php`, `src/Service/ImageProcessingService.php`, `frontend/src/views/LibraryView.vue`
|
||||
|
||||
**Image Approval & Sharing** → `src/Controller/Token/`, `src/Entity/Token.php`, `src/Service/TokenService.php`, `templates/token/`
|
||||
**Image Approval & Sharing** → `src/Controller/Token/TokenActionController.php`, `src/Entity/Token.php`, `src/Service/TokenService.php`, `templates/token/` (email approve/decline pages), `frontend/src/components/ApproveCard.vue`
|
||||
|
||||
**Device Provisioning** → `src/Controller/Device/DeviceProvisionController.php`, `templates/device/provision.html.twig`
|
||||
|
||||
@@ -316,7 +334,7 @@ Mandatory rules for all code in this project:
|
||||
|
||||
**Display & Status (device pull endpoint)** → `src/Controller/Api/DeviceImageController.php`
|
||||
|
||||
**Admin & Moderation** → `src/Controller/Admin/`, `templates/admin/`
|
||||
**Admin & Moderation** → `src/Controller/Admin/`, `frontend/src/views/AdminView.vue` (if applicable)
|
||||
|
||||
**Cross-Cutting: Async Processing** → `src/Message/ProcessImageMessage.php`, `src/MessageHandler/ProcessImageMessageHandler.php`, `config/packages/messenger.yaml`
|
||||
|
||||
@@ -333,14 +351,55 @@ pictureframe/
|
||||
│ └── docker-compose.imagick.yaml ← adds Imagick to web container
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ └── ci.yml ← Gitea Actions: lint + test on push
|
||||
├── assets/
|
||||
│ ├── app.js ← AssetMapper entry, imports Stimulus + Turbo
|
||||
│ ├── controllers/
|
||||
│ │ ├── image_upload_controller.js
|
||||
│ │ └── device_status_controller.js
|
||||
│ └── styles/
|
||||
│ └── app.css
|
||||
│ └── ci.yml ← Gitea Actions: lint + test + vite build on push
|
||||
├── frontend/ ← Vue 3 SPA source (Vite + TypeScript)
|
||||
│ ├── src/
|
||||
│ │ ├── assets/
|
||||
│ │ │ └── stickers/ ← SVG sticker assets (Seasonal, Holidays, Fun, Family, Nature)
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── base/
|
||||
│ │ │ │ ├── BaseButton.vue
|
||||
│ │ │ │ ├── BaseInput.vue
|
||||
│ │ │ │ ├── BaseBottomSheet.vue
|
||||
│ │ │ │ ├── BaseCard.vue
|
||||
│ │ │ │ ├── BaseChip.vue
|
||||
│ │ │ │ └── BaseToast.vue
|
||||
│ │ │ ├── FrameCard.vue
|
||||
│ │ │ ├── CropEditor.vue
|
||||
│ │ │ ├── StickerCanvas.vue
|
||||
│ │ │ ├── StickerTray.vue
|
||||
│ │ │ ├── DevicePicker.vue
|
||||
│ │ │ ├── PhotoThumb.vue
|
||||
│ │ │ ├── ShareSheet.vue
|
||||
│ │ │ ├── ApproveCard.vue
|
||||
│ │ │ └── BottomNav.vue
|
||||
│ │ ├── router/
|
||||
│ │ │ └── index.ts ← Vue Router; catch-all handled by Symfony
|
||||
│ │ ├── stores/
|
||||
│ │ │ ├── auth.ts ← current user, login state
|
||||
│ │ │ ├── devices.ts ← device list, current device
|
||||
│ │ │ └── upload.ts ← upload funnel state, sticker composition
|
||||
│ │ ├── styles/
|
||||
│ │ │ ├── _breakpoints.scss ← $bp-tablet: 640px, $bp-desktop: 960px
|
||||
│ │ │ ├── _tokens.scss ← CSS custom properties for all 6 themes
|
||||
│ │ │ └── global.scss ← reset, typography, base layout
|
||||
│ │ ├── types/
|
||||
│ │ │ ├── Device.ts
|
||||
│ │ │ ├── Image.ts
|
||||
│ │ │ ├── StickerLayer.ts ← { id, type, x, y, scale, rotation }
|
||||
│ │ │ ├── RenderedAsset.ts
|
||||
│ │ │ └── Token.ts
|
||||
│ │ ├── views/
|
||||
│ │ │ ├── HomeView.vue ← FrameList — FrameCard per device
|
||||
│ │ │ ├── LibraryView.vue ← photo grid, search, tabs (All/Mine/Shared)
|
||||
│ │ │ ├── UploadView.vue ← funnel: crop → sticker → device picker
|
||||
│ │ │ └── SettingsView.vue
|
||||
│ │ ├── App.vue
|
||||
│ │ └── main.ts
|
||||
│ ├── index.html
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── vite.config.ts ← outDir: '../public/build'
|
||||
├── bin/
|
||||
│ └── console
|
||||
├── config/
|
||||
@@ -352,30 +411,30 @@ pictureframe/
|
||||
│ │ ├── scheduler.yaml
|
||||
│ │ ├── security.yaml ← form_login, remember_me, ROLE_SUPER_ADMIN hierarchy
|
||||
│ │ └── twig.yaml
|
||||
│ ├── routes.yaml
|
||||
│ ├── routes.yaml ← catch-all route → SpaController for authenticated paths
|
||||
│ ├── routes/
|
||||
│ │ └── api.yaml ← /api/device/{mac}/image route
|
||||
│ │ └── api.yaml ← /api/device/{mac}/image + /api/* app endpoints
|
||||
│ └── services.yaml
|
||||
├── migrations/
|
||||
├── public/
|
||||
│ ├── build/ ← gitignored; Vite output (index.html + hashed assets)
|
||||
│ └── index.php
|
||||
├── src/
|
||||
│ ├── Controller/
|
||||
│ │ ├── Api/
|
||||
│ │ │ └── DeviceImageController.php ← GET /api/device/{mac}/image → 200/204/404
|
||||
│ │ │ ├── DeviceImageController.php ← GET /api/device/{mac}/image → 200/204/404 (binary)
|
||||
│ │ │ ├── DeviceApiController.php ← JSON CRUD for devices (Vue SPA)
|
||||
│ │ │ ├── ImageApiController.php ← JSON CRUD for images, upload, share (Vue SPA)
|
||||
│ │ │ └── UserApiController.php ← current user, settings (Vue SPA)
|
||||
│ │ ├── Admin/
|
||||
│ │ │ ├── AdminDashboardController.php
|
||||
│ │ │ └── AdminModerationController.php
|
||||
│ │ ├── SpaController.php ← catch-all; renders public/build/index.html
|
||||
│ │ ├── SecurityController.php ← login/logout (form POST, JSON response)
|
||||
│ │ ├── Device/
|
||||
│ │ │ ├── DeviceController.php
|
||||
│ │ │ └── DeviceProvisionController.php
|
||||
│ │ ├── Image/
|
||||
│ │ │ ├── ImageLibraryController.php
|
||||
│ │ │ ├── ImageUploadController.php
|
||||
│ │ │ └── ImageShareController.php
|
||||
│ │ ├── SecurityController.php
|
||||
│ │ │ └── DeviceProvisionController.php ← GET/POST /setup/{mac} → Twig
|
||||
│ │ └── Token/
|
||||
│ │ └── TokenActionController.php ← consume approve/decline/hard-delete tokens
|
||||
│ │ └── TokenActionController.php ← /token/{uuid}/approve|decline → Twig
|
||||
│ ├── Entity/
|
||||
│ │ ├── Device.php
|
||||
│ │ ├── Image.php
|
||||
@@ -386,10 +445,6 @@ pictureframe/
|
||||
│ │ ├── Orientation.php
|
||||
│ │ ├── RenderStatus.php
|
||||
│ │ └── TokenType.php
|
||||
│ ├── Form/
|
||||
│ │ ├── DeviceType.php
|
||||
│ │ ├── ImageUploadType.php
|
||||
│ │ └── RegistrationType.php
|
||||
│ ├── Message/
|
||||
│ │ └── ProcessImageMessage.php ← DTO: imageId, deviceModel, orientation
|
||||
│ ├── MessageHandler/
|
||||
@@ -413,31 +468,20 @@ pictureframe/
|
||||
├── storage/
|
||||
│ └── images/ ← gitignored; STORAGE_PATH points here
|
||||
├── templates/
|
||||
│ ├── admin/
|
||||
│ │ ├── dashboard.html.twig
|
||||
│ │ └── moderation.html.twig
|
||||
│ ├── device/
|
||||
│ │ ├── index.html.twig
|
||||
│ │ ├── provision.html.twig
|
||||
│ │ └── show.html.twig
|
||||
│ ├── image/
|
||||
│ │ ├── library.html.twig
|
||||
│ │ ├── share.html.twig
|
||||
│ │ └── upload.html.twig
|
||||
│ ├── security/
|
||||
│ │ └── login.html.twig
|
||||
│ │ └── provision.html.twig ← /setup/{mac} public provisioning page
|
||||
│ ├── token/
|
||||
│ │ ├── approve.html.twig
|
||||
│ │ └── decline.html.twig
|
||||
│ └── base.html.twig
|
||||
│ │ ├── approve.html.twig ← email approve page (no login)
|
||||
│ │ └── decline.html.twig ← email decline page (no login)
|
||||
│ └── base_public.html.twig ← minimal base for public Twig pages only
|
||||
├── tests/
|
||||
│ ├── Functional/
|
||||
│ │ ├── Api/
|
||||
│ │ │ └── DeviceImageControllerTest.php
|
||||
│ │ ├── Device/
|
||||
│ │ │ └── DeviceControllerTest.php
|
||||
│ │ └── Image/
|
||||
│ │ └── ImageLibraryControllerTest.php
|
||||
│ │ │ ├── DeviceImageControllerTest.php
|
||||
│ │ │ ├── DeviceApiControllerTest.php
|
||||
│ │ │ └── ImageApiControllerTest.php
|
||||
│ │ └── Token/
|
||||
│ │ └── TokenActionControllerTest.php
|
||||
│ ├── Integration/
|
||||
│ │ ├── MessageHandler/
|
||||
│ │ │ └── ProcessImageMessageHandlerTest.php
|
||||
@@ -461,7 +505,6 @@ pictureframe/
|
||||
├── .gitignore
|
||||
├── composer.json
|
||||
├── composer.lock
|
||||
├── importmap.php
|
||||
├── phpunit.xml.dist
|
||||
└── symfony.lock
|
||||
```
|
||||
@@ -472,7 +515,7 @@ pictureframe/
|
||||
`GET /api/device/{mac}/image` is the only machine-to-machine surface. MAC address validated against `Device` entity before serving. Returns `application/octet-stream`, 204, or 404. Isolated in `config/routes/api.yaml`.
|
||||
|
||||
**Web Application Boundary**
|
||||
All other routes behind Symfony form-login firewall. Twig responses only. `ROLE_SUPER_ADMIN` gates admin controllers.
|
||||
Authenticated routes are gated by Symfony form-login firewall. `SpaController` serves `public/build/index.html` as the catch-all — Vue Router handles client-side navigation from there. All authenticated data flows through `/api/*` JSON endpoints. `ROLE_SUPER_ADMIN` gates admin API controllers. Public flows (`/setup/{mac}`, `/token/{uuid}/approve|decline`) are outside the firewall and return Twig responses — no Vue involved.
|
||||
|
||||
**Async Processing Boundary**
|
||||
`ImageService` → Messenger bus → `ProcessImageMessageHandler`. Handler is the only component writing to `storage/images/`. `RenderedAsset.status` is the only signal crossing this boundary.
|
||||
@@ -513,7 +556,7 @@ User soft-deletes image → Image.deleted_at set (excluded by findActive*)
|
||||
|
||||
### Coherence Validation ✅
|
||||
|
||||
**Decision Compatibility:** PHP 8.4 + Symfony 8.0 + Doctrine ORM + Messenger + Scheduler + PostgreSQL 16 is a fully supported combination. Imagick is a standalone PHP extension with no framework conflicts. AssetMapper + Stimulus + Turbo has first-party Symfony UX support and requires no build tooling.
|
||||
**Decision Compatibility:** PHP 8.4 + Symfony 8.0 + Doctrine ORM + Messenger + Scheduler + PostgreSQL 16 is a fully supported combination. Imagick is a standalone PHP extension with no framework conflicts. Vue 3 + Vite + TypeScript strict + SCSS modules + Konva.js is a standard, well-supported SPA stack. The Symfony/Vue boundary is clean: Symfony serves the SPA shell and JSON API; Vue owns all authenticated UI rendering. No build-step conflicts — Vite and Symfony operate independently.
|
||||
|
||||
**Pattern Consistency:** `findActive*` naming, `storage/images/` path convention, exclusive Messenger dispatch points, and backed enum usage are consistently applied. One contradiction (`var/images/` in Data Architecture section) found during validation and corrected — all references now point to `storage/images/`.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
stepsCompleted: [1]
|
||||
inputDocuments: ['prd.md', 'architecture.md']
|
||||
stepsCompleted: [1, 2, 3]
|
||||
inputDocuments: ['prd.md', 'architecture.md', 'ux-design-specification.md']
|
||||
workflowType: 'epics-and-stories'
|
||||
project_name: 'pictureFrame'
|
||||
user_name: 'Matt.edholm'
|
||||
@@ -107,11 +107,933 @@ Architecture-derived technical requirements:
|
||||
- **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
|
||||
|
||||
_To be completed in Step 3 (epic design)_
|
||||
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
|
||||
|
||||
_To be completed in Step 2_
|
||||
### 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)
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
---
|
||||
status: reviewed
|
||||
createdAt: '2026-05-06'
|
||||
reviewedBy: [ProductOwner, Architect, DevLead]
|
||||
---
|
||||
|
||||
# pictureFrame — Comprehensive Test Plan
|
||||
|
||||
## 0. Scope and Goals
|
||||
|
||||
This plan covers unit, integration, and functional tests for all three layers of the pictureFrame system:
|
||||
|
||||
1. **Server** — Symfony 8 / PHP 8.4 backend (controllers, services, message handlers, repositories)
|
||||
2. **Frontend** — Vue 3 SPA (stores, components, views)
|
||||
3. **Firmware** — ESP32 Arduino C++ (control flow, protocol correctness)
|
||||
|
||||
Primary goals:
|
||||
- Prevent regressions in the firmware ↔ server API contract (any break requires physical reflash of every deployed device)
|
||||
- Cover all branching logic in `RotationService` and `DeviceImageController` — the most complex, failure-prone code
|
||||
- FW-02 is the explicit regression test for the NVS header-read-after-end bug (headers read after `http.end()` → NVS never updated → 304 never fires)
|
||||
- Give CI a green/red signal before any server deploy
|
||||
|
||||
---
|
||||
|
||||
## 1. Tooling and Infrastructure
|
||||
|
||||
### 1.1 PHP / Server
|
||||
|
||||
| Tool | Role | Already present? |
|
||||
|---|---|---|
|
||||
| PHPUnit 13.1 | Unit + integration + functional test runner | Yes (`phpunit.dist.xml`, `tests/bootstrap.php`) |
|
||||
| `symfony/test-pack` | `WebTestCase`, `KernelTestCase`, test client | Needs adding |
|
||||
| `dama/doctrine-test-bundle` | Wraps each test in a rolled-back transaction (fast isolation) | Needs adding |
|
||||
| `doctrine/fixtures-bundle` | Seed fixture objects without raw SQL | Needs adding |
|
||||
| Separate test database | `_test` suffix already in `doctrine.yaml` | Configured, DB needs creating |
|
||||
|
||||
**Required setup steps (all inside `ddev exec`):**
|
||||
|
||||
```bash
|
||||
# Install test dependencies
|
||||
ddev exec composer require --dev symfony/test-pack dama/doctrine-test-bundle doctrine/fixtures-bundle
|
||||
|
||||
# Create test DB and run migrations
|
||||
ddev exec php bin/console doctrine:database:create --env=test
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction --env=test
|
||||
```
|
||||
|
||||
**`phpunit.dist.xml` — add dama extension:**
|
||||
```xml
|
||||
<extensions>
|
||||
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
|
||||
</extensions>
|
||||
```
|
||||
|
||||
**`.env.test.local`** (not committed — each developer creates this):
|
||||
```
|
||||
DATABASE_URL="pgsql://db:db@db:5432/pictureframe_test"
|
||||
```
|
||||
|
||||
**`.env.test`** — add Messenger transport override so dispatched messages are observable in integration tests:
|
||||
```
|
||||
MESSENGER_TRANSPORT_DSN=in-memory://
|
||||
```
|
||||
Without this, `RenderImageMessage` dispatch assertions (IMG-01, SH-03, IMG-06) will silently not fire.
|
||||
|
||||
**Filesystem teardown for message handler tests:** `dama/doctrine-test-bundle` rolls back DB writes but NOT filesystem writes. Tests in `RenderImageMessageHandlerTest` that write `.bin` files to `storage/images/` must call a teardown that deletes the test image directory after each test. Use `setUp()`/`tearDown()` with a dedicated `storage/test/` prefix, or run handler tests in a separate suite without dama.
|
||||
|
||||
### 1.2 Frontend
|
||||
|
||||
| Tool | Role | Already present? |
|
||||
|---|---|---|
|
||||
| Vitest 2.x | Unit + component test runner (Vite-native) | Needs adding |
|
||||
| `@vue/test-utils` | Mount Vue components in JSDOM | Needs adding |
|
||||
| `@vitest/coverage-v8` | Coverage reporting | Needs adding |
|
||||
| MSW 2.x | Intercept `fetch()` calls in tests | Needs adding |
|
||||
|
||||
Add to `frontend/package.json` devDependencies, then add `test` block to `vite.config.ts`:
|
||||
```ts
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
}
|
||||
```
|
||||
|
||||
**Known gap:** `CropEditor.vue` and `StickerCanvas.vue` depend on Konva.js canvas APIs unavailable in JSDOM. These two components are excluded from component tests and do NOT count toward the 60% component coverage target. They require Playwright E2E or manual testing.
|
||||
|
||||
### 1.3 Firmware
|
||||
|
||||
| Tool | Role | Already present? |
|
||||
|---|---|---|
|
||||
| PlatformIO `native` env | Compiles C++ for host, runs Unity tests | Needs adding |
|
||||
| Unity (bundled) | Assertions and test runner macros | Bundled |
|
||||
| Hand-rolled Arduino mocks | Stubs for Arduino/ESP APIs | Needs writing |
|
||||
|
||||
**`platformio.ini` additions:**
|
||||
```ini
|
||||
[env:native-test]
|
||||
platform = native
|
||||
lib_deps =
|
||||
throwtheswitch/Unity@^2.6
|
||||
build_flags = -DUNIT_TEST -Itest/mocks
|
||||
test_build_src = no
|
||||
```
|
||||
|
||||
`test_build_src = no` is **required** — `yes` would compile `setup()` and `loop()` from `main.cpp` for the host, colliding with PlatformIO's native `main()` shim (ODR violation / duplicate symbol linker error).
|
||||
|
||||
**HTTPClient injection — template, not polymorphism:** Arduino's `HTTPClient` methods are not virtual, so `normal_operation()` cannot accept an `HTTPClient&` base reference. Use a compile-time template instead:
|
||||
|
||||
```cpp
|
||||
template<typename HTTP>
|
||||
static void normal_operation_impl(const String& mac, HTTP& http) { ... }
|
||||
|
||||
// Production:
|
||||
static void normal_operation(const String& mac) {
|
||||
WiFiClientSecure client;
|
||||
client.setInsecure();
|
||||
HTTPClient http;
|
||||
http.begin(client, url);
|
||||
normal_operation_impl(mac, http);
|
||||
}
|
||||
```
|
||||
|
||||
The mock `HTTP` type and the real `HTTPClient` share no base class — the template resolves at compile time. Production code is unchanged in behavior; tests instantiate with a mock type.
|
||||
|
||||
**Mock surface** — all files in `firmware/test/mocks/` (included before system headers via `-Itest/mocks` build flag):
|
||||
|
||||
| Mock file | Stubs | Notes |
|
||||
|---|---|---|
|
||||
| `Arduino.h` | `millis()`, `delay()`, `pinMode()`, `digitalRead()`, `String`, `Serial` | |
|
||||
| `MockHTTPClient.h` | `begin()`, `GET()`, `header()`, `end()`, `writeToStream()`, `addHeader()`, `collectHeaders()` | `header()` must return empty string after `end()` is called — this is the FW-02 behavior |
|
||||
| `WiFi.h` | `macAddress()`, `status()`, `begin()`, `mode()`, `softAP()`, `softAPIP()`, `localIP()` | |
|
||||
| `WiFiClientSecure.h` | No-op constructor, `setInsecure()` | |
|
||||
| `Preferences.h` | In-memory `std::map`-backed `getInt()`, `putInt()`, `getString()`, `putString()`, `clear()`, `begin()`, `end()` | |
|
||||
| `LittleFS.h` / `FS.h` | In-memory file store; `writeToStream()` in `MockHTTPClient` pumps into this store | The wiring between HTTPClient mock and LittleFS mock must be explicit — mock HTTP holds a pointer to mock FS |
|
||||
| `epd.h` | No-op stubs + call counters for each `epd_*` function | Shadows real header via `-Itest/mocks` — do NOT put mocks in `firmware/src/` |
|
||||
| `esp_sleep.h` | Captures `sleepUs` in a global; separate `g_deepSleepStarted` boolean | Both `esp_sleep_enable_timer_wakeup` and `esp_deep_sleep_start` must be mocked |
|
||||
|
||||
---
|
||||
|
||||
## 2. Server Tests
|
||||
|
||||
### Test taxonomy
|
||||
|
||||
| Label used in this plan | Symfony base class | Scope |
|
||||
|---|---|---|
|
||||
| **Unit** | `PHPUnit\Framework\TestCase` | Pure PHP logic, mock repositories — no DB, no container |
|
||||
| **Integration** | `KernelTestCase` + dama | Real DB, real container, no HTTP routing |
|
||||
| **Functional** | `WebTestCase` + dama | Full HTTP stack: routing, firewall, kernel |
|
||||
|
||||
### 2.1 Unit Tests — `RotationService`
|
||||
|
||||
File: `tests/Unit/Service/RotationServiceTest.php`
|
||||
|
||||
Uses `PHPUnit\Framework\TestCase` with mock `EntityManager` and `DeviceImageHistoryRepository`. No DB, no container — pure selection algorithm logic.
|
||||
|
||||
| # | Test | What it asserts |
|
||||
|---|---|---|
|
||||
| R-01 | `advance_returns_null_when_pool_empty` | No ready RenderedAssets → `advance()` returns `null` |
|
||||
| R-02 | `advance_picks_oldest_image_with_no_history` | Pool of 3 images, no history → returns oldest by `uploadedAt` |
|
||||
| R-03 | `advance_skips_recently_shown_image` | Image A shown last → Image B returned next |
|
||||
| R-04 | `advance_resets_when_all_candidates_in_window` | All pool images in uniqueness window → picks from full pool (no infinite loop) |
|
||||
| R-05 | `advance_respects_uniqueness_window_size` | Window=2, pool=5 → only 2 IDs excluded |
|
||||
| R-06 | `advance_writes_DeviceImageHistory_record` | `em->persist()` called with a `DeviceImageHistory` for the returned image |
|
||||
| R-07 | `advance_updates_device_current_image` | `$device->getCurrentImage()` matches returned image |
|
||||
| R-08 | `advance_only_considers_ready_assets` | Image with `pending` RenderedAsset excluded from pool |
|
||||
| R-09 | `advance_respects_device_approved_images_only` | Image not approved for this device excluded from pool |
|
||||
| R-10 | `advance_not_called_when_device_has_locked_image` | Controller bypasses `advance()` entirely when `lockedImage` set — assert `advance()` is never reached (test via controller, see I-06) |
|
||||
|
||||
### 2.2 Unit Tests — `TokenService`
|
||||
|
||||
File: `tests/Unit/Service/TokenServiceTest.php`
|
||||
|
||||
| # | Test | What it asserts |
|
||||
|---|---|---|
|
||||
| T-01 | `issue_creates_token_with_correct_type` | Returns `Token` with matching `TokenType` |
|
||||
| T-02 | `issue_sets_expiry_in_future` | `expiresAt > now()` |
|
||||
| T-03 | `consume_marks_token_used` | `usedAt` is set on the persisted entity |
|
||||
| T-04 | `consume_returns_token_entity` | Return value is the `Token` object |
|
||||
| T-05 | `consume_throws_on_expired_token` | Token past `expiresAt` → exception |
|
||||
| T-06 | `consume_throws_on_already_used_token` | `usedAt` already set → exception |
|
||||
| T-07 | `consume_throws_on_type_mismatch` | Requesting wrong `TokenType` → exception |
|
||||
|
||||
### 2.3 Unit Tests — `DeviceService`
|
||||
|
||||
File: `tests/Unit/Service/DeviceServiceTest.php`
|
||||
|
||||
| # | Test | What it asserts |
|
||||
|---|---|---|
|
||||
| D-01 | `link_associates_device_with_user` | `$device->getUser()` matches after link |
|
||||
| D-02 | `link_idempotent_for_same_user` | Calling link twice does not throw or create duplicates |
|
||||
| D-03 | `purgeHistory_removes_records_beyond_window` | History older than uniqueness window count is deleted |
|
||||
| D-04 | `re_provision_clears_history_and_transfers_ownership` | Physical reset (clear credentials) wipes `DeviceImageHistory` and sets `user = null` on device — FR9 security invariant |
|
||||
|
||||
### 2.4 Functional Tests — `DeviceImageController`
|
||||
|
||||
File: `tests/Functional/Controller/DeviceImageControllerTest.php`
|
||||
|
||||
Uses `WebTestCase`. This is the highest-value test class — it covers the entire firmware-facing API contract.
|
||||
|
||||
| # | Route | Setup | Expected response |
|
||||
|---|---|---|---|
|
||||
| I-01 | `GET /api/device/{unknownMac}/image` | MAC not in DB | 404 |
|
||||
| I-02 | `GET /api/device/{mac}/image` | Device exists, no approved images | 204 |
|
||||
| I-03 | `GET /api/device/{mac}/image` | One ready image, no `X-Current-Image-Id` | 200, body is binary, `X-Image-Id` set, `X-Interval-Ms` set |
|
||||
| I-04 | `GET /api/device/{mac}/image` + `X-Current-Image-Id: {id}` | ID matches current image | 304, no body, headers still set |
|
||||
| I-05 | `GET /api/device/{mac}/image` + `X-Current-Image-Id: {staleId}` | ID does NOT match current image | 200, new image served |
|
||||
| I-06 | `GET /api/device/{mac}/image` | Locked image set, no `X-Current-Image-Id` | 200, locked image served; `RotationService::advance()` NOT called (spy/mock) |
|
||||
| I-07 | `GET /api/device/{mac}/image` + `X-Current-Image-Id: {lockedId}` | Locked image = current image | 304 |
|
||||
| I-08 | `GET /api/device/{mac}/image` twice, second call changes image | Rotation advances: first call returns Image A, second call (different `X-Current-Image-Id`) returns Image B | Use a single-request test asserting `currentImage` changed in DB, not two HTTP calls through dama rollback |
|
||||
| I-09 | `GET /api/device/{mac}/image` | `X-Interval-Ms` value | `rotationIntervalMinutes * 60 * 1000`, bounded by server ceiling |
|
||||
| I-10 | `GET /api/device/{mac}/image` 200 response | `lastSeenAt` side effect | `device.lastSeenAt` updated after 200 poll |
|
||||
| I-11 | `GET /api/device/{mac}/image` 304 response | `lastSeenAt` side effect | `device.lastSeenAt` also updated after 304 — a 304 is a successful poll |
|
||||
|
||||
### 2.5 Functional Tests — `DeviceApiController`
|
||||
|
||||
File: `tests/Functional/Controller/DeviceApiControllerTest.php`
|
||||
|
||||
| # | Route | Notes |
|
||||
|---|---|---|
|
||||
| A-01 | `GET /api/devices` (authenticated) | Returns only requesting user's devices |
|
||||
| A-02 | `GET /api/devices` (unauthenticated) | 401 |
|
||||
| A-03 | `PATCH /api/devices/{id}` | Updates name, orientation, rotationIntervalMinutes |
|
||||
| A-04 | `PATCH /api/devices/{id}` — wrong user | 403 / 404 |
|
||||
| A-05 | `PUT /api/devices/{id}/lock` | Sets `lockedImage`, response includes `lockedImageId` |
|
||||
| A-06 | `PUT /api/devices/{id}/lock` — image not approved for device | 422 |
|
||||
| A-07 | `DELETE /api/devices/{id}/lock` | Clears `lockedImage`, response has `lockedImageId: null` |
|
||||
| A-08 | `PUT /api/devices/{id}/lock` — wrong user's device | 403 |
|
||||
|
||||
### 2.6 Functional Tests — `ImageApiController`
|
||||
|
||||
File: `tests/Functional/Controller/ImageApiControllerTest.php`
|
||||
|
||||
| # | Route | Notes |
|
||||
|---|---|---|
|
||||
| IMG-01 | `POST /api/images` | Valid upload → 201, `Image` in DB, `RenderImageMessage` in in-memory transport |
|
||||
| IMG-02 | `POST /api/images` | No file → 422 |
|
||||
| IMG-03 | `GET /api/images` | Returns authenticated user's images with thumbnail/original URLs |
|
||||
| IMG-04 | `DELETE /api/images/{id}` | Soft-deletes image (`deletedAt` set, row still exists) |
|
||||
| IMG-05 | `DELETE /api/images/{id}` — wrong user | 403 |
|
||||
| IMG-06 | `POST /api/images/{id}/reprocess` | New `RenderImageMessage` in in-memory transport |
|
||||
|
||||
### 2.7 Functional Tests — `SharedImageApiController`
|
||||
|
||||
File: `tests/Functional/Controller/SharedImageApiControllerTest.php`
|
||||
|
||||
| # | Route | Notes |
|
||||
|---|---|---|
|
||||
| SH-01 | `GET /api/shared` | Paginated result, `totalPages`/`total`/`page` fields correct |
|
||||
| SH-02 | `GET /api/shared?status=pending` | Filters by status |
|
||||
| SH-03 | `PUT /api/shared/{id}/approve` | Status → approved, `RenderImageMessage` in in-memory transport |
|
||||
| SH-04 | `PUT /api/shared/{id}/decline` | Status → declined |
|
||||
| SH-05 | `PUT /api/shared/{id}/approve` — wrong user | 403 |
|
||||
| SH-06 | Approve → image enters rotation pool | After approval + render, image appears in `RotationService::readyPool()` for the recipient's device |
|
||||
|
||||
### 2.8 Functional Tests — `TokenActionController`
|
||||
|
||||
File: `tests/Functional/Controller/TokenActionControllerTest.php`
|
||||
|
||||
| # | Test | Notes |
|
||||
|---|---|---|
|
||||
| TK-01 | Valid `share_approve` token | Approval performed, token marked used, redirected |
|
||||
| TK-02 | Expired token | 404 or 410 |
|
||||
| TK-03 | Already-used token | 404 or 410 |
|
||||
| TK-04 | Wrong token type | 404 |
|
||||
| TK-05 | `hard_delete_confirm` token | Image hard-deleted from DB and storage |
|
||||
|
||||
### 2.9 Functional Tests — `SetupController`
|
||||
|
||||
File: `tests/Functional/Controller/SetupControllerTest.php`
|
||||
|
||||
| # | Route | Notes |
|
||||
|---|---|---|
|
||||
| S-01 | `GET /setup/{mac}` — new device | Shows registration/login form |
|
||||
| S-02 | `GET /setup/{mac}` — already-linked device | Redirects to configure step |
|
||||
| S-03 | `POST /setup/{mac}/register` | Creates User, links Device to User |
|
||||
| S-04 | `POST /setup/{mac}/register` — duplicate email | Validation error |
|
||||
| S-05 | `POST /setup/{mac}/configure` | Saves `name`, `orientation`, `rotationIntervalMinutes` |
|
||||
|
||||
### 2.10 Functional Tests — `SecurityController`
|
||||
|
||||
File: `tests/Functional/Controller/SecurityControllerTest.php`
|
||||
|
||||
| # | Route | Notes |
|
||||
|---|---|---|
|
||||
| SEC-01 | `POST /login` valid credentials | Session cookie set, redirected |
|
||||
| SEC-02 | `POST /login` wrong password | Error message, no session |
|
||||
| SEC-03 | `POST /register` | Creates user with hashed password |
|
||||
| SEC-04 | `POST /register` duplicate email | Form error |
|
||||
|
||||
### 2.11 Integration Tests — Message Handlers
|
||||
|
||||
**Note:** These tests run without `dama/doctrine-test-bundle` transaction wrapping because they write to the filesystem. Run in a separate PHPUnit suite (`tests/Integration/`) with their own teardown strategy: each test uses a unique `storage/test/{uuid}/` path, cleaned up in `tearDown()`.
|
||||
|
||||
File: `tests/Integration/MessageHandler/RenderImageMessageHandlerTest.php`
|
||||
|
||||
Requires Imagick installed (present in DDEV via `php8.4-imagick`). Run `ddev exec` — not on host.
|
||||
|
||||
| # | Test | Notes |
|
||||
|---|---|---|
|
||||
| MH-01 | Valid image + device model | `RenderedAsset.status` = `ready`, `.bin` file written to storage path |
|
||||
| MH-01b | **4bpp palette contract** | Reads first bytes of written `.bin`; asserts palette indices match Spectra 6 map (BLACK=0x0, WHITE=0x1, YELLOW=0x2, RED=0x3, BLUE=0x5, GREEN=0x6) — prevents a server-side Imagick palette bug from silently producing a garbled display |
|
||||
| MH-02 | Missing source image file | `RenderedAsset.status` = `failed`, no uncaught exception |
|
||||
| MH-03 | Both orientations | Two `RenderedAsset` rows for same image (landscape + portrait) |
|
||||
|
||||
File: `tests/Integration/MessageHandler/RunImageCleanupMessageHandlerTest.php`
|
||||
|
||||
| # | Test | Notes |
|
||||
|---|---|---|
|
||||
| CL-01 | Image past retention with approvals | Soft-delete flag set |
|
||||
| CL-02 | Image past retention with no approvals | Hard-deleted from DB and storage |
|
||||
| CL-03 | Recent image | Untouched |
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend Tests
|
||||
|
||||
### 3.1 Store Tests
|
||||
|
||||
**`tests/frontend/stores/devices.test.ts`**
|
||||
|
||||
| # | Test | Notes |
|
||||
|---|---|---|
|
||||
| DS-01 | `fetchDevices` success | `devices` populated, `loading` false |
|
||||
| DS-02 | `fetchDevices` network error | `error` set, `devices` empty |
|
||||
| DS-03 | `updateDevice` patches local array | `devices[idx]` updated after PATCH |
|
||||
| DS-04 | `lockImage` sets `lockedImageId` on local device | Updated from server response |
|
||||
| DS-05 | `unlockImage` clears `lockedImageId` | `null` in local state |
|
||||
|
||||
**`tests/frontend/stores/images.test.ts`**
|
||||
|
||||
| # | Test | Notes |
|
||||
|---|---|---|
|
||||
| IM-01 | `fetchImages` success | `images` populated |
|
||||
| IM-02 | Upload workflow state transitions | `setFile` → `setCrop` → `setStickers` → `setDevices` in correct order |
|
||||
| IM-03 | `pendingCount` computed | Reflects `status === 'pending'` images |
|
||||
|
||||
**`tests/frontend/stores/auth.test.ts`**
|
||||
|
||||
| # | Test | Notes |
|
||||
|---|---|---|
|
||||
| AU-01 | Bootstraps from `window.__BOOTSTRAP_USER__` | `user` set, `isAuthenticated` true |
|
||||
| AU-02 | No bootstrap data | `isAuthenticated` false |
|
||||
|
||||
**`tests/frontend/stores/toast.test.ts`**
|
||||
|
||||
| # | Test | Notes |
|
||||
|---|---|---|
|
||||
| TO-01 | `push` adds message | Queue length +1 |
|
||||
| TO-02 | Auto-dismiss after 2.5s | Queue empty after timeout (Vitest fake timers) |
|
||||
|
||||
### 3.2 Component Tests
|
||||
|
||||
**`tests/frontend/components/FrameCard.test.ts`**
|
||||
|
||||
| # | Test | Notes |
|
||||
|---|---|---|
|
||||
| FC-01 | Status "ok" renders green badge | `lastSeenAt` recent |
|
||||
| FC-02 | Status "sync-fail" renders yellow badge | `lastSeenAt` >2× interval ago |
|
||||
| FC-03 | Status "no-wifi" badge | `lastSeenAt` null |
|
||||
|
||||
**`tests/frontend/components/BaseButton.test.ts`**
|
||||
|
||||
| # | Test | Notes |
|
||||
|---|---|---|
|
||||
| BB-01 | Loading spinner when `loading=true` | Label hidden |
|
||||
| BB-02 | Disabled when `disabled=true` | `disabled` attribute present |
|
||||
| BB-03 | Emits `click` when not disabled | |
|
||||
|
||||
**`tests/frontend/components/DevicePicker.test.ts`**
|
||||
|
||||
| # | Test | Notes |
|
||||
|---|---|---|
|
||||
| DP-01 | Selecting device emits `update:modelValue` | |
|
||||
| DP-02 | Deselecting removes from list | |
|
||||
| DP-03 | Pre-selected devices shown as checked | |
|
||||
|
||||
**`tests/frontend/components/ShareSheet.test.ts`**
|
||||
|
||||
| # | Test | Notes |
|
||||
|---|---|---|
|
||||
| SS-01 | Valid email submits share API call | `POST /api/share` intercepted by MSW |
|
||||
| SS-02 | Empty email shows validation error, no API call | |
|
||||
| SS-03 | Success closes sheet | |
|
||||
|
||||
### 3.3 View Integration Tests
|
||||
|
||||
**`tests/frontend/views/LibraryView.test.ts`**
|
||||
|
||||
| # | Test | Notes |
|
||||
|---|---|---|
|
||||
| LV-01 | Mounted in "uploaded" tab | Image grid renders |
|
||||
| LV-02 | Switch to "shared" tab | `fetchShared` called, ApproveCards render |
|
||||
| LV-03 | Lock chip shown for approved devices | Chip present below image |
|
||||
| LV-04 | Unlocked chip click calls `lockImage` | Store action called |
|
||||
| LV-05 | Locked chip (solid) click calls `unlockImage` | Store action called |
|
||||
| LV-06 | Share button opens ShareSheet | `shareSheetOpen` true |
|
||||
| LV-07 | Empty library state | Empty-state prompt visible when `images.length === 0` |
|
||||
|
||||
**`tests/frontend/views/HomeView.test.ts`**
|
||||
|
||||
| # | Test | Notes |
|
||||
|---|---|---|
|
||||
| HV-01 | Renders FrameCard for each device | N cards for N devices |
|
||||
| HV-02 | Empty state shown when no devices | Prompt/empty message visible |
|
||||
|
||||
---
|
||||
|
||||
## 4. Firmware Tests
|
||||
|
||||
### 4.1 Architecture
|
||||
|
||||
PlatformIO `native` env (`test_build_src = no`) compiles only `firmware/test/` test files and the specific source files they include explicitly. `setup()` and `loop()` from `main.cpp` are excluded — they would collide with PlatformIO's native `main()` shim.
|
||||
|
||||
The testable logic in `main.cpp` is extracted into:
|
||||
- `operation.cpp` / `operation.h` — `normal_operation_impl<HTTP>(mac, http)` template function
|
||||
- `provisioning.cpp` / `provisioning.h` — `ap_ssid_from_mac()`, `attempt_wifi()`
|
||||
|
||||
`epd.h` is shadowed by `firmware/test/mocks/epd.h` via the `-Itest/mocks` build flag in `[env:native-test]`.
|
||||
|
||||
### 4.2 Unit Tests — `normal_operation_impl()` control flow
|
||||
|
||||
File: `firmware/test/test_normal_operation.cpp`
|
||||
|
||||
| # | Setup | Expected |
|
||||
|---|---|---|
|
||||
| FW-01 | HTTP 200, `X-Image-Id: 42`, `X-Interval-Ms: 60000` | File written to mock FS, `epd_draw_image_from_file` call count = 1, NVS `img_id = 42`, sleep = 60000ms, `g_deepSleepStarted = true` |
|
||||
| FW-02 | HTTP 200, assert header read order | `newId` is `"42"` (non-empty) — verifies headers read BEFORE `http.end()`. **Regression test for NVS bug.** |
|
||||
| FW-03 | HTTP 304 | `epd_init` call count = 0, `epd_draw_image_from_file` call count = 0, sleep set from `X-Interval-Ms`, `g_deepSleepStarted = true` |
|
||||
| FW-04 | HTTP 204 | `show_setup_qr` (or `epd_draw_setup_screen`) call count = 1, `epd_draw_image_from_file` call count = 0 |
|
||||
| FW-05 | HTTP 404 | `show_setup_qr` call count = 1 |
|
||||
| FW-06 | HTTP 500 | `epd_fill(COLOR_YELLOW)` called |
|
||||
| FW-07 | NVS has saved `img_id = 99` | `X-Current-Image-Id: 99` present in request headers sent by mock HTTP |
|
||||
| FW-08 | NVS `img_id` at default (-1) | `X-Current-Image-Id` header NOT added to request |
|
||||
| FW-09 | `X-Interval-Ms: 120000`, `FETCH_INTERVAL_MS = 300000` | `sleepMs = 120000` (server value honored, within ceiling) |
|
||||
| FW-10 | `X-Interval-Ms: 600000`, `FETCH_INTERVAL_MS = 300000` | `sleepMs = 300000` (capped at firmware ceiling) |
|
||||
| FW-11 | `X-Interval-Ms` absent | `sleepMs = FETCH_INTERVAL_MS` (default) |
|
||||
|
||||
### 4.3 Unit Tests — Provisioning
|
||||
|
||||
File: `firmware/test/test_provisioning.cpp`
|
||||
|
||||
| # | Test | Expected |
|
||||
|---|---|---|
|
||||
| FW-12 | MAC `AA:BB:CC:DD:EE:FF` | SSID = `PictureFrame-EEFF` |
|
||||
| FW-13 | MAC `1C:C3:AB:D1:91:F8` | SSID = `PictureFrame-91F8` |
|
||||
| FW-14 | WiFi connects within timeout | `attempt_wifi()` returns `true` |
|
||||
| FW-15 | WiFi never connects | `attempt_wifi()` returns `false` after `WIFI_TIMEOUT_MS` |
|
||||
| FW-16 | WiFi connect fails → re-provisioning | `loop()` on `attempt_wifi()` failure: `epd_fill(COLOR_RED)` called, then `enter_provisioning()` called — confirms provisioning state machine re-enters correctly |
|
||||
|
||||
### 4.4 Unit Tests — Button-hold re-provisioning
|
||||
|
||||
File: `firmware/test/test_reset_button.cpp`
|
||||
|
||||
| # | Test | Expected |
|
||||
|---|---|---|
|
||||
| FW-17 | `digitalRead(PIN_BTN_RESET)` returns `LOW` for ≥ `RESET_HOLD_MS` | `clear_creds = true`, `prefs.clear()` called, provisioning mode entered |
|
||||
| FW-18 | Button released before `RESET_HOLD_MS` | `clear_creds = false`, normal boot continues |
|
||||
|
||||
---
|
||||
|
||||
## 5. Coverage Targets
|
||||
|
||||
| Layer | Target | Priority |
|
||||
|---|---|---|
|
||||
| `RotationService` | 100% line | Critical |
|
||||
| `DeviceImageController` | 100% line | Critical |
|
||||
| `TokenService` | 95%+ | High |
|
||||
| All other `src/Service/` | 80%+ | High |
|
||||
| All `src/Controller/` | 80%+ | High |
|
||||
| `src/MessageHandler/` | 85%+ | High (rendering pipeline is high-risk) |
|
||||
| Frontend stores | 80%+ | High |
|
||||
| Frontend components | 60%+ (excluding CropEditor, StickerCanvas) | Medium |
|
||||
| Firmware control flow (`operation.cpp`) | 90%+ branches | High |
|
||||
|
||||
---
|
||||
|
||||
## 6. CI Integration
|
||||
|
||||
All three suites must pass before merge to `master`.
|
||||
|
||||
```bash
|
||||
# Server
|
||||
ddev exec php bin/phpunit --coverage-text
|
||||
|
||||
# Firmware
|
||||
cd firmware && pio test -e native-test
|
||||
|
||||
# Frontend
|
||||
cd frontend && npx vitest run --coverage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation Order
|
||||
|
||||
**Phase 1 — Server functional + integration tests** (highest ROI, minimal new tooling)
|
||||
1. `composer require --dev symfony/test-pack dama/doctrine-test-bundle doctrine/fixtures-bundle`
|
||||
2. Register dama extension in `phpunit.dist.xml`
|
||||
3. Add `MESSENGER_TRANSPORT_DSN=in-memory://` to `.env.test`
|
||||
4. Create + migrate test DB
|
||||
5. Write `DeviceImageControllerTest` (I-01 through I-11) — covers the firmware API contract
|
||||
6. Write `RotationServiceTest` (R-01 through R-09) — pure unit, no DB
|
||||
7. Write remaining controller + service tests
|
||||
|
||||
**Phase 2 — Firmware native tests** (second priority — protects the one thing that requires a physical reflash)
|
||||
1. Extract `normal_operation_impl<HTTP>()` template into `operation.cpp`
|
||||
2. Add `[env:native-test]` to `platformio.ini` (`test_build_src = no`)
|
||||
3. Write mock headers in `firmware/test/mocks/`
|
||||
4. Write `test_normal_operation.cpp` (FW-01 through FW-11, FW-02 is the regression test)
|
||||
5. Write `test_provisioning.cpp` (FW-12 through FW-16)
|
||||
6. Write `test_reset_button.cpp` (FW-17 through FW-18)
|
||||
|
||||
**Phase 3 — Frontend unit tests**
|
||||
1. Add Vitest + `@vue/test-utils` + MSW to `package.json`
|
||||
2. Add `test` block to `vite.config.ts`
|
||||
3. Write store tests (no DOM needed — fastest to write)
|
||||
4. Write component tests
|
||||
|
||||
**Phase 4 — CI pipeline**
|
||||
1. Add Gitea Actions workflow
|
||||
2. Wire all three test commands in sequence
|
||||
|
||||
---
|
||||
|
||||
## 8. Known Gaps (explicitly out of scope for V1 tests)
|
||||
|
||||
- `CropEditor.vue` and `StickerCanvas.vue` — Konva.js canvas not testable in JSDOM; require Playwright E2E
|
||||
- Admin moderation panel (`AdminModerationController`) — not yet implemented
|
||||
- Collections approval (FR21) — not yet implemented
|
||||
- E-ink display pixel-level rendering correctness — no practical automated test; validated by eye on hardware
|
||||
- Interval timing drift accuracy (±5 min PRD requirement) — requires a time-series integration test; deferred to post-V1
|
||||
@@ -0,0 +1,507 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>pictureFrame — Design Directions</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Segoe UI', -apple-system, sans-serif; background: #1a1614; color: #f0ece8; padding: 24px 16px 48px; }
|
||||
h1 { text-align: center; font-size: 20px; font-weight: 700; margin-bottom: 4px; }
|
||||
.subtitle { text-align: center; color: #8a7e78; font-size: 13px; margin-bottom: 32px; }
|
||||
|
||||
.dir-tabs { display: flex; gap: 8px; justify-content: center; margin-bottom: 28px; flex-wrap: wrap; }
|
||||
.dir-tab { padding: 8px 18px; border-radius: 100px; border: 2px solid #3a3230; background: #2a2220; color: #c0b0a8; cursor: pointer; font-size: 13px; font-weight: 600; transition: all 0.2s; }
|
||||
.dir-tab:hover { border-color: #c4622a; color: #f0ece8; }
|
||||
.dir-tab.active { background: #c4622a; border-color: #c4622a; color: white; }
|
||||
|
||||
.dir-section { display: none; }
|
||||
.dir-section.active { display: block; }
|
||||
|
||||
.dir-header { text-align: center; margin-bottom: 24px; }
|
||||
.dir-header h2 { font-size: 20px; font-weight: 800; margin-bottom: 6px; }
|
||||
.dir-header p { font-size: 14px; color: #a09088; max-width: 480px; margin: 0 auto; line-height: 1.6; }
|
||||
|
||||
/* Phone shell */
|
||||
.phone-row { display: flex; gap: 20px; justify-content: center; flex-wrap: wrap; margin-bottom: 32px; }
|
||||
.phone-wrap { display: flex; flex-direction: column; align-items: center; gap: 10px; }
|
||||
.phone-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: #8a7e78; }
|
||||
.phone { width: 260px; background: #111; border-radius: 36px; padding: 12px; box-shadow: 0 8px 40px rgba(0,0,0,0.6), inset 0 0 0 2px #333; }
|
||||
.phone-screen { background: #fff8f0; border-radius: 26px; overflow: hidden; min-height: 480px; display: flex; flex-direction: column; }
|
||||
|
||||
/* Shared UI atoms */
|
||||
.status-bar { display: flex; justify-content: space-between; padding: 8px 16px 4px; font-size: 10px; font-weight: 700; color: #8a7060; }
|
||||
.screen-body { flex: 1; overflow: hidden; }
|
||||
.bottom-nav { display: flex; justify-content: space-around; padding: 8px 0 10px; border-top: 1px solid #f0e0d4; background: white; }
|
||||
.nav-item { display: flex; flex-direction: column; align-items: center; gap: 2px; font-size: 9px; font-weight: 700; color: #c0a090; }
|
||||
.nav-item.on { color: #c4622a; }
|
||||
.nav-icon { font-size: 18px; }
|
||||
|
||||
/* Atoms */
|
||||
.px { padding: 0 16px; }
|
||||
.screen-title { font-size: 22px; font-weight: 800; color: #2a1e16; padding: 14px 16px 8px; }
|
||||
.screen-sub { font-size: 12px; color: #a08070; padding: 0 16px 12px; }
|
||||
.chip { display: inline-block; padding: 3px 10px; border-radius: 100px; font-size: 10px; font-weight: 700; background: #fde8da; color: #c4622a; margin: 0 2px; }
|
||||
.chip-g { background: #daeee4; color: #2a5a3e; }
|
||||
.photo-thumb { border-radius: 10px; background: #e8d8c8; display: flex; align-items: center; justify-content: center; font-size: 22px; }
|
||||
.card-white { background: white; border-radius: 12px; padding: 12px; box-shadow: 0 1px 6px rgba(196,98,42,0.08); }
|
||||
.fab { width: 52px; height: 52px; border-radius: 50%; background: #c4622a; display: flex; align-items: center; justify-content: center; font-size: 24px; color: white; box-shadow: 0 4px 16px rgba(196,98,42,0.4); }
|
||||
.section-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: #c0a090; padding: 0 16px 6px; }
|
||||
.row { display: flex; align-items: center; }
|
||||
.divider { height: 1px; background: #f0e0d4; margin: 8px 16px; }
|
||||
|
||||
/* Insight boxes */
|
||||
.insights { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; max-width: 560px; margin: 0 auto; }
|
||||
@media (max-width: 500px) { .insights { grid-template-columns: 1fr; } }
|
||||
.insight { background: #2a2220; border-radius: 12px; padding: 14px 16px; border-left: 3px solid #c4622a; }
|
||||
.insight h4 { font-size: 12px; font-weight: 700; color: #f0ece8; margin-bottom: 4px; }
|
||||
.insight p { font-size: 12px; color: #907870; line-height: 1.5; }
|
||||
.insight.pro { border-color: #4aaa70; }
|
||||
.insight.pro h4 { color: #7adda0; }
|
||||
.insight.con { border-color: #d06050; }
|
||||
.insight.con h4 { color: #f09080; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>pictureFrame — Design Directions</h1>
|
||||
<p class="subtitle">5 layout approaches · Warm Craft theme · tap to explore</p>
|
||||
|
||||
<div class="dir-tabs">
|
||||
<button class="dir-tab active" onclick="showDir(1)">1 · Photo Grid</button>
|
||||
<button class="dir-tab" onclick="showDir(2)">2 · Frame-Centric</button>
|
||||
<button class="dir-tab" onclick="showDir(3)">3 · Upload-First</button>
|
||||
<button class="dir-tab" onclick="showDir(4)">4 · Activity Feed</button>
|
||||
<button class="dir-tab" onclick="showDir(5)">5 · Minimal Card</button>
|
||||
</div>
|
||||
|
||||
<!-- ════ DIRECTION 1: Photo Grid ════ -->
|
||||
<div class="dir-section active" id="dir1">
|
||||
<div class="dir-header">
|
||||
<h2>1 · Photo Grid</h2>
|
||||
<p>Home is a mosaic of your photos. The frame assignment is a secondary action. Feels like a personal gallery that happens to connect to a frame.</p>
|
||||
</div>
|
||||
<div class="phone-row">
|
||||
<div class="phone-wrap">
|
||||
<div class="phone-label">Home</div>
|
||||
<div class="phone">
|
||||
<div class="phone-screen">
|
||||
<div class="status-bar"><span>9:41</span><span>●●●</span></div>
|
||||
<div class="screen-title">Your Photos</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:3px;padding:0 12px 12px;">
|
||||
<div class="photo-thumb" style="height:80px;">🏔️</div>
|
||||
<div class="photo-thumb" style="height:80px;grid-row:span 2;height:163px;">👨👩👧</div>
|
||||
<div class="photo-thumb" style="height:80px;">🌅</div>
|
||||
<div class="photo-thumb" style="height:80px;">🎄</div>
|
||||
<div class="photo-thumb" style="height:80px;">🐕</div>
|
||||
<div class="photo-thumb" style="height:80px;">🎂</div>
|
||||
<div class="photo-thumb" style="height:80px;">🏖️</div>
|
||||
<div class="photo-thumb" style="height:80px;">❄️</div>
|
||||
</div>
|
||||
<div style="position:absolute;bottom:72px;right:20px;" class="fab" style="position:relative;">+</div>
|
||||
<div class="bottom-nav">
|
||||
<div class="nav-item on"><span class="nav-icon">📷</span><span>Photos</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">🖼️</span><span>Frames</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">↗️</span><span>Shared</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">👤</span><span>Me</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone-wrap">
|
||||
<div class="phone-label">Photo Detail</div>
|
||||
<div class="phone">
|
||||
<div class="phone-screen">
|
||||
<div class="status-bar"><span>9:41</span><span>●●●</span></div>
|
||||
<div class="photo-thumb" style="height:220px;border-radius:0;font-size:64px;">🏔️</div>
|
||||
<div style="padding:14px 16px 0;">
|
||||
<div style="font-size:15px;font-weight:700;color:#2a1e16;margin-bottom:4px;">Lake Trip 2024</div>
|
||||
<div style="font-size:12px;color:#a08070;margin-bottom:12px;">Added 3 days ago · On 2 frames</div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:14px;">
|
||||
<span class="chip">Margaret's Frame</span>
|
||||
<span class="chip">Living Room</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<div style="flex:1;background:#c4622a;color:white;border-radius:100px;padding:10px;text-align:center;font-size:12px;font-weight:700;">Edit & Add</div>
|
||||
<div style="flex:1;background:#f0e8df;color:#c4622a;border-radius:100px;padding:10px;text-align:center;font-size:12px;font-weight:700;">Share</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bottom-nav" style="margin-top:auto;">
|
||||
<div class="nav-item on"><span class="nav-icon">📷</span><span>Photos</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">🖼️</span><span>Frames</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">↗️</span><span>Shared</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">👤</span><span>Me</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="insights">
|
||||
<div class="insight pro"><h4>✓ Familiar</h4><p>Feels like Apple Photos or Google Photos — users know how to navigate it immediately.</p></div>
|
||||
<div class="insight pro"><h4>✓ Photo-forward</h4><p>The content (photos) is the hero. Great for libraries with many images.</p></div>
|
||||
<div class="insight con"><h4>✗ Frame is secondary</h4><p>The physical frame — the whole point — is buried under a tab, not the first thing you see.</p></div>
|
||||
<div class="insight con"><h4>✗ Upload entry unclear</h4><p>Floating "+" button competes with the grid. Upload vs. camera roll confusion likely.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════ DIRECTION 2: Frame-Centric ════ -->
|
||||
<div class="dir-section" id="dir2">
|
||||
<div class="dir-header">
|
||||
<h2>2 · Frame-Centric</h2>
|
||||
<p>Home shows your frames first. Each frame has a current photo preview. You navigate into a frame to add or manage photos for it. The frame is always the destination.</p>
|
||||
</div>
|
||||
<div class="phone-row">
|
||||
<div class="phone-wrap">
|
||||
<div class="phone-label">Home</div>
|
||||
<div class="phone">
|
||||
<div class="phone-screen">
|
||||
<div class="status-bar"><span>9:41</span><span>●●●</span></div>
|
||||
<div class="screen-title">Your Frames</div>
|
||||
<div style="padding:0 12px 12px;display:flex;flex-direction:column;gap:10px;">
|
||||
<div class="card-white" style="display:flex;gap:12px;align-items:center;">
|
||||
<div class="photo-thumb" style="width:64px;height:40px;flex-shrink:0;">👨👩👧</div>
|
||||
<div style="flex:1;">
|
||||
<div style="font-size:13px;font-weight:700;color:#2a1e16;">Margaret's Frame</div>
|
||||
<div style="font-size:11px;color:#a08070;">14 photos · Updates daily</div>
|
||||
</div>
|
||||
<span class="chip-g chip" style="font-size:9px;">Live</span>
|
||||
</div>
|
||||
<div class="card-white" style="display:flex;gap:12px;align-items:center;">
|
||||
<div class="photo-thumb" style="width:64px;height:40px;flex-shrink:0;">🏠</div>
|
||||
<div style="flex:1;">
|
||||
<div style="font-size:13px;font-weight:700;color:#2a1e16;">Living Room</div>
|
||||
<div style="font-size:11px;color:#a08070;">6 photos · Updates weekly</div>
|
||||
</div>
|
||||
<span class="chip-g chip" style="font-size:9px;">Live</span>
|
||||
</div>
|
||||
<div style="border:2px dashed #e0c8b8;border-radius:12px;padding:14px;text-align:center;">
|
||||
<div style="font-size:12px;color:#c0a090;font-weight:600;">+ Add a frame</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bottom-nav">
|
||||
<div class="nav-item on"><span class="nav-icon">🖼️</span><span>Frames</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📚</span><span>Library</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">↗️</span><span>Shared</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">👤</span><span>Me</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone-wrap">
|
||||
<div class="phone-label">Frame Detail</div>
|
||||
<div class="phone">
|
||||
<div class="phone-screen">
|
||||
<div class="status-bar"><span>9:41</span><span>●●●</span></div>
|
||||
<div style="padding:14px 16px 6px;display:flex;align-items:center;gap:10px;">
|
||||
<div style="font-size:12px;color:#a08070;">← Frames</div>
|
||||
</div>
|
||||
<div style="font-size:18px;font-weight:800;color:#2a1e16;padding:0 16px 4px;">Margaret's Frame</div>
|
||||
<div style="font-size:11px;color:#a08070;padding:0 16px 12px;">14 photos · Updates daily</div>
|
||||
<div style="display:flex;gap:8px;padding:0 12px 12px;">
|
||||
<div style="flex:1;background:#c4622a;color:white;border-radius:100px;padding:9px;text-align:center;font-size:12px;font-weight:700;">+ Add Photo</div>
|
||||
<div style="background:#f0e8df;color:#c4622a;border-radius:100px;padding:9px 14px;font-size:12px;font-weight:700;">Settings</div>
|
||||
</div>
|
||||
<div class="section-label">On this frame</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:3px;padding:0 12px;">
|
||||
<div class="photo-thumb" style="height:72px;">🏔️</div>
|
||||
<div class="photo-thumb" style="height:72px;">👨👩👧</div>
|
||||
<div class="photo-thumb" style="height:72px;">🌅</div>
|
||||
<div class="photo-thumb" style="height:72px;">🎄</div>
|
||||
<div class="photo-thumb" style="height:72px;">🐕</div>
|
||||
<div class="photo-thumb" style="height:72px;">🎂</div>
|
||||
</div>
|
||||
<div class="bottom-nav" style="margin-top:auto;">
|
||||
<div class="nav-item on"><span class="nav-icon">🖼️</span><span>Frames</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📚</span><span>Library</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">↗️</span><span>Shared</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">👤</span><span>Me</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="insights">
|
||||
<div class="insight pro"><h4>✓ Frame is the hero</h4><p>Every interaction starts with "which frame?" — keeps the physical gift front and center.</p></div>
|
||||
<div class="insight pro"><h4>✓ Clear mental model</h4><p>Navigate into a frame → add photos to it. Mirrors how users think about the product.</p></div>
|
||||
<div class="insight con"><h4>✗ Library is secondary</h4><p>Your full photo library is a tab away. Users who want to browse all photos have to look for it.</p></div>
|
||||
<div class="insight con"><h4>✗ Multi-frame add is extra steps</h4><p>Adding one photo to multiple frames requires navigating between frame detail screens.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════ DIRECTION 3: Upload-First ════ -->
|
||||
<div class="dir-section" id="dir3">
|
||||
<div class="dir-header">
|
||||
<h2>3 · Upload-First</h2>
|
||||
<p>The upload action IS the home screen. A big prominent upload button with recent activity below. The app communicates: the main thing you do here is put photos on frames.</p>
|
||||
</div>
|
||||
<div class="phone-row">
|
||||
<div class="phone-wrap">
|
||||
<div class="phone-label">Home</div>
|
||||
<div class="phone">
|
||||
<div class="phone-screen">
|
||||
<div class="status-bar"><span>9:41</span><span>●●●</span></div>
|
||||
<div style="padding:20px 16px 16px;">
|
||||
<div style="font-size:22px;font-weight:800;color:#2a1e16;margin-bottom:4px;">pictureFrame</div>
|
||||
<div style="font-size:12px;color:#a08070;">Hey Matt 👋</div>
|
||||
</div>
|
||||
<div style="margin:0 12px 16px;background:#c4622a;border-radius:16px;padding:20px;text-align:center;color:white;">
|
||||
<div style="font-size:32px;margin-bottom:8px;">📷</div>
|
||||
<div style="font-size:15px;font-weight:800;margin-bottom:4px;">Add a photo</div>
|
||||
<div style="font-size:11px;opacity:0.8;">Crop, add stickers, put it on a frame</div>
|
||||
</div>
|
||||
<div class="section-label">Recent</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;padding:0 12px;">
|
||||
<div style="display:flex;gap:10px;align-items:center;background:white;border-radius:10px;padding:10px;">
|
||||
<div class="photo-thumb" style="width:40px;height:40px;flex-shrink:0;font-size:18px;">🏔️</div>
|
||||
<div style="flex:1;">
|
||||
<div style="font-size:12px;font-weight:700;color:#2a1e16;">Lake Trip</div>
|
||||
<div style="font-size:10px;color:#a08070;">Margaret's Frame · 2 days ago</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;align-items:center;background:white;border-radius:10px;padding:10px;">
|
||||
<div class="photo-thumb" style="width:40px;height:40px;flex-shrink:0;font-size:18px;">🎄</div>
|
||||
<div style="flex:1;">
|
||||
<div style="font-size:12px;font-weight:700;color:#2a1e16;">Christmas 2024</div>
|
||||
<div style="font-size:10px;color:#a08070;">Living Room · 5 days ago</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bottom-nav" style="margin-top:auto;">
|
||||
<div class="nav-item on"><span class="nav-icon">🏠</span><span>Home</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📚</span><span>Library</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">🖼️</span><span>Frames</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">👤</span><span>Me</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone-wrap">
|
||||
<div class="phone-label">Upload Flow Step 1</div>
|
||||
<div class="phone">
|
||||
<div class="phone-screen">
|
||||
<div class="status-bar"><span>9:41</span><span>●●●</span></div>
|
||||
<div style="padding:14px 16px 6px;display:flex;align-items:center;justify-content:space-between;">
|
||||
<div style="font-size:13px;color:#a08070;">Cancel</div>
|
||||
<div style="font-size:14px;font-weight:700;color:#2a1e16;">Crop Photo</div>
|
||||
<div style="font-size:13px;font-weight:700;color:#c4622a;">Next →</div>
|
||||
</div>
|
||||
<div style="margin:8px 12px;background:#f0e8df;border-radius:14px;aspect-ratio:5/3;display:flex;align-items:center;justify-content:center;border:3px solid #c4622a;position:relative;">
|
||||
<div style="font-size:56px;">🏔️</div>
|
||||
<div style="position:absolute;bottom:8px;right:8px;background:rgba(196,98,42,0.15);border-radius:6px;padding:3px 7px;font-size:9px;font-weight:700;color:#c4622a;">Margaret's Frame</div>
|
||||
</div>
|
||||
<div style="padding:12px 16px 0;font-size:11px;color:#a08070;text-align:center;">Pinch and drag to fit the frame shape</div>
|
||||
<div style="padding:10px 16px 0;display:flex;gap:8px;justify-content:center;">
|
||||
<div style="background:#f0e8df;color:#c4622a;border-radius:100px;padding:8px 16px;font-size:11px;font-weight:700;">Landscape</div>
|
||||
<div style="background:#c4622a;color:white;border-radius:100px;padding:8px 16px;font-size:11px;font-weight:700;">Portrait</div>
|
||||
</div>
|
||||
<div class="bottom-nav" style="margin-top:auto;">
|
||||
<div class="nav-item on"><span class="nav-icon">🏠</span><span>Home</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📚</span><span>Library</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">🖼️</span><span>Frames</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">👤</span><span>Me</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="insights">
|
||||
<div class="insight pro"><h4>✓ Intent is unmistakable</h4><p>First thing you see is "add a photo." The app's purpose is communicated before anything else.</p></div>
|
||||
<div class="insight pro"><h4>✓ Upload funnel is the flow</h4><p>Tapping the big button goes straight into crop → sticker → add to frame. No detours.</p></div>
|
||||
<div class="insight con"><h4>✗ Repeat visitors see the same home</h4><p>After the 10th upload, the big prompt feels redundant. No sense of the growing collection.</p></div>
|
||||
<div class="insight con"><h4>✗ Library/frames are tabs</h4><p>Managing existing content requires navigation rather than being surfaced on home.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════ DIRECTION 4: Activity Feed ════ -->
|
||||
<div class="dir-section" id="dir4">
|
||||
<div class="dir-header">
|
||||
<h2>4 · Activity Feed</h2>
|
||||
<p>Home is a chronological feed of what's happened — photos added, shares received, approvals needed. Upload lives as a persistent top button. Feels connected and social.</p>
|
||||
</div>
|
||||
<div class="phone-row">
|
||||
<div class="phone-wrap">
|
||||
<div class="phone-label">Home</div>
|
||||
<div class="phone">
|
||||
<div class="phone-screen">
|
||||
<div class="status-bar"><span>9:41</span><span>●●●</span></div>
|
||||
<div style="padding:14px 16px 8px;display:flex;align-items:center;justify-content:space-between;">
|
||||
<div style="font-size:18px;font-weight:800;color:#2a1e16;">Recent</div>
|
||||
<div style="background:#c4622a;color:white;border-radius:100px;padding:7px 14px;font-size:12px;font-weight:700;">+ Photo</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:1px;padding:0 12px 12px;">
|
||||
<div style="background:white;border-radius:12px 12px 4px 4px;padding:12px;margin-bottom:2px;">
|
||||
<div style="font-size:10px;color:#a08070;margin-bottom:6px;">Sarah shared a photo · 1h ago</div>
|
||||
<div style="display:flex;gap:10px;align-items:center;">
|
||||
<div class="photo-thumb" style="width:48px;height:48px;flex-shrink:0;font-size:20px;">🎂</div>
|
||||
<div style="flex:1;">
|
||||
<div style="font-size:12px;font-weight:700;color:#2a1e16;">Birthday Party</div>
|
||||
<div style="font-size:10px;color:#a08070;">Tap to approve for a frame</div>
|
||||
</div>
|
||||
<div style="background:#fde8da;color:#c4622a;border-radius:100px;padding:5px 10px;font-size:10px;font-weight:700;">Approve</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:white;border-radius:4px;padding:12px;margin-bottom:2px;">
|
||||
<div style="font-size:10px;color:#a08070;margin-bottom:6px;">You added a photo · 2 days ago</div>
|
||||
<div style="display:flex;gap:10px;align-items:center;">
|
||||
<div class="photo-thumb" style="width:48px;height:48px;flex-shrink:0;font-size:20px;">🏔️</div>
|
||||
<div style="flex:1;">
|
||||
<div style="font-size:12px;font-weight:700;color:#2a1e16;">Lake Trip 2024</div>
|
||||
<div style="font-size:10px;color:#a08070;">On Margaret's Frame</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:white;border-radius:4px 4px 12px 12px;padding:12px;">
|
||||
<div style="font-size:10px;color:#a08070;margin-bottom:6px;">Frame updated · 5 days ago</div>
|
||||
<div style="font-size:12px;font-weight:600;color:#2a1e16;">Margaret's Frame cycled to a new photo</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bottom-nav">
|
||||
<div class="nav-item on"><span class="nav-icon">📋</span><span>Feed</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📚</span><span>Library</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">🖼️</span><span>Frames</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">👤</span><span>Me</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone-wrap">
|
||||
<div class="phone-label">Approve Flow</div>
|
||||
<div class="phone">
|
||||
<div class="phone-screen">
|
||||
<div class="status-bar"><span>9:41</span><span>●●●</span></div>
|
||||
<div style="padding:14px 16px 6px;display:flex;align-items:center;gap:8px;">
|
||||
<div style="font-size:12px;color:#a08070;">← Feed</div>
|
||||
</div>
|
||||
<div style="font-size:16px;font-weight:700;color:#2a1e16;padding:0 16px 4px;">Sarah shared a photo</div>
|
||||
<div style="font-size:11px;color:#a08070;padding:0 16px 12px;">Add it to one of your frames?</div>
|
||||
<div class="photo-thumb" style="height:160px;margin:0 12px 14px;font-size:64px;">🎂</div>
|
||||
<div class="section-label">Choose a frame</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;padding:0 12px 12px;">
|
||||
<div style="display:flex;gap:10px;align-items:center;background:white;border-radius:10px;padding:10px;border:2px solid #c4622a;">
|
||||
<div class="photo-thumb" style="width:40px;height:25px;flex-shrink:0;font-size:14px;">👵</div>
|
||||
<div style="font-size:12px;font-weight:700;color:#2a1e16;">Margaret's Frame</div>
|
||||
<div style="margin-left:auto;font-size:16px;">✓</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;align-items:center;background:white;border-radius:10px;padding:10px;">
|
||||
<div class="photo-thumb" style="width:40px;height:25px;flex-shrink:0;font-size:14px;">🏠</div>
|
||||
<div style="font-size:12px;font-weight:700;color:#2a1e16;">Living Room</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:0 12px;"><div style="background:#c4622a;color:white;border-radius:100px;padding:12px;text-align:center;font-size:13px;font-weight:700;">Add to Margaret's Frame</div></div>
|
||||
<div class="bottom-nav" style="margin-top:auto;">
|
||||
<div class="nav-item on"><span class="nav-icon">📋</span><span>Feed</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📚</span><span>Library</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">🖼️</span><span>Frames</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">👤</span><span>Me</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="insights">
|
||||
<div class="insight pro"><h4>✓ Approvals surface naturally</h4><p>Shared photos that need approval appear inline in the feed — no hunting for a notification.</p></div>
|
||||
<div class="insight pro"><h4>✓ Feels alive</h4><p>The home screen changes as photos are added and frames cycle. Gives the app a sense of activity.</p></div>
|
||||
<div class="insight con"><h4>✗ Complexity for simple users</h4><p>Margaret opening the app sees a feed with notifications and actions. Might be overwhelming.</p></div>
|
||||
<div class="insight con"><h4>✗ Upload is a button, not the hero</h4><p>The core action (add a photo) competes with the feed content for attention.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════ DIRECTION 5: Minimal Card ════ -->
|
||||
<div class="dir-section" id="dir5">
|
||||
<div class="dir-header">
|
||||
<h2>5 · Minimal Card</h2>
|
||||
<p>Home is clean and spacious — one frame card prominently displayed with its current photo, a clear "Add Photo" action, and simple navigation. Maximum focus, minimum noise.</p>
|
||||
</div>
|
||||
<div class="phone-row">
|
||||
<div class="phone-wrap">
|
||||
<div class="phone-label">Home (1 frame)</div>
|
||||
<div class="phone">
|
||||
<div class="phone-screen">
|
||||
<div class="status-bar"><span>9:41</span><span>●●●</span></div>
|
||||
<div style="padding:16px 16px 8px;display:flex;align-items:center;justify-content:space-between;">
|
||||
<div style="font-size:18px;font-weight:800;color:#2a1e16;">pictureFrame</div>
|
||||
<div style="font-size:20px;">👤</div>
|
||||
</div>
|
||||
<div style="margin:0 12px 16px;background:white;border-radius:16px;overflow:hidden;box-shadow:0 2px 12px rgba(196,98,42,0.1);">
|
||||
<div class="photo-thumb" style="height:140px;border-radius:0;font-size:56px;">👨👩👧</div>
|
||||
<div style="padding:14px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
|
||||
<div style="font-size:15px;font-weight:700;color:#2a1e16;">Margaret's Frame</div>
|
||||
<span class="chip-g chip" style="font-size:9px;">Live</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#a08070;margin-bottom:12px;">14 photos · Next cycle in 6 hours</div>
|
||||
<div style="background:#c4622a;color:white;border-radius:100px;padding:11px;text-align:center;font-size:13px;font-weight:700;">+ Add a Photo</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-label">Library</div>
|
||||
<div style="display:flex;gap:6px;padding:0 12px;overflow:hidden;">
|
||||
<div class="photo-thumb" style="width:64px;height:64px;flex-shrink:0;font-size:22px;">🏔️</div>
|
||||
<div class="photo-thumb" style="width:64px;height:64px;flex-shrink:0;font-size:22px;">🌅</div>
|
||||
<div class="photo-thumb" style="width:64px;height:64px;flex-shrink:0;font-size:22px;">🎄</div>
|
||||
<div class="photo-thumb" style="width:64px;height:64px;flex-shrink:0;font-size:22px;">🐕</div>
|
||||
</div>
|
||||
<div class="bottom-nav" style="margin-top:auto;">
|
||||
<div class="nav-item on"><span class="nav-icon">🏠</span><span>Home</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📚</span><span>Library</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">🖼️</span><span>Frames</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">👤</span><span>Me</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone-wrap">
|
||||
<div class="phone-label">Home (2+ frames)</div>
|
||||
<div class="phone">
|
||||
<div class="phone-screen">
|
||||
<div class="status-bar"><span>9:41</span><span>●●●</span></div>
|
||||
<div style="padding:16px 16px 8px;font-size:18px;font-weight:800;color:#2a1e16;">pictureFrame</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;padding:0 12px 12px;">
|
||||
<div style="background:white;border-radius:14px;overflow:hidden;box-shadow:0 2px 8px rgba(196,98,42,0.08);">
|
||||
<div class="photo-thumb" style="height:80px;border-radius:0;font-size:36px;">👨👩👧</div>
|
||||
<div style="padding:10px 12px;display:flex;align-items:center;justify-content:space-between;">
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:700;color:#2a1e16;">Margaret's Frame</div>
|
||||
<div style="font-size:10px;color:#a08070;">14 photos</div>
|
||||
</div>
|
||||
<div style="background:#c4622a;color:white;border-radius:100px;padding:6px 12px;font-size:11px;font-weight:700;">+ Add</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:white;border-radius:14px;overflow:hidden;box-shadow:0 2px 8px rgba(196,98,42,0.08);">
|
||||
<div class="photo-thumb" style="height:80px;border-radius:0;font-size:36px;">🏠</div>
|
||||
<div style="padding:10px 12px;display:flex;align-items:center;justify-content:space-between;">
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:700;color:#2a1e16;">Living Room</div>
|
||||
<div style="font-size:10px;color:#a08070;">6 photos</div>
|
||||
</div>
|
||||
<div style="background:#c4622a;color:white;border-radius:100px;padding:6px 12px;font-size:11px;font-weight:700;">+ Add</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bottom-nav" style="margin-top:auto;">
|
||||
<div class="nav-item on"><span class="nav-icon">🏠</span><span>Home</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📚</span><span>Library</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">🖼️</span><span>Frames</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">👤</span><span>Me</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="insights">
|
||||
<div class="insight pro"><h4>✓ Frame + action in one glance</h4><p>You see the frame's current photo AND the "Add Photo" button together. Purpose is immediate.</p></div>
|
||||
<div class="insight pro"><h4>✓ Scales cleanly</h4><p>One frame: big featured card. Two+ frames: stacked cards, each with their own Add button.</p></div>
|
||||
<div class="insight pro"><h4>✓ Closest to the mental model</h4><p>The frame is the hero, the photo is the content, the action is obvious. No secondary navigation needed for the primary job.</p></div>
|
||||
<div class="insight con"><h4>✗ Library is below the fold</h4><p>The photo library strip is a hint, not a feature. Power users with large libraries need to go to the Library tab.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showDir(n) {
|
||||
document.querySelectorAll('.dir-section').forEach(s => s.classList.remove('active'));
|
||||
document.querySelectorAll('.dir-tab').forEach(t => t.classList.remove('active'));
|
||||
document.getElementById('dir' + n).classList.add('active');
|
||||
document.querySelectorAll('.dir-tab')[n-1].classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,827 @@
|
||||
---
|
||||
stepsCompleted: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
|
||||
inputDocuments: ['prd.md', 'architecture.md']
|
||||
workflowType: 'ux-design'
|
||||
project_name: 'pictureFrame'
|
||||
user_name: 'Matt.edholm'
|
||||
date: '2026-04-27'
|
||||
---
|
||||
|
||||
# UX Design Specification - pictureFrame
|
||||
|
||||
**Author:** Matt.edholm
|
||||
**Date:** 2026-04-27
|
||||
|
||||
---
|
||||
|
||||
<!-- UX design content will be appended sequentially through collaborative workflow steps -->
|
||||
|
||||
## Executive Summary
|
||||
|
||||
### Project Vision
|
||||
|
||||
pictureFrame is a handcrafted e-ink digital picture frame ecosystem built as a meaningful gift. The physical frame is hand-built; the companion web app is where the frame's content comes to life — uploading photos, approving shares from family, curating what someone you love wakes up to every morning.
|
||||
|
||||
The app is not a monitoring dashboard. It is an image curation tool with a warm, personal, playful character — simple and obvious in its interactions, fun in its personality. Future directions (image stickers, overlays) are consistent with this tone: the app should feel like a place where you make something for someone, not a place where you manage a device.
|
||||
|
||||
### Target Users
|
||||
|
||||
**Margaret (recipient)** — non-technical gift recipient. Approves photos shared by family members. Her primary interaction is clicking an email approve link and choosing which frame to add a photo to — no login required for that flow. When she does open the app, it should be immediately obvious what to do.
|
||||
|
||||
**Matt (builder/admin)** — gift giver and super admin. Uploads the initial photo collection, approves images per device, configures device settings, monitors the fleet. Power user comfort without admin visual weight — the controls should feel purposeful, not bureaucratic.
|
||||
|
||||
**Sarah (contributor)** — family member. Uploads photos and shares them to a recipient. Should never need to understand approvals, device settings, or the rotation engine. Her job is: pick a photo, send it to someone.
|
||||
|
||||
### Key Design Challenges
|
||||
|
||||
1. **Non-technical provisioning** — Two-phase QR flow must be self-explanatory and self-healing. Every failure state resets automatically. No step can require interpretation.
|
||||
|
||||
2. **Email-first approval** — The share email is a primary UX surface, not just a notification. It must work on any email client, any device, with no login. The device-selection page after approval is a critical no-auth screen.
|
||||
|
||||
3. **Three users, one app** — Matt wants control and visibility. Sarah wants to pick a photo and go. The app must not expose contributor-level users to admin complexity, while giving Matt the full picture.
|
||||
|
||||
### Design Opportunities
|
||||
|
||||
1. **Curation as the core emotional experience** — Choosing which photos appear on someone's frame is an act of care. The image upload and approval flows should feel considered and personal, not transactional. Image detail views should feel like a canvas — ready for future sticker/overlay features without a redesign.
|
||||
|
||||
2. **The gift moment** — When a device is first linked via setup QR, there is an opportunity to frame the experience as "you just gave someone this" rather than "device provisioning complete."
|
||||
|
||||
3. **Warm, playful personality** — Simple and obvious as the baseline; fun as the texture. The visual and copy language should reflect that this is for family, not for IT departments.
|
||||
|
||||
## Core User Experience
|
||||
|
||||
### Defining Experience
|
||||
|
||||
The heart of pictureFrame is the upload-to-frame flow: pick a photo, make it yours, see it in the frame, put it there. Everything else — sharing, approvals, device settings — orbits this moment.
|
||||
|
||||
The editing experience is the product's personality. Crop/fit and pre-baked sticker overlays are first-class, not afterthoughts. Images with stickers retain their edit state and can be re-edited at any time — sticker placement, sizing, and removal is always available on images you own.
|
||||
|
||||
### Platform Strategy
|
||||
|
||||
Mobile-first web app. iOS Safari and Android Chrome are the primary targets; all flows are designed touch-first. Laptop is a supported secondary use case. No native app.
|
||||
|
||||
Touch interactions drive all design decisions: tap targets sized for thumbs, drag-to-position for sticker placement, tap-to-remove (×) on placed stickers.
|
||||
|
||||
### Upload & Edit Flow
|
||||
|
||||
The primary upload flow is a linear funnel on mobile:
|
||||
|
||||
1. **Pick** — select from camera roll or files
|
||||
2. **Crop/Fit** — frame-shaped crop UI (device aspect ratio always visible; landscape or portrait per device)
|
||||
3. **Stickers** — add pre-baked sticker overlays; drag to position, resize, tap × to delete; multiple stickers supported
|
||||
4. **Add to Frame** — choose which device(s) this goes to (own devices only; sharing to others uses the share flow)
|
||||
5. **Done** — return to home page
|
||||
|
||||
Images with stickers can be re-edited from the library at any time. The original and edit state (crop parameters, sticker positions/sizes) are stored separately from the rendered output.
|
||||
|
||||
### Sharing & Approval
|
||||
|
||||
Sharing is initiated from the UI (library or image detail). The email approve flow is the primary approval mechanism for recipients — one tap, no login required, device-selection page after approval.
|
||||
|
||||
Users can also approve or decline images directly within the app UI (equivalent action to the email flow, both are first-class paths).
|
||||
|
||||
### Critical Success Moments
|
||||
|
||||
- **First photo on the frame** — the upload-edit-add funnel completes and the image enters rotation. This should feel like sending something.
|
||||
- **Sticker placement** — drag-and-drop on mobile should feel immediate and fun; the frame preview keeps the recipient's experience in frame.
|
||||
- **Email approval** — one tap, confirmation, done. No friction for non-technical recipients.
|
||||
- **Re-editing** — returning to a photo with stickers and adjusting it should feel natural, not like a special mode.
|
||||
|
||||
### Experience Principles
|
||||
|
||||
1. **The frame is always in view** — the device's aspect ratio is visible throughout crop and sticker editing. You are always making something for someone.
|
||||
2. **Edit state is never lost** — sticker compositions and crop settings are preserved and re-editable indefinitely.
|
||||
3. **Fun is prominent** — stickers and editing are surfaced in the primary flow, not buried in an overflow menu.
|
||||
4. **One flow, complete** — upload leads directly to frame assignment; no orphaned library items unless the user explicitly chooses not to add to a device.
|
||||
5. **Approve your way** — email tap or UI action: both paths are first-class and produce identical results.
|
||||
|
||||
## Desired Emotional Response
|
||||
|
||||
### Primary Emotional Goals
|
||||
|
||||
- **Warmth** — sharing and curating photos should feel like an act of care, not a task. The product is for family; the UI should reflect that.
|
||||
- **Playfulness** — the sticker/editing experience is where personality lives. It should make you smile. A funny edit spreads because someone saw it on a frame and said "send me that one."
|
||||
- **Connection** — the frame is the social surface. You see a photo cycling on someone's wall, you want it, you ask for it. The app is how you send it; it should be fast.
|
||||
- **Confidence** — every step obvious and safe, no dead ends.
|
||||
|
||||
### Emotional Journey Mapping
|
||||
|
||||
| Moment | Target feeling |
|
||||
|---|---|
|
||||
| First open | "This feels friendly" |
|
||||
| Upload + crop | "Easy — and I'm making something" |
|
||||
| Sticker placement | "Haha, yes" |
|
||||
| Add to own frame | Immediate satisfaction — no approval, no wait |
|
||||
| See photo on someone's frame in person | "Send me that one" |
|
||||
| Owner finds it, shares it | Quick, obvious, done |
|
||||
| Email approve (recipient) | "Oh that was easy" |
|
||||
| Frame cycles to new photo | The payoff — warmth without the app |
|
||||
|
||||
### Micro-Emotions
|
||||
|
||||
- **Delight** — sticker interactions, fun copy, small animations on completion
|
||||
- **Accomplishment** — the upload-edit-add funnel ends with something on the frame, not in a queue
|
||||
- **Trust** — no approval needed for your own devices; the system does what you expect
|
||||
- **Connection** — the frame in the room drives the social loop; the app just closes it
|
||||
|
||||
### Design Implications
|
||||
|
||||
- **Warmth** → rounded UI, named devices ("Dad's Frame", not "Device #3"), friendly copy throughout
|
||||
- **Playfulness** → stickers prominent in edit flow; small celebratory feedback on actions
|
||||
- **Connection** → finding and sharing a photo should take seconds; the share flow is the product's social handshake
|
||||
- **Confidence** → one obvious action per screen; your own devices need no approval gate; clear feedback on every tap
|
||||
|
||||
### Emotions to Avoid
|
||||
|
||||
- Tech anxiety — especially for non-technical recipients
|
||||
- Overwhelm — settings and admin complexity never leak into contributor or recipient flows
|
||||
- Transactional — nothing should feel like filing paperwork
|
||||
- Waiting — adding to your own frame is instant; no approval limbo for the uploader
|
||||
|
||||
## UX Pattern Analysis & Inspiration
|
||||
|
||||
### Inspiring Products Analysis
|
||||
|
||||
**Snapchat — sticker/overlay editing**
|
||||
The gold standard for tap-to-place sticker UX on mobile. Users already know it: tap a sticker from a tray to place it on the canvas, drag to reposition, pinch to resize, tap to select, × to delete. Zero learning curve. The sticker tray scrolls horizontally and stays out of the way of the canvas. This is the exact pattern to replicate for pictureFrame's sticker editor.
|
||||
|
||||
**iMessage / Apple Photos — sharing and warmth**
|
||||
Named recipients, not abstract IDs. Sharing feels like handing something to a specific person. Confirmation is quiet — a small animation, not a modal. The visual language is warm and human without being childish.
|
||||
|
||||
**Instagram — mobile upload funnel**
|
||||
Crop-first design: you see the frame before you decide anything else. The aspect ratio is always visible. The upload flow is linear and committed — you move forward, not sideways. One obvious action per step.
|
||||
|
||||
**Canva (simplified) — pre-baked element tray**
|
||||
A scrollable horizontal tray of categorized elements (stickers, in our case) that opens from the bottom of the screen. Doesn't take over the canvas. Categories let you browse without overwhelming. Works well for non-technical users who just want to pick something fun.
|
||||
|
||||
### Transferable UX Patterns
|
||||
|
||||
**Sticker interaction (from Snapchat):**
|
||||
Tap from tray → placed on canvas → drag to move → pinch to resize → tap to re-select → × to delete. Applied directly to pictureFrame's sticker editor. Users arrive already knowing this pattern.
|
||||
|
||||
**Crop-first framing (from Instagram):**
|
||||
Show the device aspect ratio before anything else in the edit flow. The frame shape is the first thing you see — you are always designing for a specific physical object, not cropping an abstract photo.
|
||||
|
||||
**Named, human recipients (from iMessage):**
|
||||
Devices are always shown by their given name ("Margaret's Frame") in every context — upload, share, approve. Never a MAC address, device ID, or generic label.
|
||||
|
||||
**Bottom sheet element tray (from Canva/Snapchat):**
|
||||
Sticker categories and sticker items in a bottom sheet that slides up without covering the full canvas. Scrolls horizontally within categories. Dismisses by tapping the canvas.
|
||||
|
||||
**Quiet confirmation (from Apple Photos):**
|
||||
Completing an action (add to frame, approve, share) should feel settled and calm — a small visual acknowledgment, then return to context. No success modal that demands a tap to dismiss.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Approval-first design** — adding a photo to your own frame should never require an approval step. Approval friction belongs only in the cross-user sharing flow.
|
||||
- **Settings leaking into primary flows** — device configuration, rotation frequency, uniqueness windows are admin concerns. They should never appear in the upload, sticker, or share flows.
|
||||
- **Buried stickers** — if stickers require more than one tap to reach from the edit screen, they won't be used. They must be a first-class element of the editing UI.
|
||||
- **Empty canvas anxiety** — the crop/edit screen should never look blank or intimidating. The device frame shape, a clear "Add sticker" affordance, and the photo itself should fill the space confidently.
|
||||
- **Generic confirmation copy** — "Success" or "Done" is wasted space. Every completion moment should say something specific and warm.
|
||||
|
||||
### Design Inspiration Strategy
|
||||
|
||||
**Adopt directly:**
|
||||
- Snapchat sticker interaction model — tap, drag, pinch, ×
|
||||
- Instagram crop-first funnel — frame shape before anything else
|
||||
- Bottom sheet sticker tray — non-intrusive, dismissable
|
||||
|
||||
**Adapt for pictureFrame:**
|
||||
- Canva element tray → simplified to stickers only, mobile-optimized, warmer visual style
|
||||
- iMessage sharing warmth → applied to the share flow and email design, not just in-app interactions
|
||||
|
||||
**Avoid entirely:**
|
||||
- Any UX that treats the frame as a device to be configured rather than a gift to be curated
|
||||
- Multi-step confirmation flows for actions the user has clear intent on
|
||||
|
||||
## Design System Foundation
|
||||
|
||||
### Design System Choice
|
||||
|
||||
**Authenticated app:** Vue 3 SPA (TypeScript strict, Vite, Vue Router, Pinia) with SCSS modules scoped per SFC component and Konva.js + Vue-Konva for the sticker canvas editor.
|
||||
|
||||
**Public flows** (email approve/decline, provisioning setup page): Symfony + Twig, minimal SCSS, no Vue dependency.
|
||||
|
||||
### Rationale for Selection
|
||||
|
||||
- **Vue 3 over React** — Composition API is clean, bundle size is smaller, TypeScript integration is excellent, appropriate for this application's complexity level.
|
||||
- **SCSS modules over Tailwind** — semantic, authored CSS produces more maintainable component styles; complex canvas states (sticker selected, dragging, pinch-active) express more clearly as authored classes than utility strings. No utility class noise in templates.
|
||||
- **No DaisyUI** — components are written properly from scratch in Vue SFCs with scoped SCSS; a pre-built component library is unnecessary.
|
||||
- **Konva.js** over Fabric.js — lighter, superior mobile touch event handling, maintained Vue-Konva wrapper for reactive sticker state.
|
||||
- **TypeScript strict mode** — API response interfaces mirror Symfony entities; compiler surfaces contract drift before it reaches deployed devices. Non-negotiable given the no-breaking-changes firmware constraint.
|
||||
|
||||
### Implementation Approach
|
||||
|
||||
- Vue app scaffolded with Vite + TypeScript template
|
||||
- `src/types/` directory holds interfaces mirroring every Symfony API response shape: `Device`, `Image`, `StickerLayer`, `RenderedAsset`, `Token`
|
||||
- Symfony serves the Vue app's `index.html` shell for all authenticated routes; Vue Router handles client-side navigation
|
||||
- Public Symfony routes (`/setup/{mac}`, `/token/{uuid}/approve`) remain outside the Vue app entirely
|
||||
|
||||
### Customization Strategy
|
||||
|
||||
- Design tokens defined as SCSS variables: color palette, spacing scale, border radius, typography — warm/playful values set once, used everywhere
|
||||
- Component SCSS lives in each SFC `<style scoped lang="scss">`
|
||||
- Global styles (reset, typography, base layout) in `src/styles/global.scss`
|
||||
|
||||
## Visual Design Foundation
|
||||
|
||||
### Color System
|
||||
|
||||
Six user-selectable themes, all shipping in V1. The user picks their preferred theme in app settings; the selection persists per account. All themes share the same semantic token structure — only the values change.
|
||||
|
||||
| Theme | Primary | Accent | Character |
|
||||
|---|---|---|---|
|
||||
| 🪵 Warm Craft | #c4622a | #f5c842 | Handmade, amber, walnut |
|
||||
| 🎉 Playful Pop | #d94f6e | #ffb347 | Bold, energetic, fun |
|
||||
| 🌿 Sage & Cream | #3d7a5a | #e8965a | Calm, natural, approachable |
|
||||
| 🌸 Dusty Mauve | #8a4a7a | #d4a843 | Whimsical, warm, personal |
|
||||
| 🌊 Ocean Dusk | #2a6878 | #f0875a | Refined, calm, premium |
|
||||
| 🍯 Honey & Slate | #5a5068 | #e8a830 | Sophisticated, warm-neutral |
|
||||
|
||||
**Semantic tokens** (same names across all themes):
|
||||
`--color-primary`, `--color-primary-soft`, `--color-accent`, `--color-surface`, `--color-surface-raised`, `--color-text`, `--color-text-muted`, `--color-border`
|
||||
|
||||
All themes meet WCAG AA contrast on their respective backgrounds.
|
||||
|
||||
### Typography System
|
||||
|
||||
**Typeface:** Nunito (Google Fonts, variable weight)
|
||||
- Rounded terminals — warm and approachable without being childish
|
||||
- Excellent mobile legibility at small sizes
|
||||
- Variable weight supports the full scale without multiple font files
|
||||
|
||||
**Type scale:**
|
||||
|
||||
| Role | Size | Weight |
|
||||
|---|---|---|
|
||||
| Display | 28px | 800 |
|
||||
| Heading 1 | 22px | 700 |
|
||||
| Heading 2 | 18px | 700 |
|
||||
| Body | 15px | 400 |
|
||||
| Body strong | 15px | 600 |
|
||||
| Label | 13px | 600 |
|
||||
| Caption | 11px | 700 (tracked +0.08em) |
|
||||
|
||||
**Line heights:** 1.2 for headings, 1.6 for body, 1 for labels/captions.
|
||||
|
||||
### Spacing & Layout Foundation
|
||||
|
||||
**Base unit:** 8px
|
||||
**Scale:** 4 · 8 · 12 · 16 · 24 · 32 · 48 · 64
|
||||
|
||||
**Touch targets:** 44px minimum height (iOS HIG)
|
||||
**Card padding:** 16px mobile, 24px desktop
|
||||
**Screen padding:** 16px mobile, 32px desktop
|
||||
**Border radius:** 12px cards, 14px large cards, 100px pills/buttons
|
||||
|
||||
**Layout:** Single-column mobile (max 480px content width). Desktop widens to a centered 960px container — two-column where appropriate (library grid, device list).
|
||||
|
||||
### Accessibility Considerations
|
||||
|
||||
- All 6 themes validated at WCAG AA contrast (4.5:1 for body text)
|
||||
- Touch targets ≥ 44px on all interactive elements
|
||||
- Nunito at 15px body size exceeds readability minimums for older users (important for Margaret as a primary user)
|
||||
- Theme preference stored per account, not per device
|
||||
|
||||
## Design Direction Decision
|
||||
|
||||
### Design Directions Explored
|
||||
|
||||
Five layout directions were evaluated:
|
||||
1. **Photo Grid** — gallery-first, frame as secondary navigation
|
||||
2. **Frame-Centric** — frames as top-level, navigate into each to manage photos
|
||||
3. **Upload-First** — prominent upload CTA with recent activity below
|
||||
4. **Activity Feed** — chronological feed with inline approvals
|
||||
5. **Minimal Card** — frame card with current photo and Add action together
|
||||
|
||||
### Chosen Direction
|
||||
|
||||
**Direction 5 — Minimal Card**
|
||||
|
||||
Home screen shows each frame as a card: current photo preview, frame name, photo count, and a prominent "+ Add Photo" button — all in one view. One frame: large featured card with library strip below. Two or more frames: stacked cards, each self-contained with their own action.
|
||||
|
||||
### Design Rationale
|
||||
|
||||
Direction 5 is the most honest representation of the product's mental model: the frame is the hero, the photo is the content, the action is obvious. No secondary navigation required to accomplish the primary job.
|
||||
|
||||
It scales cleanly from a single-frame user (Matt gifting one frame) to a multi-frame household without a layout change — just more cards.
|
||||
|
||||
The library strip on the single-frame home gives power users a hint of their collection without making it the focus. The Library tab handles full browsing.
|
||||
|
||||
### Implementation Approach
|
||||
|
||||
- Home screen: `FrameList.vue` — renders `FrameCard.vue` per device
|
||||
- Single frame state: large card with aspect-ratio photo preview, status chip, next-cycle info, full-width "+ Add Photo" CTA, library strip below
|
||||
- Multi-frame state: compact stacked cards, each with thumbnail, name, count, and "+ Add" pill button
|
||||
- Empty state (no frames yet): single card-shaped prompt to provision a frame via QR
|
||||
- "+ Add Photo" always opens the upload → crop → sticker → add funnel
|
||||
|
||||
## User Journey Flows
|
||||
|
||||
### 1. Upload → Edit → Add to Frame
|
||||
|
||||
The primary flow. Triggered from "+ Add Photo" on any frame card.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A([Tap + Add Photo]) --> B[System photo picker opens]
|
||||
B --> C{Photo selected?}
|
||||
C -- No --> Z([Cancelled — back to Home])
|
||||
C -- Yes --> D[Crop screen\nDevice aspect ratio visible\nPinch/drag to fit]
|
||||
D --> E[Tap Next]
|
||||
E --> F[Sticker screen\nCanvas with sticker tray icon]
|
||||
F --> G{Add stickers?}
|
||||
G -- Yes --> H[Tap sticker tray\nBottom sheet opens]
|
||||
H --> I[Tap sticker → placed on canvas\nDrag/pinch/× to manage]
|
||||
I --> G
|
||||
G -- Done --> J[Tap Add to Frame]
|
||||
J --> K[Device picker bottom sheet\nFrames shown by name with thumbnail]
|
||||
K --> L{Select frames}
|
||||
L --> M[Tap Done]
|
||||
M --> N[Quiet confirmation animation]
|
||||
N --> O([Back to Home\nPhoto in rotation])
|
||||
```
|
||||
|
||||
**Key UX decisions:**
|
||||
- Crop screen shows the destination device's aspect ratio border — named ("Margaret's Frame") if coming from a specific frame card
|
||||
- Stickers step is always shown but skippable — one tap to proceed without adding any
|
||||
- Device picker pre-selects the frame the user came from (if any)
|
||||
- No success modal — quiet animation, return to home
|
||||
|
||||
### 2. Device Provisioning (Two-Phase QR)
|
||||
|
||||
Triggered by the empty state on Home ("Set up your first frame") or the "+ Add a frame" action.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A([Frame powered on\nOr button held 5s to reset]) --> B[E-ink: QR code for AP\n"Scan to connect"]
|
||||
B --> C[User scans QR\nPhone joins PictureFrame AP]
|
||||
C --> D[Captive portal opens\nWiFi SSID + password entry]
|
||||
D --> E{WiFi connects?}
|
||||
E -- No --> F[E-ink: full red screen\nAP reactivates\nProvisioning QR redisplays]
|
||||
F --> B
|
||||
E -- Yes --> G[E-ink: success QR\n"Scan to finish setup"]
|
||||
G --> H[User scans QR\nOpens /setup/mac in browser]
|
||||
H --> I{Has account?}
|
||||
I -- No --> J[Registration screen\nEmail + password]
|
||||
J --> K[Account created\nDevice linked]
|
||||
I -- Yes --> L[Login screen]
|
||||
L --> K
|
||||
K --> M[App: name your frame\nOrientation · frequency]
|
||||
M --> N([Frame reboots\nBegins image cycle])
|
||||
```
|
||||
|
||||
**Key UX decisions:**
|
||||
- Phase 1 (AP + captive portal) has zero server dependency — works with no internet
|
||||
- Every failure state is self-healing; user never needs to manually retry
|
||||
- Setup page (`/setup/mac`) is a Symfony Twig page — outside the Vue SPA
|
||||
- Frame naming happens immediately after linking — before the user leaves the setup flow
|
||||
|
||||
### 3. Share → Email Approve (Sarah → Margaret)
|
||||
|
||||
Triggered from image detail → Share action in the library.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A([Sarah: tap Share on image]) --> B[Share sheet\nRecipient search by name/email]
|
||||
B --> C[Sarah selects Margaret\nTaps Send]
|
||||
C --> D[Server sends email to Margaret\nImage preview + Approve button]
|
||||
D --> E{Margaret taps?}
|
||||
E -- Approve --> F[Device selection page\nNo login required\nShows Margaret's frames by name]
|
||||
F --> G[Margaret taps a frame\nTaps Done]
|
||||
G --> H[Image enters approved pool\nConfirmation shown]
|
||||
H --> I([Next cycle: photo appears on frame])
|
||||
E -- Decline --> J[Decline confirmed\nImage not added]
|
||||
E -- Ignores --> K[Token expires after TTL\nNo action taken]
|
||||
```
|
||||
|
||||
**Key UX decisions:**
|
||||
- Device selection page is a public Symfony Twig page — no login, no Vue app
|
||||
- Margaret sees frames by their human name, not technical identifiers
|
||||
- Approval is single-use — tapping Approve again after TTL shows a friendly expired message
|
||||
- Sarah sees no confirmation until Margaret approves — she shared it, not approved it
|
||||
|
||||
### 4. In-App Approve / Decline
|
||||
|
||||
For users who approve shares through the app rather than email.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A([Notification or Library badge\n"1 photo waiting"]) --> B[Library → Shared tab\nPhoto shown with Approve/Decline]
|
||||
B --> C{User action}
|
||||
C -- Approve --> D[Device picker bottom sheet\nSame as upload flow]
|
||||
D --> E[Select frame → Done]
|
||||
E --> F[Image enters rotation\nQuiet confirmation]
|
||||
C -- Decline --> G[Confirm: Decline this photo?]
|
||||
G -- Yes --> H[Photo removed from Shared tab]
|
||||
G -- No --> B
|
||||
```
|
||||
|
||||
**Key UX decisions:**
|
||||
- In-app approve produces identical outcome to email approve — same device picker, same pool entry
|
||||
- Decline requires one confirmation tap to prevent accidental dismissal
|
||||
- Approved/declined photos leave the Shared tab immediately
|
||||
|
||||
### Journey Patterns
|
||||
|
||||
**Entry via context:** When "+ Add Photo" is tapped from a specific frame card, that frame is pre-selected throughout the upload funnel. When tapped from a general context, the device picker shows all frames with none pre-selected.
|
||||
|
||||
**Bottom sheet for selection:** Device picking, sticker tray, and share recipient search all use the same bottom sheet pattern — slides up, dismisses on tap outside or swipe down.
|
||||
|
||||
**Quiet completion:** No flow ends with a success modal. Completion is signalled by a brief animation and return to the previous context.
|
||||
|
||||
**Self-healing errors:** Every error state in provisioning resets to a retry-ready state automatically. No dead ends.
|
||||
|
||||
### Flow Optimization Principles
|
||||
|
||||
1. **Pre-selection reduces decisions** — context determines defaults; the user confirms rather than chooses from scratch
|
||||
2. **No orphaned steps** — every flow has a clear completion state; nothing leaves the user stranded mid-flow
|
||||
3. **Public flows stay simple** — approve/decline email pages and provisioning setup are Symfony Twig, not Vue
|
||||
4. **Destructive actions confirm once** — decline, delete, and reset all require one confirmation tap; no double-confirmation
|
||||
|
||||
## Component Strategy
|
||||
|
||||
### Foundation Components
|
||||
|
||||
Built once, used everywhere. SCSS tokens drive all theming.
|
||||
|
||||
| Component | Purpose | Key states |
|
||||
|---|---|---|
|
||||
| `BaseButton` | Primary, secondary, pill, destructive variants | default, hover, active, disabled, loading |
|
||||
| `BaseInput` | Text fields, password, email | default, focused, error, disabled |
|
||||
| `BaseBottomSheet` | Slides up from bottom, dismisses on tap outside or swipe down | closed, open, loading |
|
||||
| `BaseCard` | Rounded surface with shadow, padding variants | default, pressable |
|
||||
| `BaseChip` | Inline label/tag with color variants | default, removable |
|
||||
| `BaseToast` | Quiet completion animation — appears briefly, no dismiss required | success, info |
|
||||
| `BottomNav` | 4-item fixed bottom navigation | item active/inactive states |
|
||||
|
||||
### Custom Components
|
||||
|
||||
**`FrameCard`**
|
||||
The hero component. Shows frame's current photo, name, status, next-cycle info, and "+ Add Photo" CTA. Two layouts: featured (single frame) and compact (multi-frame list).
|
||||
- States: `featured`, `compact`, `empty` (no photo yet), `offline` (red border), `sync-fail` (yellow border)
|
||||
- Actions: tap photo → frame detail; tap "+ Add Photo" → upload funnel with frame pre-selected
|
||||
- Accessibility: frame name as aria-label, status communicated via both color and text
|
||||
|
||||
**`CropEditor`**
|
||||
Frame-shaped crop UI. Shows device aspect ratio as a bordered overlay. User pinches/drags the photo behind it to fit.
|
||||
- Built on native CSS with touch event handlers (no canvas needed for crop)
|
||||
- Shows destination frame name in corner ("Margaret's Frame")
|
||||
- Orientation toggle (landscape/portrait) where device supports both
|
||||
- States: `idle`, `dragging`, `pinching`
|
||||
|
||||
**`StickerCanvas`**
|
||||
Konva.js stage wrapping the cropped photo with a sticker layer on top. Manages collection of placed stickers reactively via Pinia.
|
||||
- Sticker object: `{ id, type, x, y, scale, rotation }`
|
||||
- Touch: tap to place/select, drag to move, pinch to resize, tap × to delete
|
||||
- Re-editable: loads saved sticker state when editing an existing photo
|
||||
- States: `idle`, `sticker-selected` (shows × handle), `dragging`
|
||||
|
||||
**`StickerTray`**
|
||||
Bottom sheet containing categorised sticker library. Scrolls horizontally within categories. Tap a sticker to place it on the `StickerCanvas`.
|
||||
- Categories: Seasonal · Holidays · Fun · Family · Nature
|
||||
- Dismisses by tapping the canvas or swiping the sheet down
|
||||
- Pre-baked sticker set (SVG assets in `src/assets/stickers/`)
|
||||
|
||||
**`DevicePicker`**
|
||||
Bottom sheet listing user's frames by name with current photo thumbnail. Used in: upload funnel (Add to Frame step), in-app approve flow.
|
||||
- Single-select or multi-select mode
|
||||
- Pre-selects the frame from context (if launched from a FrameCard)
|
||||
- "All Frames" option in multi-select mode
|
||||
- Empty state: "You don't have any frames yet" with link to provisioning
|
||||
|
||||
**`PhotoThumb`**
|
||||
Square/rectangular thumbnail for library grids. Shows sticker indicator badge if photo has sticker composition saved.
|
||||
- Sizes: `sm` (64px), `md` (80px), `lg` (120px)
|
||||
- States: `default`, `selected` (checkbox overlay), `has-stickers` (badge)
|
||||
|
||||
**`ShareSheet`**
|
||||
Bottom sheet for sharing a photo to another user. Text input searches connected family members by name or email. Tap to select, tap Send.
|
||||
- States: `search`, `selected`, `sending`, `sent`
|
||||
|
||||
**`ApproveCard`**
|
||||
Used in Library → Shared tab. Shows incoming shared photo with Approve and Decline actions inline.
|
||||
- States: `pending`, `approved`, `declined`
|
||||
|
||||
### Implementation Priority
|
||||
|
||||
**Phase 1 — Core flows (ship nothing without these):**
|
||||
`FrameCard` · `CropEditor` · `StickerCanvas` · `StickerTray` · `DevicePicker` · `BaseBottomSheet` · `BaseButton` · `BottomNav`
|
||||
|
||||
**Phase 2 — Library and sharing:**
|
||||
`PhotoThumb` · `ShareSheet` · `ApproveCard` · `BaseChip` · `BaseCard`
|
||||
|
||||
**Phase 3 — Polish:**
|
||||
`BaseToast` · `BaseInput` · sticker indicator animations · FrameCard offline/sync-fail states
|
||||
|
||||
## Defining Experience
|
||||
|
||||
### The Core Interaction
|
||||
|
||||
pictureFrame: "Make a photo and put it on someone's frame."
|
||||
|
||||
Pick a photo. Crop it to the frame's shape. Add something fun. Send it there. That's the product. Everything else — sharing, approvals, device settings, admin — supports this moment without appearing in it.
|
||||
|
||||
### User Mental Model
|
||||
|
||||
Users arrive thinking of this like making a card or decorating a photo for someone — a creative act with a specific recipient in mind. The physical frame is always the destination. Unlike Instagram (destination: feed) or Google Photos (destination: archive), the mental model here is: *I am making something that will sit on a wall in someone's home.*
|
||||
|
||||
That framing changes everything. The crop UI isn't "adjust composition" — it's "fit this photo to the frame on Margaret's mantle." The sticker isn't decoration for a post — it's a santa hat you put on Dad because it'll make her laugh every time it cycles up.
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- The device frame shape is visible from the first moment of editing. The user always knows what they're making and for what.
|
||||
- Sticker placement responds immediately to touch — tap to place, drag to move, no perceptible lag on mobile.
|
||||
- "Add to frame" is the natural last step of every upload. No photo lands in a library without a destination unless the user explicitly skips it.
|
||||
- The path from home screen to editing a photo is one tap.
|
||||
- Completion feels like sending something, not saving a file.
|
||||
|
||||
### Pattern Analysis
|
||||
|
||||
**Established patterns used:**
|
||||
- Instagram crop-first funnel — show the frame before any other option
|
||||
- Snapchat sticker interaction model — tap, drag, pinch, × to delete
|
||||
- iMessage-style quiet confirmation — small acknowledgment, return to context
|
||||
|
||||
**Novel combination:**
|
||||
The crop UI shows the *physical device's aspect ratio*, not an abstract crop box. You are fitting a photo to a specific object. This framing is new — no mainstream app crops to a named physical recipient's device. The sticker tray appears within that same frame-shaped canvas. The entire editing experience is spatially oriented toward the destination.
|
||||
|
||||
No user education required — the interactions are familiar. The *context* (making something for a physical frame for a specific person) is what's new, and the UI makes that context obvious rather than explaining it.
|
||||
|
||||
### Experience Mechanics
|
||||
|
||||
**1. Initiation**
|
||||
Prominent "+" or photo icon on the home screen. One tap. Opens the device camera roll / file picker immediately — no intermediate screen.
|
||||
|
||||
**2. Crop & Fit**
|
||||
Photo loads into a frame-shaped crop UI (device aspect ratio: landscape 800×480 or portrait 480×800). User pinches/drags to fit. The frame border is styled to suggest the physical e-ink frame — the destination is visible from the first interaction.
|
||||
|
||||
**3. Stickers**
|
||||
A sticker tray icon is visible at the bottom of the canvas. Tap to open a bottom sheet with sticker categories. Tap a sticker to place it on the canvas. Drag to reposition, pinch to resize, tap to select, tap × to delete. Tap outside the tray to dismiss it. Multiple stickers supported. Canvas auto-saves sticker state.
|
||||
|
||||
**4. Add to Frame**
|
||||
"Add to Frame" CTA always visible. Tapping it slides up a device picker — shows all user's own frames by name with a small thumbnail of the current display. User taps a frame (or multiple). Taps Done.
|
||||
|
||||
**5. Completion**
|
||||
Small, warm confirmation animation. Returns to home screen. No modal to dismiss. The photo is now in rotation on that frame.
|
||||
|
||||
## UX Consistency Patterns
|
||||
|
||||
### Button Hierarchy
|
||||
|
||||
One primary action per screen. Secondary and tertiary actions are visually subordinate — never compete with the primary.
|
||||
|
||||
| Variant | Use | Visual |
|
||||
|---|---|---|
|
||||
| **Primary** | The one obvious action (Add to Frame, Approve, Send) | Full-width pill, `--color-primary` fill, Nunito 600 |
|
||||
| **Secondary** | Alternative action at same level (Skip, Cancel) | Pill outline, `--color-primary` stroke, transparent fill |
|
||||
| **Ghost** | Low-priority in-context action (Edit, View details) | No border, `--color-text-muted`, min 44px tap target |
|
||||
| **Destructive** | Irreversible actions (Decline, Delete, Remove from frame) | Pill, `--color-error` fill — never in the same visual group as primary |
|
||||
| **Icon pill** | Floating action in canvas contexts (sticker tray toggle) | Circle, `--color-surface-raised`, 48px diameter |
|
||||
|
||||
**Rules:**
|
||||
- Primary button is always full-width on mobile. Never two full-width buttons side by side.
|
||||
- Destructive actions are never the first option presented. They appear below or after the primary action.
|
||||
- Disabled state: 40% opacity, `cursor: not-allowed`, `aria-disabled="true"` — never hidden.
|
||||
|
||||
### Feedback Patterns
|
||||
|
||||
Quiet by default. Loud only for blocking errors.
|
||||
|
||||
| Type | Trigger | Treatment |
|
||||
|---|---|---|
|
||||
| **Success / completion** | Add to frame, approve, share sent | `BaseToast` — slides up from bottom, holds 2.5s, disappears; warm copy ("Photo on its way to Margaret's frame") |
|
||||
| **Info** | Informational prompt (e.g., "Token expires in 24h") | `BaseToast` with `--color-accent` indicator — same slide/dismiss behavior |
|
||||
| **Warning** | Non-blocking issue (frame offline, photo duplicate) | Inline warning chip on the relevant component — no toast, no modal |
|
||||
| **Error** | Blocking failure (upload failed, network error) | Inline below the triggering action — red text + retry affordance; no modal unless the entire screen is broken |
|
||||
| **Validation** | Form field constraint not met | Inline below the field — never above it, never in a banner |
|
||||
|
||||
**Rules:**
|
||||
- Toasts never require a tap to dismiss. They are not modals.
|
||||
- Error text never says "Error" as the first word. Describe what happened and what to do: "Couldn't save — tap to try again."
|
||||
- Never show multiple toasts at once. Queue them; show the most recent.
|
||||
- Confirmations for destructive actions use an inline confirmation pattern (confirm button appears in place of the action), not a modal dialog.
|
||||
|
||||
### Form Patterns
|
||||
|
||||
**Field states (BaseInput):**
|
||||
- `default` — `--color-border` outline, `--color-text-muted` placeholder
|
||||
- `focused` — `--color-primary` outline (2px), label floats up
|
||||
- `filled` — label floated, text visible
|
||||
- `error` — `--color-error` outline, error message below
|
||||
- `disabled` — 40% opacity, not interactable
|
||||
|
||||
**Rules:**
|
||||
- Labels are always visible (floating label pattern — not disappearing placeholders).
|
||||
- Validation fires on blur, not on keystroke — don't punish the user mid-typing.
|
||||
- Required fields are not marked with asterisks. If every field is required, say nothing. If a field is optional, mark it "(optional)".
|
||||
- Password fields always include a show/hide toggle.
|
||||
- Submit button is disabled until minimum required fields are filled.
|
||||
|
||||
### Navigation Patterns
|
||||
|
||||
`BottomNav` — 4 items, always visible in the authenticated app.
|
||||
|
||||
| Tab | Icon | Label |
|
||||
|---|---|---|
|
||||
| Home | House icon | Home |
|
||||
| Library | Grid icon | Library |
|
||||
| Shared | Heart icon (badge for pending) | Shared |
|
||||
| Settings | Gear icon | Settings |
|
||||
|
||||
**Rules:**
|
||||
- Active tab uses `--color-primary` fill, inactive tabs use `--color-text-muted`.
|
||||
- The Shared tab shows a numeric badge when pending approvals exist. Max display: "9+" for 10+.
|
||||
- `BottomNav` is hidden during full-screen editing flows (CropEditor, StickerCanvas) — the canvas owns the full screen.
|
||||
- Back navigation in multi-step flows (upload funnel) uses an in-flow back button (top-left chevron), not the bottom nav.
|
||||
- The upload funnel is a modal flow — launched over the current screen, closed on cancel or completion; bottom nav does not appear.
|
||||
|
||||
### Bottom Sheet Pattern
|
||||
|
||||
The primary overlay pattern. Used for: DevicePicker, StickerTray, ShareSheet, any in-context selection.
|
||||
|
||||
**Behavior rules:**
|
||||
- Slides up with a 250ms ease-out animation.
|
||||
- Handle pill (32px × 4px, `--color-border`) at top center — tap or swipe down to dismiss.
|
||||
- Tap outside (on the darkened overlay) also dismisses.
|
||||
- Scroll within the sheet when content exceeds ~60vh — the sheet does not expand beyond 85vh.
|
||||
- Loading state within an open sheet shows a skeleton, not a spinner — never collapses the sheet to show a spinner.
|
||||
- Bottom nav is hidden behind the overlay but still present — it reappears on sheet dismiss.
|
||||
|
||||
Full-page modals are not used for selection actions. Modals are reserved for destructive confirmations only — and even those use the inline confirmation pattern where possible.
|
||||
|
||||
### Empty States
|
||||
|
||||
Every screen with variable content has a defined empty state.
|
||||
|
||||
| Context | Empty state treatment |
|
||||
|---|---|
|
||||
| Home — no frames | Illustrated prompt card: frame silhouette + "Set up your first frame" + QR setup CTA |
|
||||
| Home — frame with no photos | FrameCard shows device photo area as a soft dashed rect with "+ Add a photo" overlaid |
|
||||
| Library — no uploads | Centered illustration + "No photos yet" + "Upload your first photo" button |
|
||||
| Library Shared — no pending | Centered quiet message: "Nothing waiting to approve" — no illustration needed |
|
||||
| Device picker — no frames | "You don't have any frames yet" + "Add a frame" link |
|
||||
| Search — no results | "No photos matching '[term]'" — no illustration, just clear text |
|
||||
|
||||
**Rules:**
|
||||
- Empty states always include one action. Never strand the user with only an explanation.
|
||||
- Empty states never use the word "empty" or "nothing found" as the headline. Be specific to context.
|
||||
|
||||
### Loading States
|
||||
|
||||
Loading is communicated inline; the app never shows a full-screen spinner.
|
||||
|
||||
| Context | Loading treatment |
|
||||
|---|---|
|
||||
| Page/route transition | Top-of-screen progress bar (`--color-primary`, 3px) — no content blocking |
|
||||
| Image grid loading | Skeleton cards — same size as `PhotoThumb`, shimmer animation |
|
||||
| FrameCard photo loading | Skeleton in the photo area — same aspect ratio as the device |
|
||||
| Bottom sheet content | Skeleton rows inside the open sheet |
|
||||
| Button action in progress | `BaseButton` loading state: spinner replaces label, button disabled, width does not change |
|
||||
| Upload progress | Progress ring in the upload sheet — percentage visible |
|
||||
|
||||
**Rules:**
|
||||
- Skeletons match the shape of the content they're loading. No generic spinner where a card will appear.
|
||||
- Button loading state never changes the button width — prevents layout shift.
|
||||
- Loading states have a 200ms delay before appearing — instantaneous actions should not flash a skeleton.
|
||||
- If loading exceeds 10 seconds, show a "Taking longer than expected — tap to cancel" inline option.
|
||||
|
||||
### Search and Filtering
|
||||
|
||||
Used in: Library (search by photo metadata/date), ShareSheet (recipient search).
|
||||
|
||||
**Library search:**
|
||||
- Search bar appears below the page title — not in the nav bar.
|
||||
- Real-time filter as the user types (300ms debounce).
|
||||
- Active search shows a "× Clear" inline in the search field.
|
||||
- Library tabs (All / Mine / Shared) remain visible below search — filtering is additive (tab + search combined).
|
||||
|
||||
**ShareSheet recipient search:**
|
||||
- Inline within the bottom sheet — field is auto-focused when sheet opens.
|
||||
- Searches by name and email simultaneously.
|
||||
- Results appear as tappable rows with avatar initial + name.
|
||||
- Selected recipient shows as a chip above the search field — tap chip to deselect.
|
||||
|
||||
**Rules:**
|
||||
- Search never navigates away from the current screen — it filters the visible content in place.
|
||||
- No "Search" button — results update live.
|
||||
- Search field placeholder is context-specific: "Search your photos" / "Find someone…"
|
||||
|
||||
## Responsive Design & Accessibility
|
||||
|
||||
### Responsive Strategy
|
||||
|
||||
**Mobile is the primary platform.** iOS Safari and Android Chrome are the targets; every layout decision starts from a phone. Desktop is a supported second screen — useful for Matt managing his fleet, but Margaret and Sarah will be on their phones.
|
||||
|
||||
**Desktop gets more room, not a different product.** The same flows and components scale to a wider container — no feature gating by device, no reorganized information architecture. Desktop widens to a 960px centered container with some two-column opportunities (library grid, settings + device list side-by-side).
|
||||
|
||||
**Tablet is phone layout at a larger scale.** No dedicated tablet layout — mobile layout centered with max-width 640px on the content. Tablets are uncommon enough in this use case to not warrant a dedicated design tier.
|
||||
|
||||
**Canvas contexts are mobile-only in V1.** The sticker editor (CropEditor + StickerCanvas) is not optimized for desktop — pinch-to-resize doesn't exist with a mouse. Desktop shows the crop/sticker UI in a centered modal, with drag-to-move functional and scroll-to-resize as a fallback.
|
||||
|
||||
### Breakpoint Strategy
|
||||
|
||||
Mobile-first SCSS. Media queries add layout complexity at wider sizes — they never remove it.
|
||||
|
||||
```scss
|
||||
// Defined once in src/styles/_breakpoints.scss
|
||||
$bp-tablet: 640px; // tablet and up
|
||||
$bp-desktop: 960px; // desktop and up
|
||||
|
||||
@mixin tablet { @media (min-width: #{$bp-tablet}) { @content; } }
|
||||
@mixin desktop { @media (min-width: #{$bp-desktop}) { @content; } }
|
||||
```
|
||||
|
||||
**What changes at each breakpoint:**
|
||||
|
||||
| Element | Mobile (< 640px) | Tablet (640–959px) | Desktop (960px+) |
|
||||
|---|---|---|---|
|
||||
| Content width | 100% (16px padding) | 100% (32px padding) | 960px centered |
|
||||
| Library grid | 2 columns | 3 columns | 4 columns |
|
||||
| FrameCard (multi) | Full-width stacked | Full-width stacked | Two-column grid |
|
||||
| Settings | Single column | Single column | Two-column (nav + content) |
|
||||
| Bottom nav | Fixed bottom | Fixed bottom | Hidden — top nav instead |
|
||||
| Upload funnel | Full screen | Full screen | Centered modal (480px wide) |
|
||||
|
||||
No JavaScript for breakpoints. All layout adaptation is pure CSS/SCSS. JS `matchMedia` is used only for canvas resize events in the sticker editor.
|
||||
|
||||
### Accessibility Strategy
|
||||
|
||||
**Target: WCAG 2.1 AA.** Margaret is the most accessibility-sensitive user — she may be older, less familiar with apps, potentially using system large text. The email approve flow is a public-facing page that must work for anyone who receives it, with any assistive technology.
|
||||
|
||||
**Specific requirements:**
|
||||
|
||||
| Requirement | Standard | Implementation |
|
||||
|---|---|---|
|
||||
| Color contrast (body text) | 4.5:1 minimum | All 6 themes validated at AA |
|
||||
| Color contrast (large text ≥18px) | 3:1 minimum | All theme headings verified |
|
||||
| Touch target size | 44×44px minimum | Enforced via BaseButton and all interactive elements |
|
||||
| Focus indicators | Visible, 3:1 contrast | `--color-primary` 2px ring on all interactive elements |
|
||||
| Text resize | 200% zoom without horizontal scroll | Single-column mobile layout handles this naturally |
|
||||
| Screen reader labels | All interactive elements labeled | `aria-label` on icon-only buttons; `aria-live` on toasts |
|
||||
| Color not sole indicator | Status communicated beyond color | FrameCard offline: red border + text label; share badge: color + number |
|
||||
| Skip navigation | Skip-to-content link | First element in DOM: `<a class="skip-link">Skip to main content</a>` |
|
||||
| Form labels | Always visible | Floating label pattern — never placeholder-only |
|
||||
| Error identification | Described in text | Error messages describe the issue, not just highlight the field red |
|
||||
|
||||
### Focus Management
|
||||
|
||||
- **Bottom sheet open:** Focus moves to the first interactive element inside the sheet.
|
||||
- **Bottom sheet close:** Focus returns to the trigger element that opened the sheet.
|
||||
- **Upload funnel (modal flow):** Focus trapped within the funnel steps. Escape key dismisses.
|
||||
- **Toast notifications:** Announced via `aria-live="polite"` — screen readers read them without interrupting current action.
|
||||
- **Route transitions:** On navigation, focus moves to the page `<h1>` — prevents focus loss between route changes.
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
**Responsive:**
|
||||
- Primary test devices: iPhone SE (smallest supported), iPhone 15 Pro Max (largest common), Android mid-range (Pixel 7a)
|
||||
- Browser targets: Safari iOS, Chrome Android, Chrome/Firefox desktop, Safari desktop
|
||||
- Viewport testing range: 375px – 1440px
|
||||
- Real device testing for touch interactions — simulator does not reliably replicate pinch/drag behavior
|
||||
|
||||
**Accessibility:**
|
||||
- **Automated:** axe-core via `@axe-core/vue` in development mode — surfaces AA violations in browser console during development
|
||||
- **Color contrast:** All theme token combinations validated before any theme ships
|
||||
- **Screen reader:** VoiceOver (iOS) for mobile flows; VoiceOver (macOS) + NVDA (Windows) for desktop and email flows
|
||||
- **Keyboard navigation:** Full keyboard walkthrough of all primary flows — tab order, focus rings, enter/space on custom controls
|
||||
- **Touch target audit:** Visual overlay tool to verify 44px minimum on every release
|
||||
|
||||
**Email flows (special case):**
|
||||
- Approve/decline email tested in: Gmail (mobile + web), Apple Mail (iOS + macOS), Outlook (web)
|
||||
- No Vue, no JavaScript required — Twig-rendered HTML must function with images disabled, CSS disabled, and screen reader only
|
||||
- Plain text email fallback required
|
||||
|
||||
### Implementation Guidelines
|
||||
|
||||
**SCSS responsive approach:**
|
||||
```scss
|
||||
// Mobile-first — base styles are mobile; mixin adds desktop behavior
|
||||
.library-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
|
||||
@include tablet { grid-template-columns: repeat(3, 1fr); }
|
||||
@include desktop { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
```
|
||||
|
||||
**Semantic HTML first:**
|
||||
- Page structure: `<main>`, `<nav>`, `<header>`, `<section>` — not `<div>` soup
|
||||
- Button elements for actions, `<a>` for navigation — no `<div>` click handlers
|
||||
- Lists (`<ul>/<li>`) for library grids and device pickers
|
||||
- `<figure>/<figcaption>` for photo thumbnails where a label is meaningful
|
||||
|
||||
**ARIA usage rules:**
|
||||
- Use native HTML semantics before reaching for ARIA
|
||||
- `aria-label` on icon-only buttons: `<button aria-label="Close sticker tray">`
|
||||
- `aria-live="polite"` on toast container
|
||||
- `aria-expanded` on BottomSheet trigger buttons
|
||||
- `aria-selected` on BottomNav active tab
|
||||
- `aria-invalid` + `aria-describedby` wiring on BaseInput error state
|
||||
|
||||
**Vue-specific accessibility:**
|
||||
- `v-focus` directive for programmatic focus management (on sheet open, route change)
|
||||
- Route change handler in `App.vue` — on every `$route` change, focus the `<h1>` of the new view
|
||||
- Canvas context (Konva stage): keyboard-accessible alternatives for all sticker actions — sticker selection available via visible control buttons, not drag-only
|
||||
@@ -0,0 +1,550 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>pictureFrame — Theme Explorer</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0ece8; padding: 24px 16px; }
|
||||
h1 { text-align: center; font-size: 22px; color: #2a2420; margin-bottom: 6px; font-weight: 700; }
|
||||
.subtitle { text-align: center; color: #7a6e68; font-size: 14px; margin-bottom: 32px; }
|
||||
|
||||
.theme-tabs { display: flex; gap: 8px; justify-content: center; margin-bottom: 28px; flex-wrap: wrap; }
|
||||
.tab { padding: 10px 20px; border-radius: 100px; border: 2px solid transparent; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.2s; }
|
||||
.tab.active { border-color: currentColor; }
|
||||
|
||||
.theme-section { display: none; }
|
||||
.theme-section.active { display: block; }
|
||||
|
||||
.palette-row { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
|
||||
.swatch { flex: 1; min-width: 80px; height: 72px; border-radius: 12px; display: flex; flex-direction: column; justify-content: flex-end; padding: 8px; font-size: 10px; font-weight: 600; }
|
||||
|
||||
.preview-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
|
||||
@media (max-width: 600px) { .preview-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.btn { display: inline-block; padding: 12px 24px; border-radius: 100px; font-size: 15px; font-weight: 700; border: none; cursor: pointer; margin-right: 8px; margin-bottom: 8px; }
|
||||
.btn-sm { padding: 8px 16px; font-size: 13px; }
|
||||
|
||||
.card { border-radius: 14px; padding: 16px; margin-bottom: 12px; }
|
||||
.card-title { font-size: 15px; font-weight: 700; margin-bottom: 4px; }
|
||||
.card-sub { font-size: 13px; opacity: 0.65; }
|
||||
|
||||
.tag { display: inline-block; padding: 4px 12px; border-radius: 100px; font-size: 12px; font-weight: 600; margin-right: 6px; }
|
||||
|
||||
.frame-preview { border-radius: 8px; aspect-ratio: 5/3; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 600; opacity: 0.7; border: 3px solid; }
|
||||
|
||||
.sticker-demo { font-size: 28px; background: white; border-radius: 50%; width: 52px; height: 52px; display: inline-flex; align-items: center; justify-content: center; margin: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
||||
|
||||
.section-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; opacity: 0.45; margin-bottom: 12px; }
|
||||
.nav-bar { display: flex; justify-content: space-around; padding: 12px 0 4px; border-radius: 0 0 16px 16px; }
|
||||
.nav-item { display: flex; flex-direction: column; align-items: center; gap: 3px; font-size: 10px; font-weight: 600; opacity: 0.5; cursor: pointer; }
|
||||
.nav-item.active { opacity: 1; }
|
||||
.nav-icon { font-size: 20px; }
|
||||
|
||||
/* ── THEME 1: Warm Craft ── */
|
||||
.t1-tab { background: #fff8f0; color: #c4622a; }
|
||||
.t1-tab.active { background: #c4622a; color: white; }
|
||||
.t1-text { color: #2a1e16; } .t1-subtext { color: #7a5e4e; }
|
||||
.t1-btn-primary { background: #c4622a; color: white; }
|
||||
.t1-btn-secondary { background: #f0e8df; color: #c4622a; }
|
||||
.t1-btn-accent { background: #f5c842; color: #2a1e16; }
|
||||
.t1-card { background: white; box-shadow: 0 2px 12px rgba(196,98,42,0.08); }
|
||||
.t1-tag-primary { background: #fde8da; color: #c4622a; }
|
||||
.t1-tag-accent { background: #fef8d8; color: #8a6a00; }
|
||||
.t1-frame-border { border-color: #c4622a; color: #c4622a; background: #fde8da; }
|
||||
|
||||
/* ── THEME 2: Playful Pop ── */
|
||||
.t2-tab { background: #fff0f0; color: #d94f6e; }
|
||||
.t2-tab.active { background: #d94f6e; color: white; }
|
||||
.t2-text { color: #26101a; } .t2-subtext { color: #7a4558; }
|
||||
.t2-btn-primary { background: #d94f6e; color: white; }
|
||||
.t2-btn-secondary { background: #fce8ed; color: #d94f6e; }
|
||||
.t2-btn-accent { background: #ffb347; color: #26101a; }
|
||||
.t2-card { background: white; box-shadow: 0 2px 12px rgba(217,79,110,0.08); }
|
||||
.t2-tag-primary { background: #fce8ed; color: #d94f6e; }
|
||||
.t2-tag-accent { background: #fff0d8; color: #a05a00; }
|
||||
.t2-frame-border { border-color: #d94f6e; color: #d94f6e; background: #fce8ed; }
|
||||
|
||||
/* ── THEME 3: Sage & Cream ── */
|
||||
.t3-tab { background: #f0f7f2; color: #3d7a5a; }
|
||||
.t3-tab.active { background: #3d7a5a; color: white; }
|
||||
.t3-text { color: #1a2e24; } .t3-subtext { color: #5a7868; }
|
||||
.t3-btn-primary { background: #3d7a5a; color: white; }
|
||||
.t3-btn-secondary { background: #eaf2ed; color: #3d7a5a; }
|
||||
.t3-btn-accent { background: #e8965a; color: white; }
|
||||
.t3-card { background: white; box-shadow: 0 2px 12px rgba(61,122,90,0.08); }
|
||||
.t3-tag-primary { background: #daeee4; color: #2a5a3e; }
|
||||
.t3-tag-accent { background: #fde8d4; color: #a0521a; }
|
||||
.t3-frame-border { border-color: #3d7a5a; color: #3d7a5a; background: #daeee4; }
|
||||
|
||||
/* ── THEME 4: Dusty Mauve ── */
|
||||
.t4-tab { background: #f7f0f7; color: #8a4a7a; }
|
||||
.t4-tab.active { background: #8a4a7a; color: white; }
|
||||
.t4-text { color: #221220; } .t4-subtext { color: #6e4866; }
|
||||
.t4-btn-primary { background: #8a4a7a; color: white; }
|
||||
.t4-btn-secondary { background: #f2e4f0; color: #8a4a7a; }
|
||||
.t4-btn-accent { background: #d4a843; color: #221220; }
|
||||
.t4-card { background: white; box-shadow: 0 2px 12px rgba(138,74,122,0.08); }
|
||||
.t4-tag-primary { background: #f2e4f0; color: #8a4a7a; }
|
||||
.t4-tag-accent { background: #fef4d8; color: #8a6000; }
|
||||
.t4-frame-border { border-color: #8a4a7a; color: #8a4a7a; background: #f2e4f0; }
|
||||
|
||||
/* ── THEME 5: Ocean Dusk ── */
|
||||
.t5-tab { background: #eef4f7; color: #2a6878; }
|
||||
.t5-tab.active { background: #2a6878; color: white; }
|
||||
.t5-text { color: #0e1f26; } .t5-subtext { color: #4a6e78; }
|
||||
.t5-btn-primary { background: #2a6878; color: white; }
|
||||
.t5-btn-secondary { background: #deedf2; color: #2a6878; }
|
||||
.t5-btn-accent { background: #f0875a; color: white; }
|
||||
.t5-card { background: white; box-shadow: 0 2px 12px rgba(42,104,120,0.08); }
|
||||
.t5-tag-primary { background: #deedf2; color: #1a5060; }
|
||||
.t5-tag-accent { background: #fde8da; color: #a04020; }
|
||||
.t5-frame-border { border-color: #2a6878; color: #2a6878; background: #deedf2; }
|
||||
|
||||
/* ── THEME 6: Honey & Slate ── */
|
||||
.t6-tab { background: #f7f4ee; color: #5a5068; }
|
||||
.t6-tab.active { background: #5a5068; color: white; }
|
||||
.t6-text { color: #1e1a28; } .t6-subtext { color: #6a6278; }
|
||||
.t6-btn-primary { background: #5a5068; color: white; }
|
||||
.t6-btn-secondary { background: #eeeaf6; color: #5a5068; }
|
||||
.t6-btn-accent { background: #e8a830; color: #1e1a28; }
|
||||
.t6-card { background: white; box-shadow: 0 2px 12px rgba(90,80,104,0.08); }
|
||||
.t6-tag-primary { background: #eeeaf6; color: #4a4058; }
|
||||
.t6-tag-accent { background: #fef3d4; color: #8a6000; }
|
||||
.t6-frame-border { border-color: #5a5068; color: #5a5068; background: #eeeaf6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>pictureFrame</h1>
|
||||
<p class="subtitle">Theme Explorer — click a theme to preview</p>
|
||||
|
||||
<div style="position:fixed;top:16px;right:16px;display:flex;align-items:center;gap:8px;background:white;padding:8px 14px;border-radius:100px;box-shadow:0 2px 12px rgba(0,0,0,0.12);z-index:100;">
|
||||
<label for="fav-select" style="font-size:12px;font-weight:600;color:#8a7a70;white-space:nowrap;">❤️ Fav:</label>
|
||||
<select id="fav-select" onchange="saveFavorite(this.value)" style="padding:2px 6px;border:none;font-size:12px;font-weight:600;color:#2a2420;background:transparent;cursor:pointer;outline:none;max-width:130px;">
|
||||
<option value="">— pick —</option>
|
||||
<option value="1">🪵 Warm Craft</option>
|
||||
<option value="2">🎉 Playful Pop</option>
|
||||
<option value="3">🌿 Sage & Cream</option>
|
||||
<option value="4">🌸 Dusty Mauve</option>
|
||||
<option value="5">🌊 Ocean Dusk</option>
|
||||
<option value="6">🍯 Honey & Slate</option>
|
||||
</select>
|
||||
<span id="fav-label" style="font-size:11px;color:#3a9a5a;display:none;font-weight:700;">✓</span>
|
||||
</div>
|
||||
|
||||
<div class="theme-tabs">
|
||||
<button class="tab t1-tab active" id="tab1" onclick="showTheme(1)">🪵 Warm Craft</button>
|
||||
<button class="tab t2-tab" id="tab2" onclick="showTheme(2)">🎉 Playful Pop</button>
|
||||
<button class="tab t3-tab" id="tab3" onclick="showTheme(3)">🌿 Sage & Cream</button>
|
||||
<button class="tab t4-tab" id="tab4" onclick="showTheme(4)">🌸 Dusty Mauve</button>
|
||||
<button class="tab t5-tab" id="tab5" onclick="showTheme(5)">🌊 Ocean Dusk</button>
|
||||
<button class="tab t6-tab" id="tab6" onclick="showTheme(6)">🍯 Honey & Slate</button>
|
||||
</div>
|
||||
|
||||
<!-- ════ THEME 1: Warm Craft ════ -->
|
||||
<div class="theme-section active" id="theme1">
|
||||
<div style="background:#fff8f0;border-radius:20px;padding:24px;">
|
||||
<p class="section-label t1-text">Color Palette</p>
|
||||
<div class="palette-row">
|
||||
<div class="swatch" style="background:#c4622a;color:white"><span>Primary</span><span>#c4622a</span></div>
|
||||
<div class="swatch" style="background:#e8956a;color:white"><span>Secondary</span><span>#e8956a</span></div>
|
||||
<div class="swatch" style="background:#f5c842;color:#2a1e16"><span>Accent</span><span>#f5c842</span></div>
|
||||
<div class="swatch" style="background:#f0e8df;color:#7a5e4e"><span>Surface</span><span>#f0e8df</span></div>
|
||||
<div class="swatch" style="background:#2a1e16;color:#f0e8df"><span>Text</span><span>#2a1e16</span></div>
|
||||
</div>
|
||||
<p class="section-label t1-text">Concept</p>
|
||||
<div class="t1-card card" style="margin-bottom:16px;"><p class="t1-text" style="font-size:14px;line-height:1.6;">Inspired by the walnut frame itself. Warm amber and terracotta feel handmade and intentional — like something that belongs on a shelf, not a screen. The golden accent echoes the e-ink's yellow. Feels like a gift, not an app.</p></div>
|
||||
<p class="section-label t1-text">Buttons</p>
|
||||
<div style="margin-bottom:20px;">
|
||||
<button class="btn t1-btn-primary">Add to Frame</button>
|
||||
<button class="btn t1-btn-secondary">Share Photo</button>
|
||||
<button class="btn t1-btn-accent">Approve</button>
|
||||
<button class="btn btn-sm t1-btn-secondary">Decline</button>
|
||||
</div>
|
||||
<div class="preview-grid">
|
||||
<div>
|
||||
<p class="section-label t1-text">Library Card</p>
|
||||
<div class="t1-card card">
|
||||
<div style="background:#f0e8df;border-radius:10px;height:100px;margin-bottom:12px;display:flex;align-items:center;justify-content:center;font-size:32px;">🏔️</div>
|
||||
<div class="card-title t1-text">Lake Trip 2024</div>
|
||||
<div class="card-sub t1-subtext">Added 3 days ago</div>
|
||||
<div style="margin-top:10px;"><span class="tag t1-tag-primary">Margaret's Frame</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="section-label t1-text">Device Card</p>
|
||||
<div class="t1-card card">
|
||||
<div class="frame-preview t1-frame-border">Margaret's Frame</div>
|
||||
<div class="card-title t1-text" style="margin-top:12px;">Margaret's Frame</div>
|
||||
<div class="card-sub t1-subtext">12 photos · Rotates daily</div>
|
||||
<div style="margin-top:10px;"><span class="tag t1-tag-accent">Online</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="section-label t1-text">Sticker Editor</p>
|
||||
<div class="t1-card card" style="text-align:center;padding:24px;">
|
||||
<div style="background:#f0e8df;border-radius:14px;padding:20px;margin-bottom:16px;position:relative;">
|
||||
<div style="font-size:48px;margin-bottom:8px;">🏔️</div>
|
||||
<div style="font-size:11px;opacity:0.5;">Photo canvas</div>
|
||||
<div class="sticker-demo" style="position:absolute;top:8px;right:8px;">🎅</div>
|
||||
</div>
|
||||
<div><span class="sticker-demo">🎅</span><span class="sticker-demo">⭐</span><span class="sticker-demo">🎉</span><span class="sticker-demo">❤️</span><span class="sticker-demo">🌟</span></div>
|
||||
</div>
|
||||
<p class="section-label t1-text" style="margin-top:20px;">Navigation</p>
|
||||
<div class="t1-card card" style="padding:0;">
|
||||
<div class="nav-bar t1-text">
|
||||
<div class="nav-item active"><span class="nav-icon">🖼️</span><span>Home</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📷</span><span>Upload</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📚</span><span>Library</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">⚙️</span><span>Frames</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════ THEME 2: Playful Pop ════ -->
|
||||
<div class="theme-section" id="theme2">
|
||||
<div style="background:#fef6f8;border-radius:20px;padding:24px;">
|
||||
<p class="section-label t2-text">Color Palette</p>
|
||||
<div class="palette-row">
|
||||
<div class="swatch" style="background:#d94f6e;color:white"><span>Primary</span><span>#d94f6e</span></div>
|
||||
<div class="swatch" style="background:#f47b93;color:white"><span>Secondary</span><span>#f47b93</span></div>
|
||||
<div class="swatch" style="background:#ffb347;color:#26101a"><span>Accent</span><span>#ffb347</span></div>
|
||||
<div class="swatch" style="background:#fce8ed;color:#7a4558"><span>Surface</span><span>#fce8ed</span></div>
|
||||
<div class="swatch" style="background:#26101a;color:#fef6f8"><span>Text</span><span>#26101a</span></div>
|
||||
</div>
|
||||
<p class="section-label t2-text">Concept</p>
|
||||
<div class="t2-card card" style="margin-bottom:16px;"><p class="t2-text" style="font-size:14px;line-height:1.6;">Bold, warm coral with a sunny amber accent. Feels energetic and fun — the kind of app you're happy to open. The sticker editor will feel at home here. Expressive and youthful while staying warm and personal rather than cold and techy.</p></div>
|
||||
<p class="section-label t2-text">Buttons</p>
|
||||
<div style="margin-bottom:20px;">
|
||||
<button class="btn t2-btn-primary">Add to Frame</button>
|
||||
<button class="btn t2-btn-secondary">Share Photo</button>
|
||||
<button class="btn t2-btn-accent">Approve</button>
|
||||
<button class="btn btn-sm t2-btn-secondary">Decline</button>
|
||||
</div>
|
||||
<div class="preview-grid">
|
||||
<div>
|
||||
<p class="section-label t2-text">Library Card</p>
|
||||
<div class="t2-card card">
|
||||
<div style="background:#fce8ed;border-radius:10px;height:100px;margin-bottom:12px;display:flex;align-items:center;justify-content:center;font-size:32px;">🏔️</div>
|
||||
<div class="card-title t2-text">Lake Trip 2024</div>
|
||||
<div class="card-sub t2-subtext">Added 3 days ago</div>
|
||||
<div style="margin-top:10px;"><span class="tag t2-tag-primary">Margaret's Frame</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="section-label t2-text">Device Card</p>
|
||||
<div class="t2-card card">
|
||||
<div class="frame-preview t2-frame-border">Margaret's Frame</div>
|
||||
<div class="card-title t2-text" style="margin-top:12px;">Margaret's Frame</div>
|
||||
<div class="card-sub t2-subtext">12 photos · Rotates daily</div>
|
||||
<div style="margin-top:10px;"><span class="tag t2-tag-accent">Online</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="section-label t2-text">Sticker Editor</p>
|
||||
<div class="t2-card card" style="text-align:center;padding:24px;">
|
||||
<div style="background:#fce8ed;border-radius:14px;padding:20px;margin-bottom:16px;position:relative;">
|
||||
<div style="font-size:48px;margin-bottom:8px;">🏔️</div>
|
||||
<div style="font-size:11px;opacity:0.5;">Photo canvas</div>
|
||||
<div class="sticker-demo" style="position:absolute;top:8px;right:8px;">🎅</div>
|
||||
</div>
|
||||
<div><span class="sticker-demo">🎅</span><span class="sticker-demo">⭐</span><span class="sticker-demo">🎉</span><span class="sticker-demo">❤️</span><span class="sticker-demo">🌟</span></div>
|
||||
</div>
|
||||
<p class="section-label t2-text" style="margin-top:20px;">Navigation</p>
|
||||
<div class="t2-card card" style="padding:0;">
|
||||
<div class="nav-bar t2-text">
|
||||
<div class="nav-item active"><span class="nav-icon">🖼️</span><span>Home</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📷</span><span>Upload</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📚</span><span>Library</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">⚙️</span><span>Frames</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════ THEME 3: Sage & Cream ════ -->
|
||||
<div class="theme-section" id="theme3">
|
||||
<div style="background:#f7f5f0;border-radius:20px;padding:24px;">
|
||||
<p class="section-label t3-text">Color Palette</p>
|
||||
<div class="palette-row">
|
||||
<div class="swatch" style="background:#3d7a5a;color:white"><span>Primary</span><span>#3d7a5a</span></div>
|
||||
<div class="swatch" style="background:#6aab87;color:white"><span>Secondary</span><span>#6aab87</span></div>
|
||||
<div class="swatch" style="background:#e8965a;color:white"><span>Accent</span><span>#e8965a</span></div>
|
||||
<div class="swatch" style="background:#eaf2ed;color:#5a7868"><span>Surface</span><span>#eaf2ed</span></div>
|
||||
<div class="swatch" style="background:#1a2e24;color:#f7f5f0"><span>Text</span><span>#1a2e24</span></div>
|
||||
</div>
|
||||
<p class="section-label t3-text">Concept</p>
|
||||
<div class="t3-card card" style="margin-bottom:16px;"><p class="t3-text" style="font-size:14px;line-height:1.6;">Sage green with a warm terracotta accent — calm and natural, like something grown rather than designed. Welcoming and trustworthy without being corporate. The most approachable for Margaret; the terracotta keeps it from feeling clinical.</p></div>
|
||||
<p class="section-label t3-text">Buttons</p>
|
||||
<div style="margin-bottom:20px;">
|
||||
<button class="btn t3-btn-primary">Add to Frame</button>
|
||||
<button class="btn t3-btn-secondary">Share Photo</button>
|
||||
<button class="btn t3-btn-accent">Approve</button>
|
||||
<button class="btn btn-sm t3-btn-secondary">Decline</button>
|
||||
</div>
|
||||
<div class="preview-grid">
|
||||
<div>
|
||||
<p class="section-label t3-text">Library Card</p>
|
||||
<div class="t3-card card">
|
||||
<div style="background:#eaf2ed;border-radius:10px;height:100px;margin-bottom:12px;display:flex;align-items:center;justify-content:center;font-size:32px;">🏔️</div>
|
||||
<div class="card-title t3-text">Lake Trip 2024</div>
|
||||
<div class="card-sub t3-subtext">Added 3 days ago</div>
|
||||
<div style="margin-top:10px;"><span class="tag t3-tag-primary">Margaret's Frame</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="section-label t3-text">Device Card</p>
|
||||
<div class="t3-card card">
|
||||
<div class="frame-preview t3-frame-border">Margaret's Frame</div>
|
||||
<div class="card-title t3-text" style="margin-top:12px;">Margaret's Frame</div>
|
||||
<div class="card-sub t3-subtext">12 photos · Rotates daily</div>
|
||||
<div style="margin-top:10px;"><span class="tag t3-tag-accent">Online</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="section-label t3-text">Sticker Editor</p>
|
||||
<div class="t3-card card" style="text-align:center;padding:24px;">
|
||||
<div style="background:#eaf2ed;border-radius:14px;padding:20px;margin-bottom:16px;position:relative;">
|
||||
<div style="font-size:48px;margin-bottom:8px;">🏔️</div>
|
||||
<div style="font-size:11px;opacity:0.5;">Photo canvas</div>
|
||||
<div class="sticker-demo" style="position:absolute;top:8px;right:8px;">🎅</div>
|
||||
</div>
|
||||
<div><span class="sticker-demo">🎅</span><span class="sticker-demo">⭐</span><span class="sticker-demo">🎉</span><span class="sticker-demo">❤️</span><span class="sticker-demo">🌟</span></div>
|
||||
</div>
|
||||
<p class="section-label t3-text" style="margin-top:20px;">Navigation</p>
|
||||
<div class="t3-card card" style="padding:0;">
|
||||
<div class="nav-bar t3-text">
|
||||
<div class="nav-item active"><span class="nav-icon">🖼️</span><span>Home</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📷</span><span>Upload</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📚</span><span>Library</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">⚙️</span><span>Frames</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════ THEME 4: Dusty Mauve ════ -->
|
||||
<div class="theme-section" id="theme4">
|
||||
<div style="background:#f9f2f8;border-radius:20px;padding:24px;">
|
||||
<p class="section-label t4-text">Color Palette</p>
|
||||
<div class="palette-row">
|
||||
<div class="swatch" style="background:#8a4a7a;color:white"><span>Primary</span><span>#8a4a7a</span></div>
|
||||
<div class="swatch" style="background:#b87aaa;color:white"><span>Secondary</span><span>#b87aaa</span></div>
|
||||
<div class="swatch" style="background:#d4a843;color:#221220"><span>Accent</span><span>#d4a843</span></div>
|
||||
<div class="swatch" style="background:#f2e4f0;color:#6e4866"><span>Surface</span><span>#f2e4f0</span></div>
|
||||
<div class="swatch" style="background:#221220;color:#f9f2f8"><span>Text</span><span>#221220</span></div>
|
||||
</div>
|
||||
<p class="section-label t4-text">Concept</p>
|
||||
<div class="t4-card card" style="margin-bottom:16px;"><p class="t4-text" style="font-size:14px;line-height:1.6;">Muted dusty rose and plum with a warm gold accent. Whimsical and personal without being loud — the kind of palette that feels handpicked rather than designed. Pairs beautifully with family photos and gives the sticker editor a slightly magical quality.</p></div>
|
||||
<p class="section-label t4-text">Buttons</p>
|
||||
<div style="margin-bottom:20px;">
|
||||
<button class="btn t4-btn-primary">Add to Frame</button>
|
||||
<button class="btn t4-btn-secondary">Share Photo</button>
|
||||
<button class="btn t4-btn-accent">Approve</button>
|
||||
<button class="btn btn-sm t4-btn-secondary">Decline</button>
|
||||
</div>
|
||||
<div class="preview-grid">
|
||||
<div>
|
||||
<p class="section-label t4-text">Library Card</p>
|
||||
<div class="t4-card card">
|
||||
<div style="background:#f2e4f0;border-radius:10px;height:100px;margin-bottom:12px;display:flex;align-items:center;justify-content:center;font-size:32px;">🏔️</div>
|
||||
<div class="card-title t4-text">Lake Trip 2024</div>
|
||||
<div class="card-sub t4-subtext">Added 3 days ago</div>
|
||||
<div style="margin-top:10px;"><span class="tag t4-tag-primary">Margaret's Frame</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="section-label t4-text">Device Card</p>
|
||||
<div class="t4-card card">
|
||||
<div class="frame-preview t4-frame-border">Margaret's Frame</div>
|
||||
<div class="card-title t4-text" style="margin-top:12px;">Margaret's Frame</div>
|
||||
<div class="card-sub t4-subtext">12 photos · Rotates daily</div>
|
||||
<div style="margin-top:10px;"><span class="tag t4-tag-accent">Online</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="section-label t4-text">Sticker Editor</p>
|
||||
<div class="t4-card card" style="text-align:center;padding:24px;">
|
||||
<div style="background:#f2e4f0;border-radius:14px;padding:20px;margin-bottom:16px;position:relative;">
|
||||
<div style="font-size:48px;margin-bottom:8px;">🏔️</div>
|
||||
<div style="font-size:11px;opacity:0.5;">Photo canvas</div>
|
||||
<div class="sticker-demo" style="position:absolute;top:8px;right:8px;">🎅</div>
|
||||
</div>
|
||||
<div><span class="sticker-demo">🎅</span><span class="sticker-demo">⭐</span><span class="sticker-demo">🎉</span><span class="sticker-demo">❤️</span><span class="sticker-demo">🌟</span></div>
|
||||
</div>
|
||||
<p class="section-label t4-text" style="margin-top:20px;">Navigation</p>
|
||||
<div class="t4-card card" style="padding:0;">
|
||||
<div class="nav-bar t4-text">
|
||||
<div class="nav-item active"><span class="nav-icon">🖼️</span><span>Home</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📷</span><span>Upload</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📚</span><span>Library</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">⚙️</span><span>Frames</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════ THEME 5: Ocean Dusk ════ -->
|
||||
<div class="theme-section" id="theme5">
|
||||
<div style="background:#eef5f8;border-radius:20px;padding:24px;">
|
||||
<p class="section-label t5-text">Color Palette</p>
|
||||
<div class="palette-row">
|
||||
<div class="swatch" style="background:#2a6878;color:white"><span>Primary</span><span>#2a6878</span></div>
|
||||
<div class="swatch" style="background:#5498aa;color:white"><span>Secondary</span><span>#5498aa</span></div>
|
||||
<div class="swatch" style="background:#f0875a;color:white"><span>Accent</span><span>#f0875a</span></div>
|
||||
<div class="swatch" style="background:#deedf2;color:#4a6e78"><span>Surface</span><span>#deedf2</span></div>
|
||||
<div class="swatch" style="background:#0e1f26;color:#eef5f8"><span>Text</span><span>#0e1f26</span></div>
|
||||
</div>
|
||||
<p class="section-label t5-text">Concept</p>
|
||||
<div class="t5-card card" style="margin-bottom:16px;"><p class="t5-text" style="font-size:14px;line-height:1.6;">Deep teal with a warm peach-coral accent — calm and considered, like late afternoon light on water. Feels premium and intentional. The warmth of the accent stops it from feeling cold. A more refined option that still carries personality and pairs beautifully with photography.</p></div>
|
||||
<p class="section-label t5-text">Buttons</p>
|
||||
<div style="margin-bottom:20px;">
|
||||
<button class="btn t5-btn-primary">Add to Frame</button>
|
||||
<button class="btn t5-btn-secondary">Share Photo</button>
|
||||
<button class="btn t5-btn-accent">Approve</button>
|
||||
<button class="btn btn-sm t5-btn-secondary">Decline</button>
|
||||
</div>
|
||||
<div class="preview-grid">
|
||||
<div>
|
||||
<p class="section-label t5-text">Library Card</p>
|
||||
<div class="t5-card card">
|
||||
<div style="background:#deedf2;border-radius:10px;height:100px;margin-bottom:12px;display:flex;align-items:center;justify-content:center;font-size:32px;">🏔️</div>
|
||||
<div class="card-title t5-text">Lake Trip 2024</div>
|
||||
<div class="card-sub t5-subtext">Added 3 days ago</div>
|
||||
<div style="margin-top:10px;"><span class="tag t5-tag-primary">Margaret's Frame</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="section-label t5-text">Device Card</p>
|
||||
<div class="t5-card card">
|
||||
<div class="frame-preview t5-frame-border">Margaret's Frame</div>
|
||||
<div class="card-title t5-text" style="margin-top:12px;">Margaret's Frame</div>
|
||||
<div class="card-sub t5-subtext">12 photos · Rotates daily</div>
|
||||
<div style="margin-top:10px;"><span class="tag t5-tag-accent">Online</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="section-label t5-text">Sticker Editor</p>
|
||||
<div class="t5-card card" style="text-align:center;padding:24px;">
|
||||
<div style="background:#deedf2;border-radius:14px;padding:20px;margin-bottom:16px;position:relative;">
|
||||
<div style="font-size:48px;margin-bottom:8px;">🏔️</div>
|
||||
<div style="font-size:11px;opacity:0.5;">Photo canvas</div>
|
||||
<div class="sticker-demo" style="position:absolute;top:8px;right:8px;">🎅</div>
|
||||
</div>
|
||||
<div><span class="sticker-demo">🎅</span><span class="sticker-demo">⭐</span><span class="sticker-demo">🎉</span><span class="sticker-demo">❤️</span><span class="sticker-demo">🌟</span></div>
|
||||
</div>
|
||||
<p class="section-label t5-text" style="margin-top:20px;">Navigation</p>
|
||||
<div class="t5-card card" style="padding:0;">
|
||||
<div class="nav-bar t5-text">
|
||||
<div class="nav-item active"><span class="nav-icon">🖼️</span><span>Home</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📷</span><span>Upload</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📚</span><span>Library</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">⚙️</span><span>Frames</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════ THEME 6: Honey & Slate ════ -->
|
||||
<div class="theme-section" id="theme6">
|
||||
<div style="background:#f5f3f8;border-radius:20px;padding:24px;">
|
||||
<p class="section-label t6-text">Color Palette</p>
|
||||
<div class="palette-row">
|
||||
<div class="swatch" style="background:#5a5068;color:white"><span>Primary</span><span>#5a5068</span></div>
|
||||
<div class="swatch" style="background:#8a80a0;color:white"><span>Secondary</span><span>#8a80a0</span></div>
|
||||
<div class="swatch" style="background:#e8a830;color:#1e1a28"><span>Accent</span><span>#e8a830</span></div>
|
||||
<div class="swatch" style="background:#eeeaf6;color:#6a6278"><span>Surface</span><span>#eeeaf6</span></div>
|
||||
<div class="swatch" style="background:#1e1a28;color:#f5f3f8"><span>Text</span><span>#1e1a28</span></div>
|
||||
</div>
|
||||
<p class="section-label t6-text">Concept</p>
|
||||
<div class="t6-card card" style="margin-bottom:16px;"><p class="t6-text" style="font-size:14px;line-height:1.6;">Cool lavender-slate with a rich honey gold accent. The most sophisticated of the set — the slate reads as calm and considered while the honey accent brings warmth and personality. Photos look stunning against this palette. Feels like a frame that belongs in a thoughtfully decorated home.</p></div>
|
||||
<p class="section-label t6-text">Buttons</p>
|
||||
<div style="margin-bottom:20px;">
|
||||
<button class="btn t6-btn-primary">Add to Frame</button>
|
||||
<button class="btn t6-btn-secondary">Share Photo</button>
|
||||
<button class="btn t6-btn-accent">Approve</button>
|
||||
<button class="btn btn-sm t6-btn-secondary">Decline</button>
|
||||
</div>
|
||||
<div class="preview-grid">
|
||||
<div>
|
||||
<p class="section-label t6-text">Library Card</p>
|
||||
<div class="t6-card card">
|
||||
<div style="background:#eeeaf6;border-radius:10px;height:100px;margin-bottom:12px;display:flex;align-items:center;justify-content:center;font-size:32px;">🏔️</div>
|
||||
<div class="card-title t6-text">Lake Trip 2024</div>
|
||||
<div class="card-sub t6-subtext">Added 3 days ago</div>
|
||||
<div style="margin-top:10px;"><span class="tag t6-tag-primary">Margaret's Frame</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="section-label t6-text">Device Card</p>
|
||||
<div class="t6-card card">
|
||||
<div class="frame-preview t6-frame-border">Margaret's Frame</div>
|
||||
<div class="card-title t6-text" style="margin-top:12px;">Margaret's Frame</div>
|
||||
<div class="card-sub t6-subtext">12 photos · Rotates daily</div>
|
||||
<div style="margin-top:10px;"><span class="tag t6-tag-accent">Online</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="section-label t6-text">Sticker Editor</p>
|
||||
<div class="t6-card card" style="text-align:center;padding:24px;">
|
||||
<div style="background:#eeeaf6;border-radius:14px;padding:20px;margin-bottom:16px;position:relative;">
|
||||
<div style="font-size:48px;margin-bottom:8px;">🏔️</div>
|
||||
<div style="font-size:11px;opacity:0.5;">Photo canvas</div>
|
||||
<div class="sticker-demo" style="position:absolute;top:8px;right:8px;">🎅</div>
|
||||
</div>
|
||||
<div><span class="sticker-demo">🎅</span><span class="sticker-demo">⭐</span><span class="sticker-demo">🎉</span><span class="sticker-demo">❤️</span><span class="sticker-demo">🌟</span></div>
|
||||
</div>
|
||||
<p class="section-label t6-text" style="margin-top:20px;">Navigation</p>
|
||||
<div class="t6-card card" style="padding:0;">
|
||||
<div class="nav-bar t6-text">
|
||||
<div class="nav-item active"><span class="nav-icon">🖼️</span><span>Home</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📷</span><span>Upload</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">📚</span><span>Library</span></div>
|
||||
<div class="nav-item"><span class="nav-icon">⚙️</span><span>Frames</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const names = ['', '🪵 Warm Craft', '🎉 Playful Pop', '🌿 Sage & Cream', '🌸 Dusty Mauve', '🌊 Ocean Dusk', '🍯 Honey & Slate'];
|
||||
|
||||
function showTheme(n) {
|
||||
document.querySelectorAll('.theme-section').forEach(s => s.classList.remove('active'));
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.getElementById('theme' + n).classList.add('active');
|
||||
document.getElementById('tab' + n).classList.add('active');
|
||||
}
|
||||
|
||||
function saveFavorite(val) {
|
||||
if (!val) return;
|
||||
localStorage.setItem('pf-fav-theme', val);
|
||||
showTheme(parseInt(val));
|
||||
const label = document.getElementById('fav-label');
|
||||
label.style.display = 'inline';
|
||||
updateFavStars();
|
||||
setTimeout(() => { label.style.display = 'none'; }, 2000);
|
||||
}
|
||||
|
||||
function updateFavStars() {
|
||||
const fav = localStorage.getItem('pf-fav-theme');
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const tab = document.getElementById('tab' + i);
|
||||
const base = names[i];
|
||||
tab.textContent = (fav == i) ? base + ' ❤️' : base;
|
||||
}
|
||||
}
|
||||
|
||||
window.onload = function() {
|
||||
const fav = localStorage.getItem('pf-fav-theme');
|
||||
if (fav) {
|
||||
document.getElementById('fav-select').value = fav;
|
||||
updateFavStars();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
|
||||
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||
throw new LogicException('Dependencies are missing. Try running "composer install".');
|
||||
}
|
||||
|
||||
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
|
||||
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||
|
||||
return function (array $context) {
|
||||
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
|
||||
return new Application($kernel);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
services:
|
||||
###> doctrine/doctrine-bundle ###
|
||||
database:
|
||||
ports:
|
||||
- "5432"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
###> symfony/mailer ###
|
||||
mailer:
|
||||
image: axllent/mailpit
|
||||
ports:
|
||||
- "1025"
|
||||
- "8025"
|
||||
environment:
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||
###< symfony/mailer ###
|
||||
@@ -0,0 +1,25 @@
|
||||
|
||||
services:
|
||||
###> doctrine/doctrine-bundle ###
|
||||
database:
|
||||
image: postgres:${POSTGRES_VERSION:-16}-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-app}
|
||||
# You should definitely change the password in production
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-app}
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"]
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
volumes:
|
||||
- database_data:/var/lib/postgresql/data:rw
|
||||
# You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data!
|
||||
# - ./docker/db/data:/var/lib/postgresql/data:rw
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
volumes:
|
||||
###> doctrine/doctrine-bundle ###
|
||||
database_data:
|
||||
###< doctrine/doctrine-bundle ###
|
||||
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"type": "project",
|
||||
"license": "proprietary",
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"doctrine/doctrine-bundle": "^3.2",
|
||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||
"doctrine/orm": "^3.6",
|
||||
"phpdocumentor/reflection-docblock": "^6.0",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
"symfony/asset": "7.4.*",
|
||||
"symfony/console": "7.4.*",
|
||||
"symfony/doctrine-messenger": "7.4.*",
|
||||
"symfony/dotenv": "7.4.*",
|
||||
"symfony/expression-language": "7.4.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/form": "7.4.*",
|
||||
"symfony/framework-bundle": "7.4.*",
|
||||
"symfony/http-client": "7.4.*",
|
||||
"symfony/intl": "7.4.*",
|
||||
"symfony/mailer": "7.4.*",
|
||||
"symfony/messenger": "7.4.*",
|
||||
"symfony/mime": "7.4.*",
|
||||
"symfony/monolog-bundle": "^3.0|^4.0",
|
||||
"symfony/notifier": "7.4.*",
|
||||
"symfony/process": "7.4.*",
|
||||
"symfony/property-access": "7.4.*",
|
||||
"symfony/property-info": "7.4.*",
|
||||
"symfony/runtime": "7.4.*",
|
||||
"symfony/scheduler": "7.4.*",
|
||||
"symfony/security-bundle": "7.4.*",
|
||||
"symfony/serializer": "7.4.*",
|
||||
"symfony/string": "7.4.*",
|
||||
"symfony/translation": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
"symfony/validator": "7.4.*",
|
||||
"symfony/web-link": "7.4.*",
|
||||
"symfony/yaml": "7.4.*",
|
||||
"twig/extra-bundle": "^2.12|^3.0",
|
||||
"twig/twig": "^2.12|^3.0"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"php-http/discovery": true,
|
||||
"symfony/flex": true,
|
||||
"symfony/runtime": true
|
||||
},
|
||||
"bump-after-update": true,
|
||||
"sort-packages": true
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"App\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"replace": {
|
||||
"symfony/polyfill-ctype": "*",
|
||||
"symfony/polyfill-iconv": "*",
|
||||
"symfony/polyfill-php72": "*",
|
||||
"symfony/polyfill-php73": "*",
|
||||
"symfony/polyfill-php74": "*",
|
||||
"symfony/polyfill-php80": "*",
|
||||
"symfony/polyfill-php81": "*",
|
||||
"symfony/polyfill-php82": "*"
|
||||
},
|
||||
"scripts": {
|
||||
"auto-scripts": {
|
||||
"cache:clear": "symfony-cmd",
|
||||
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
||||
},
|
||||
"post-install-cmd": [
|
||||
"@auto-scripts"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@auto-scripts"
|
||||
]
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/symfony": "*"
|
||||
},
|
||||
"extra": {
|
||||
"symfony": {
|
||||
"allow-contrib": false,
|
||||
"require": "7.4.*"
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^13.1",
|
||||
"symfony/browser-kit": "7.4.*",
|
||||
"symfony/css-selector": "7.4.*",
|
||||
"symfony/debug-bundle": "7.4.*",
|
||||
"symfony/maker-bundle": "^1.0",
|
||||
"symfony/stopwatch": "7.4.*",
|
||||
"symfony/web-profiler-bundle": "7.4.*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
||||
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
|
||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
||||
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
|
||||
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
|
||||
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
|
||||
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
framework:
|
||||
cache:
|
||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||
#prefix_seed: your_vendor_name/app_name
|
||||
|
||||
# The "app" cache stores to the filesystem by default.
|
||||
# The data in this cache should persist between deploys.
|
||||
# Other options include:
|
||||
|
||||
# Redis
|
||||
#app: cache.adapter.redis
|
||||
#default_redis_provider: redis://localhost
|
||||
|
||||
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
|
||||
#app: cache.adapter.apcu
|
||||
|
||||
# Namespaced pools use the above "app" backend by default
|
||||
#pools:
|
||||
#my.dedicated.cache: null
|
||||
@@ -0,0 +1,3 @@
|
||||
framework:
|
||||
form:
|
||||
csrf_protection: true
|
||||
@@ -0,0 +1,4 @@
|
||||
dama_doctrine_test:
|
||||
enable_static_connection: true
|
||||
enable_static_meta_data_cache: true
|
||||
enable_static_query_cache: true
|
||||
@@ -0,0 +1,5 @@
|
||||
when@dev:
|
||||
debug:
|
||||
# Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser.
|
||||
# See the "server:dump" command to start a new server.
|
||||
dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%"
|
||||
@@ -0,0 +1,46 @@
|
||||
doctrine:
|
||||
dbal:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
|
||||
# IMPORTANT: You MUST configure your server version,
|
||||
# either here or in the DATABASE_URL env var (see .env file)
|
||||
#server_version: '16'
|
||||
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
orm:
|
||||
validate_xml_mapping: true
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore
|
||||
identity_generation_preferences:
|
||||
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
|
||||
auto_mapping: true
|
||||
mappings:
|
||||
App:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
alias: App
|
||||
|
||||
when@test:
|
||||
doctrine:
|
||||
dbal:
|
||||
# "TEST_TOKEN" is typically set by ParaTest
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
|
||||
when@prod:
|
||||
doctrine:
|
||||
orm:
|
||||
query_cache_driver:
|
||||
type: pool
|
||||
pool: doctrine.system_cache_pool
|
||||
result_cache_driver:
|
||||
type: pool
|
||||
pool: doctrine.result_cache_pool
|
||||
|
||||
framework:
|
||||
cache:
|
||||
pools:
|
||||
doctrine.result_cache_pool:
|
||||
adapter: cache.app
|
||||
doctrine.system_cache_pool:
|
||||
adapter: cache.system
|
||||
@@ -0,0 +1,6 @@
|
||||
doctrine_migrations:
|
||||
migrations_paths:
|
||||
# namespace is arbitrary but should be different from App\Migrations
|
||||
# as migrations classes should NOT be autoloaded
|
||||
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||
enable_profiler: false
|
||||
@@ -0,0 +1,25 @@
|
||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
|
||||
# Note that the session will be started ONLY if you read or write from it.
|
||||
session: true
|
||||
|
||||
# Trust the Traefik reverse proxy that terminates TLS before Nginx.
|
||||
# REMOTE_ADDR = trust whatever IP is connecting to PHP-FPM (always Nginx in Docker).
|
||||
trusted_proxies: 'REMOTE_ADDR'
|
||||
trusted_headers:
|
||||
- 'x-forwarded-for'
|
||||
- 'x-forwarded-host'
|
||||
- 'x-forwarded-proto'
|
||||
- 'x-forwarded-port'
|
||||
- 'x-forwarded-prefix'
|
||||
|
||||
#esi: true
|
||||
#fragments: true
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
test: true
|
||||
session:
|
||||
storage_factory_id: session.storage.factory.mock_file
|
||||
@@ -0,0 +1,5 @@
|
||||
framework:
|
||||
mailer:
|
||||
dsn: '%env(MAILER_DSN)%'
|
||||
envelope:
|
||||
sender: '%env(MAILER_SENDER)%'
|
||||
@@ -0,0 +1,31 @@
|
||||
framework:
|
||||
messenger:
|
||||
failure_transport: failed
|
||||
|
||||
transports:
|
||||
# https://symfony.com/doc/current/messenger.html#transport-configuration
|
||||
image_processing:
|
||||
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
retry_strategy:
|
||||
max_retries: 1
|
||||
multiplier: 2
|
||||
async:
|
||||
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
retry_strategy:
|
||||
max_retries: 3
|
||||
multiplier: 2
|
||||
failed: 'doctrine://default?queue_name=failed'
|
||||
|
||||
default_bus: messenger.bus.default
|
||||
|
||||
buses:
|
||||
messenger.bus.default: []
|
||||
|
||||
routing:
|
||||
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
|
||||
Symfony\Component\Notifier\Message\ChatMessage: async
|
||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||
|
||||
App\Message\RenderImageMessage: image_processing
|
||||
App\Message\AdvanceRotationMessage: async
|
||||
App\Message\RunImageCleanupMessage: async
|
||||
@@ -0,0 +1,55 @@
|
||||
monolog:
|
||||
channels:
|
||||
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
|
||||
|
||||
when@dev:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
channels: ["!event"]
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine", "!console"]
|
||||
|
||||
when@test:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
handler: nested
|
||||
excluded_http_codes: [404, 405]
|
||||
channels: ["!event"]
|
||||
nested:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
|
||||
when@prod:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
handler: nested
|
||||
excluded_http_codes: [404, 405]
|
||||
channels: ["!deprecation"]
|
||||
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
||||
nested:
|
||||
type: stream
|
||||
path: php://stderr
|
||||
level: debug
|
||||
formatter: monolog.formatter.json
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine"]
|
||||
deprecation:
|
||||
type: stream
|
||||
channels: [deprecation]
|
||||
path: php://stderr
|
||||
formatter: monolog.formatter.json
|
||||
@@ -0,0 +1,12 @@
|
||||
framework:
|
||||
notifier:
|
||||
chatter_transports:
|
||||
texter_transports:
|
||||
channel_policy:
|
||||
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
|
||||
urgent: ['email']
|
||||
high: ['email']
|
||||
medium: ['email']
|
||||
low: ['email']
|
||||
admin_recipients:
|
||||
- { email: admin@example.com }
|
||||
@@ -0,0 +1,3 @@
|
||||
framework:
|
||||
property_info:
|
||||
with_constructor_extractor: true
|
||||
@@ -0,0 +1,10 @@
|
||||
framework:
|
||||
router:
|
||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||
default_uri: '%env(DEFAULT_URI)%'
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
router:
|
||||
strict_requirements: null
|
||||
@@ -0,0 +1,49 @@
|
||||
security:
|
||||
password_hashers:
|
||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||
|
||||
providers:
|
||||
app_user_provider:
|
||||
entity:
|
||||
class: App\Entity\User
|
||||
property: email
|
||||
|
||||
firewalls:
|
||||
dev:
|
||||
pattern: ^/(_profiler|_wdt|build)/
|
||||
security: false
|
||||
main:
|
||||
lazy: true
|
||||
provider: app_user_provider
|
||||
form_login:
|
||||
login_path: /login
|
||||
check_path: /login
|
||||
default_target_path: /
|
||||
enable_csrf: true
|
||||
logout:
|
||||
path: /logout
|
||||
target: /login
|
||||
remember_me:
|
||||
secret: '%kernel.secret%'
|
||||
lifetime: 2592000 # 30 days
|
||||
always_remember_me: true
|
||||
|
||||
role_hierarchy:
|
||||
ROLE_SUPER_ADMIN: [ROLE_USER, ROLE_ADMIN]
|
||||
|
||||
access_control:
|
||||
- { path: ^/login, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/register, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/setup, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/token, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/device, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/, roles: ROLE_USER }
|
||||
|
||||
when@test:
|
||||
security:
|
||||
password_hashers:
|
||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
|
||||
algorithm: auto
|
||||
cost: 4
|
||||
time_cost: 3
|
||||
memory_cost: 10
|
||||
@@ -0,0 +1,4 @@
|
||||
framework:
|
||||
test: true
|
||||
form:
|
||||
csrf_protection: false
|
||||
@@ -0,0 +1,5 @@
|
||||
framework:
|
||||
default_locale: en
|
||||
translator:
|
||||
default_path: '%kernel.project_dir%/translations'
|
||||
providers:
|
||||
@@ -0,0 +1,6 @@
|
||||
twig:
|
||||
file_name_pattern: '*.twig'
|
||||
|
||||
when@test:
|
||||
twig:
|
||||
strict_variables: true
|
||||
@@ -0,0 +1,11 @@
|
||||
framework:
|
||||
validation:
|
||||
# Enables validator auto-mapping support.
|
||||
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
|
||||
#auto_mapping:
|
||||
# App\Entity\: []
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
validation:
|
||||
not_compromised_password: false
|
||||
@@ -0,0 +1,13 @@
|
||||
when@dev:
|
||||
web_profiler:
|
||||
toolbar: true
|
||||
|
||||
framework:
|
||||
profiler:
|
||||
collect_serializer_data: true
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
profiler:
|
||||
collect: false
|
||||
collect_serializer_data: true
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
|
||||
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
|
||||
|
||||
# This file is the entry point to configure the routes of your app.
|
||||
# Methods with the #[Route] attribute are automatically imported.
|
||||
# See also https://symfony.com/doc/current/routing.html
|
||||
|
||||
# To list all registered routes, run the following command:
|
||||
# bin/console debug:router
|
||||
|
||||
controllers:
|
||||
resource: routing.controllers
|
||||
@@ -0,0 +1,4 @@
|
||||
when@dev:
|
||||
_errors:
|
||||
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
|
||||
prefix: /_error
|
||||
@@ -0,0 +1,3 @@
|
||||
_security_logout:
|
||||
resource: security.route_loader.logout
|
||||
type: service
|
||||
@@ -0,0 +1,8 @@
|
||||
when@dev:
|
||||
web_profiler_wdt:
|
||||
resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'
|
||||
prefix: /_wdt
|
||||
|
||||
web_profiler_profiler:
|
||||
resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'
|
||||
prefix: /_profiler
|
||||
@@ -0,0 +1,23 @@
|
||||
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
|
||||
|
||||
# This file is the entry point to configure your own services.
|
||||
# Files in the packages/ subdirectory configure your dependencies.
|
||||
# See also https://symfony.com/doc/current/service_container/import.html
|
||||
|
||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
_defaults:
|
||||
autowire: true # Automatically injects dependencies in your services.
|
||||
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
|
||||
|
||||
# makes classes in src/ available to be used as services
|
||||
# this creates a service per class whose id is the fully-qualified class name
|
||||
App\:
|
||||
resource: '../src/'
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
@@ -1,119 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download a photo, letterbox to 800×480, Floyd-Steinberg dither to the
|
||||
Waveshare 7.3" 6-colour palette, and write src/image.h as a PROGMEM array."""
|
||||
|
||||
import io
|
||||
import math
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
W, H = 480, 800 # portrait canvas; firmware rotates 90° CW onto landscape display
|
||||
OUT = Path(__file__).parent / "src" / "image.h"
|
||||
|
||||
URL = (
|
||||
"https://d3eguztg5751m.cloudfront.net/as/assets-mem-com/cmi/3/0/0/7/11557003"
|
||||
"/20231128_115457370_0_orig.jpg/-/kenneth-edholm-fort-wayne-in-obituary.jpg"
|
||||
"?maxheight=650"
|
||||
)
|
||||
|
||||
# Waveshare 6-colour palette: (display code, match RGB)
|
||||
# RGB values tuned for photographic content — skin tones sit between
|
||||
# white and yellow/red, so those two anchors matter most.
|
||||
PALETTE = [
|
||||
(0x0, ( 0, 0, 0)), # Black
|
||||
(0x1, (255, 255, 255)), # White
|
||||
(0x2, (255, 220, 0)), # Yellow
|
||||
(0x3, (220, 40, 40)), # Red
|
||||
(0x5, ( 20, 80, 200)), # Blue
|
||||
(0x6, ( 50, 160, 50)), # Green
|
||||
]
|
||||
CODES = np.array([c for c, _ in PALETTE], dtype=np.uint8)
|
||||
PAL_RGB = np.array([rgb for _, rgb in PALETTE], dtype=np.float32) # (6,3)
|
||||
|
||||
|
||||
def floyd_steinberg(img_rgb: np.ndarray) -> np.ndarray:
|
||||
"""In-place F-S dither; returns (H,W) array of display colour codes."""
|
||||
arr = img_rgb.astype(np.float32)
|
||||
out = np.zeros((H, W), dtype=np.uint8)
|
||||
|
||||
for y in range(H):
|
||||
for x in range(W):
|
||||
px = np.clip(arr[y, x], 0, 255)
|
||||
diffs = PAL_RGB - px
|
||||
idx = int(np.argmin((diffs ** 2).sum(axis=1)))
|
||||
out[y, x] = CODES[idx]
|
||||
|
||||
err = px - PAL_RGB[idx]
|
||||
if x + 1 < W:
|
||||
arr[y, x + 1] += err * (7 / 16)
|
||||
if y + 1 < H:
|
||||
if x > 0:
|
||||
arr[y + 1, x - 1] += err * (3 / 16)
|
||||
arr[y + 1, x] += err * (5 / 16)
|
||||
if x + 1 < W:
|
||||
arr[y + 1, x + 1] += err * (1 / 16)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f"Downloading {URL} ...")
|
||||
req = urllib.request.Request(URL, headers={"User-Agent": "pictureFrame/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
img_bytes = r.read()
|
||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
print(f"Downloaded: {img.size[0]}×{img.size[1]}")
|
||||
|
||||
# Letterbox: fit inside 800×480, centre on white background
|
||||
scale = min(W / img.size[0], H / img.size[1])
|
||||
new_w = int(img.size[0] * scale)
|
||||
new_h = int(img.size[1] * scale)
|
||||
resized = img.resize((new_w, new_h), Image.LANCZOS)
|
||||
canvas = Image.new("RGB", (W, H), (255, 255, 255))
|
||||
canvas.paste(resized, ((W - new_w) // 2, (H - new_h) // 2))
|
||||
canvas.save("/tmp/picture_frame_preview.png")
|
||||
print("Preview → /tmp/picture_frame_preview.png")
|
||||
|
||||
print(f"Dithering {W}×{H} to 6-colour palette (this takes ~60s) ...")
|
||||
arr = np.array(canvas, dtype=np.float32)
|
||||
codes = floyd_steinberg(arr)
|
||||
|
||||
# Colour distribution
|
||||
names = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
|
||||
for i, (code, name) in enumerate(zip(CODES, names)):
|
||||
cnt = int((codes == code).sum())
|
||||
print(f" {name:7s} ({code:#04x}): {cnt:7d} px ({100*cnt/(W*H):.1f}%)")
|
||||
|
||||
# Pack 4bpp: high nibble = even column, low nibble = odd column
|
||||
high = codes[:, 0::2].astype(np.uint8)
|
||||
low = codes[:, 1::2].astype(np.uint8)
|
||||
packed = ((high << 4) | low)
|
||||
packed_bytes = packed.tobytes()
|
||||
assert len(packed_bytes) == H * (W // 2)
|
||||
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Writing {OUT} ...")
|
||||
ROW = W // 2
|
||||
with OUT.open("w") as f:
|
||||
f.write("#pragma once\n")
|
||||
f.write("#include <pgmspace.h>\n\n")
|
||||
f.write(f"// {W}×{H} photo, 4bpp Waveshare 7.3\" 6-colour\n")
|
||||
f.write("// Packing: byte[x/2] = (code[x]<<4)|code[x+1], even col = high nibble\n")
|
||||
f.write(f"#define IMAGE_ROW {ROW} // bytes per display row\n\n")
|
||||
f.write("const uint8_t IMAGE_DATA[] PROGMEM = {\n")
|
||||
for y in range(H):
|
||||
row = packed_bytes[y * ROW:(y + 1) * ROW]
|
||||
for off in range(0, ROW, 16):
|
||||
chunk = row[off:off + 16]
|
||||
f.write(" " + ",".join(f"0x{b:02X}" for b in chunk) + ",\n")
|
||||
f.write("};\n")
|
||||
|
||||
kb = len(packed_bytes) / 1024
|
||||
print(f"Done — {len(packed_bytes):,} bytes ({kb:.1f} KB) → {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,33 @@
|
||||
[env:esp32dev]
|
||||
platform = espressif32
|
||||
board = esp32dev
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
upload_port = /dev/ttyUSB0
|
||||
monitor_port = /dev/ttyUSB0
|
||||
board_build.filesystem = littlefs
|
||||
lib_deps =
|
||||
ricmoo/QRCode@^0.0.1
|
||||
|
||||
; Flash a single image from firmware/data/img.bin — no WiFi, no server needed.
|
||||
; 1. pio run -e test-display --target uploadfs (upload the image)
|
||||
; 2. pio run -e test-display --target upload (upload the sketch)
|
||||
[env:test-display]
|
||||
platform = espressif32
|
||||
board = esp32dev
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
upload_port = /dev/ttyUSB0
|
||||
monitor_port = /dev/ttyUSB0
|
||||
board_build.filesystem = littlefs
|
||||
build_flags = -DENV_TEST_DISPLAY
|
||||
build_src_filter = +<epd.cpp> +<test_display.cpp>
|
||||
lib_deps =
|
||||
ricmoo/QRCode@^0.0.1
|
||||
|
||||
[env:native-test]
|
||||
platform = native
|
||||
lib_deps =
|
||||
throwtheswitch/Unity@^2.6
|
||||
build_flags = -DUNIT_TEST -std=c++17 -iquote test/mocks -iquote test -Itest/mocks -Itest
|
||||
test_build_src = no
|
||||
@@ -0,0 +1,343 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate ap_bg.bin and setup_bg.bin — 800×480 4bpp backgrounds for the
|
||||
pictureFrame e-ink device. QR overlay areas are left WHITE so the
|
||||
firmware can render the actual QR code at runtime.
|
||||
|
||||
Run from the firmware/ directory:
|
||||
python3 scripts/gen_screens.py
|
||||
|
||||
Constants exported (copy to epd.cpp):
|
||||
AP_QR_X, AP_QR_Y, AP_QR_CELL, AP_QR_PX
|
||||
SETUP_QR_X, SETUP_QR_Y, SETUP_QR_CELL, SETUP_QR_PX
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os, sys
|
||||
|
||||
# ── Display ──────────────────────────────────────────────────────────────────
|
||||
W, H = 800, 480
|
||||
|
||||
# ── EPD palette ───────────────────────────────────────────────────────────────
|
||||
BLACK = 0x0; BK = (26, 26, 26 )
|
||||
WHITE = 0x1; WH = (245, 245, 240)
|
||||
YELLOW = 0x2; YL = (240, 208, 0 )
|
||||
RED = 0x3; RD = (192, 48, 32 )
|
||||
BLUE = 0x5; BL = (24, 64, 192)
|
||||
GREEN = 0x6; GR = (16, 160, 64 )
|
||||
|
||||
PALETTE_RGB = {BLACK: BK, WHITE: WH, YELLOW: YL, RED: RD, BLUE: BL, GREEN: GR}
|
||||
|
||||
def nearest(r, g, b):
|
||||
best, best_d = WHITE, float("inf")
|
||||
for n, (pr, pg, pb) in PALETTE_RGB.items():
|
||||
d = (r-pr)**2 + (g-pg)**2 + (b-pb)**2
|
||||
if d < best_d: best, best_d = n, d
|
||||
return best
|
||||
|
||||
def pack(img):
|
||||
"""Convert RGB PIL image → 4bpp packed bytearray."""
|
||||
px = img.load()
|
||||
out = bytearray()
|
||||
for y in range(H):
|
||||
for x in range(0, W, 2):
|
||||
hi = nearest(*px[x, y])
|
||||
lo = nearest(*px[x+1, y])
|
||||
out.append((hi << 4) | lo)
|
||||
return out
|
||||
|
||||
# ── Fonts ─────────────────────────────────────────────────────────────────────
|
||||
FONT_DIR = "/usr/share/fonts/truetype/dejavu"
|
||||
def ttf(name, size):
|
||||
try: return ImageFont.truetype(os.path.join(FONT_DIR, name), size)
|
||||
except: return ImageFont.load_default()
|
||||
|
||||
F_HEAD = ttf("DejaVuSans-Bold.ttf", 26)
|
||||
F_BAR = ttf("DejaVuSans-Bold.ttf", 13)
|
||||
F_STEP = ttf("DejaVuSans.ttf", 13)
|
||||
F_STEP_B= ttf("DejaVuSans-Bold.ttf", 13)
|
||||
F_STEPN = ttf("DejaVuSans-Bold.ttf", 13)
|
||||
F_LABEL = ttf("DejaVuSans-Bold.ttf", 11)
|
||||
F_TINY = ttf("DejaVuSans-Bold.ttf", 10)
|
||||
F_FOOT = ttf("DejaVuSans.ttf", 12)
|
||||
F_CHIP = ttf("DejaVuSans-Bold.ttf", 12)
|
||||
F_SUB = ttf("DejaVuSans.ttf", 14)
|
||||
F_BIG = ttf("DejaVuSans-Bold.ttf", 14)
|
||||
|
||||
# ── Layout constants ──────────────────────────────────────────────────────────
|
||||
BAR_H = 52
|
||||
BODY_Y = BAR_H # 52
|
||||
|
||||
LEFT_X = 0; LEFT_W = 310
|
||||
DIV1_X = 310; DIV_W = 2
|
||||
CTR_X = 312; CTR_W = 196
|
||||
DIV2_X = 508
|
||||
RIGHT_X = 510; RIGHT_W = 290 # 800-510
|
||||
|
||||
# QR positions (MUST match epd.cpp constants)
|
||||
AP_QR_CELL = 5
|
||||
AP_QR_MODS = 37 # version 5, ECC_LOW
|
||||
AP_QR_PX = AP_QR_MODS * AP_QR_CELL # 185
|
||||
|
||||
SETUP_QR_CELL = 5
|
||||
SETUP_QR_MODS = 41 # version 6, ECC_LOW
|
||||
SETUP_QR_PX = SETUP_QR_MODS * SETUP_QR_CELL # 205
|
||||
|
||||
# Centre of right panel
|
||||
RIGHT_CX = RIGHT_X + RIGHT_W // 2 # 655
|
||||
BODY_CY = BODY_Y + (H - BODY_Y) // 2 # 266
|
||||
|
||||
AP_QR_X = RIGHT_CX - AP_QR_PX // 2 # 563
|
||||
AP_QR_Y = BODY_CY - AP_QR_PX // 2 # 174 (will nudge down below label)
|
||||
AP_QR_Y = 185 # nudge down a bit for "SCAN TO CONNECT" label above
|
||||
|
||||
SETUP_QR_X = RIGHT_CX - SETUP_QR_PX // 2 # 553
|
||||
SETUP_QR_Y = BODY_CY - SETUP_QR_PX // 2 # 164
|
||||
SETUP_QR_Y = 175 # nudge for label
|
||||
|
||||
def leave_qr_white(draw, qr_x, qr_y, qr_px):
|
||||
"""Blank the QR overlay region so firmware can write the real QR."""
|
||||
draw.rectangle([qr_x, qr_y, qr_x+qr_px-1, qr_y+qr_px-1], fill=WH)
|
||||
|
||||
def text_center(draw, cx, y, text, font, fill):
|
||||
bb = draw.textbbox((0,0), text, font=font)
|
||||
tw = bb[2]-bb[0]
|
||||
draw.text((cx - tw//2, y), text, font=font, fill=fill)
|
||||
|
||||
def orientation_diagrams(draw, accent, show_active_ls=True):
|
||||
"""Draw both orientation diagrams in the centre panel.
|
||||
accent = RGB colour for the active / ribbon highlights."""
|
||||
cx = CTR_X + CTR_W // 2 # 410
|
||||
|
||||
# ── Section title ─────────────────────────────────────────────
|
||||
text_center(draw, cx, BODY_Y+15, "FRAME", F_TINY, BK)
|
||||
text_center(draw, cx, BODY_Y+27, "ORIENTATION", F_TINY, BK)
|
||||
|
||||
# ── Landscape ──────────────────────────────────────────────────
|
||||
ls_x, ls_y, ls_w, ls_h = CTR_X+43, BODY_Y+52, 110, 66
|
||||
rib_w, rib_h = 110, 10
|
||||
|
||||
text_center(draw, cx, ls_y-14, "LANDSCAPE", F_LABEL, accent if show_active_ls else BK)
|
||||
|
||||
ls_border = accent if show_active_ls else BK
|
||||
draw.rectangle([ls_x, ls_y, ls_x+ls_w-1, ls_y+ls_h-1], outline=ls_border, width=3)
|
||||
rib_rgb = accent if show_active_ls else BK
|
||||
draw.rectangle([ls_x, ls_y+ls_h, ls_x+rib_w-1, ls_y+ls_h+rib_h-1], fill=rib_rgb)
|
||||
|
||||
if show_active_ls:
|
||||
# check badge
|
||||
bx, by = cx-9, ls_y+ls_h+rib_h+5
|
||||
draw.rectangle([bx, by, bx+18, by+18], fill=accent)
|
||||
text_center(draw, bx+9, by+3, "✓", F_CHIP, BK)
|
||||
|
||||
# Thin separator
|
||||
sep_y = ls_y + ls_h + rib_h + (30 if show_active_ls else 14)
|
||||
draw.rectangle([CTR_X+10, sep_y, CTR_X+CTR_W-10, sep_y], fill=(180,180,175))
|
||||
|
||||
# ── Portrait ──────────────────────────────────────────────────
|
||||
pt_x, pt_y = CTR_X+56, sep_y+14
|
||||
pt_w, pt_h = 64, 106
|
||||
pr_w, pr_h = 10, 106
|
||||
|
||||
text_center(draw, cx, pt_y-14, "PORTRAIT", F_LABEL, BK)
|
||||
|
||||
draw.rectangle([pt_x-pr_w, pt_y, pt_x-1, pt_y+pr_h-1], fill=BK)
|
||||
draw.rectangle([pt_x, pt_y, pt_x+pt_w-1, pt_y+pt_h-1], outline=BK, width=3)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# AP SCREEN — yellow accent, WiFi credentials
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
def gen_ap():
|
||||
img = Image.new("RGB", (W, H), WH)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# ── Status bar ────────────────────────────────────────────────
|
||||
draw.rectangle([0, 0, W-1, BAR_H-1], fill=YL)
|
||||
draw.text((24, 18), "SETUP MODE — STEP 1 OF 2", font=F_BAR, fill=BK)
|
||||
|
||||
# Right chip: black box with device SSID
|
||||
chip_x, chip_y = 498, 11
|
||||
chip_text = "PictureFrame-91F8"
|
||||
bb = draw.textbbox((0,0), chip_text, font=F_CHIP)
|
||||
chip_w = bb[2]-bb[0] + 22
|
||||
chip_x2 = chip_x + chip_w
|
||||
draw.rectangle([chip_x, chip_y, chip_x2, BAR_H-12], fill=BK)
|
||||
draw.text((chip_x+11, chip_y+7), chip_text, font=F_CHIP, fill=YL)
|
||||
|
||||
# ── Panel dividers ────────────────────────────────────────────
|
||||
draw.rectangle([DIV1_X, BODY_Y, DIV1_X+1, H-1], fill=BK)
|
||||
draw.rectangle([DIV2_X, BODY_Y, DIV2_X+1, H-1], fill=BK)
|
||||
|
||||
# ── Left panel ────────────────────────────────────────────────
|
||||
# Heading
|
||||
draw.text((28, BODY_Y+20), "Connect to", font=F_HEAD, fill=BK)
|
||||
draw.text((28, BODY_Y+52), "WiFi", font=F_HEAD, fill=BK)
|
||||
bb = draw.textbbox((0,0), "WiFi", font=F_HEAD)
|
||||
draw.rectangle([28, BODY_Y+82, 28+bb[2]+2, BODY_Y+85], fill=YL)
|
||||
|
||||
# Steps
|
||||
steps = [
|
||||
("Scan the QR code →", "Phone joins PictureFrame-91F8"),
|
||||
("Browser opens — enter", "your home WiFi password"),
|
||||
("Tap Connect and watch", "for the QR code to change"),
|
||||
]
|
||||
sy = BODY_Y + 105
|
||||
for i, (l1, l2) in enumerate(steps):
|
||||
bx, by = 28, sy + i*46
|
||||
draw.rectangle([bx, by, bx+24, by+24], fill=BK)
|
||||
text_center(draw, bx+12, by+6, str(i+1), F_STEPN, YL)
|
||||
draw.text((62, by+3), l1, font=F_STEP, fill=BK)
|
||||
draw.text((62, by+17), l2, font=F_STEP, fill=BK)
|
||||
|
||||
# Divider + footnote
|
||||
draw.rectangle([28, BODY_Y+254, 283, BODY_Y+255], fill=BK)
|
||||
draw.text((28, BODY_Y+262), "Page didn't open?", font=F_FOOT, fill=BK)
|
||||
draw.text((28, BODY_Y+276), "Go to 192.168.4.1", font=F_FOOT, fill=BK)
|
||||
|
||||
# ── Centre panel ─────────────────────────────────────────────
|
||||
orientation_diagrams(draw, YL, show_active_ls=True)
|
||||
|
||||
# ── Right panel ──────────────────────────────────────────────
|
||||
cx = RIGHT_CX
|
||||
|
||||
# "SCAN TO CONNECT" label
|
||||
text_center(draw, cx, AP_QR_Y - 26, "SCAN TO CONNECT", F_BIG, BK)
|
||||
|
||||
# QR border: yellow outer, black inner
|
||||
qx, qy, qp = AP_QR_X, AP_QR_Y, AP_QR_PX
|
||||
draw.rectangle([qx-6, qy-6, qx+qp+5, qy+qp+5], outline=YL, width=3)
|
||||
draw.rectangle([qx-3, qy-3, qx+qp+2, qy+qp+2], outline=BK, width=3)
|
||||
|
||||
# Leave QR area white for firmware overlay
|
||||
leave_qr_white(draw, qx, qy, qp)
|
||||
|
||||
# "Encodes WIFI:..." label below
|
||||
text_center(draw, cx, qy+qp+10, "WIFI:S:PictureFrame-91F8;T:nopass;;", F_FOOT, (100,100,95))
|
||||
|
||||
return img
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# SETUP SCREEN — green accent, account link
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
def gen_setup():
|
||||
img = Image.new("RGB", (W, H), WH)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# ── Status bar ────────────────────────────────────────────────
|
||||
draw.rectangle([0, 0, W-1, BAR_H-1], fill=GR)
|
||||
|
||||
# WiFi bars icon
|
||||
bars = [(0, 8), (0, 13), (0, 18), (0, 22)]
|
||||
bx = 24
|
||||
for i, (_, bh) in enumerate(bars):
|
||||
draw.rectangle([bx + i*8, BAR_H//2 - bh//2, bx+i*8+5, BAR_H//2 + bh//2], fill=WH)
|
||||
draw.text((bx+38, 18), "WIFI CONNECTED — STEP 2 OF 2", font=F_BAR, fill=WH)
|
||||
|
||||
# Right IP chip
|
||||
ip_text = "192.168.x.x"
|
||||
bb = draw.textbbox((0,0), ip_text, font=F_CHIP)
|
||||
chip_w = bb[2]-bb[0] + 22
|
||||
chip_x = W - chip_w - 20
|
||||
draw.rectangle([chip_x, 11, chip_x+chip_w, BAR_H-12], fill=WH)
|
||||
draw.text((chip_x+11, 18), ip_text, font=F_CHIP, fill=GR)
|
||||
|
||||
# ── Panel dividers ────────────────────────────────────────────
|
||||
draw.rectangle([DIV1_X, BODY_Y, DIV1_X+1, H-1], fill=BK)
|
||||
draw.rectangle([DIV2_X, BODY_Y, DIV2_X+1, H-1], fill=BK)
|
||||
|
||||
# ── Left panel ────────────────────────────────────────────────
|
||||
draw.text((28, BODY_Y+20), "Almost", font=F_HEAD, fill=BK)
|
||||
draw.text((28, BODY_Y+52), "ready.", font=F_HEAD, fill=BK)
|
||||
bb = draw.textbbox((0,0), "ready.", font=F_HEAD)
|
||||
draw.rectangle([28, BODY_Y+82, 28+bb[2]+2, BODY_Y+85], fill=GR)
|
||||
|
||||
draw.text((28, BODY_Y+96), "Scan to name this frame and", font=F_STEP, fill=(80,80,75))
|
||||
draw.text((28, BODY_Y+110), "link it to your account.", font=F_STEP, fill=(80,80,75))
|
||||
|
||||
steps = [
|
||||
("Scan the QR with your phone", "camera or QR app"),
|
||||
("Sign in at pictureframe", ".edholm.me"),
|
||||
("Name the frame, choose", "orientation — done."),
|
||||
]
|
||||
sy = BODY_Y + 136
|
||||
for i, (l1, l2) in enumerate(steps):
|
||||
bx, by = 28, sy + i*46
|
||||
draw.rectangle([bx, by, bx+24, by+24], fill=GR)
|
||||
text_center(draw, bx+12, by+6, str(i+1), F_STEPN, WH)
|
||||
draw.text((62, by+3), l1, font=F_STEP, fill=BK)
|
||||
draw.text((62, by+17), l2, font=F_STEP, fill=BK)
|
||||
|
||||
# URL bar
|
||||
url_y = BODY_Y + 278
|
||||
draw.rectangle([28, url_y, 284, url_y+32], fill=BK)
|
||||
draw.text((38, url_y+4), "URL", font=F_TINY, fill=GR)
|
||||
draw.text((38, url_y+16), "pictureframe.edholm.me/setup/...", font=ttf("DejaVuSans.ttf", 10), fill=WH)
|
||||
|
||||
# Progress track
|
||||
prog_y = BODY_Y + 328
|
||||
draw.text((28, prog_y), "SETUP PROGRESS", font=F_TINY, fill=(140,140,135))
|
||||
seg_y = prog_y + 14
|
||||
segs = [("WiFi", GR), ("Account", BK), ("Frame ready", (200,200,195))]
|
||||
seg_w = (284 - 28 - 8) // 3 # ~82px each
|
||||
for i, (label, color) in enumerate(segs):
|
||||
sx = 28 + i*(seg_w+4)
|
||||
draw.rectangle([sx, seg_y, sx+seg_w, seg_y+6], fill=color)
|
||||
text_center(draw, sx+seg_w//2, seg_y+10, label, ttf("DejaVuSans.ttf", 9), BK)
|
||||
|
||||
# ── Centre panel ─────────────────────────────────────────────
|
||||
orientation_diagrams(draw, GR, show_active_ls=True)
|
||||
|
||||
# ── Right panel ──────────────────────────────────────────────
|
||||
cx = RIGHT_CX
|
||||
|
||||
text_center(draw, cx, SETUP_QR_Y - 26, "SCAN TO FINISH", F_BIG, BK)
|
||||
|
||||
qx, qy, qp = SETUP_QR_X, SETUP_QR_Y, SETUP_QR_PX
|
||||
draw.rectangle([qx-6, qy-6, qx+qp+5, qy+qp+5], outline=GR, width=3)
|
||||
draw.rectangle([qx-3, qy-3, qx+qp+2, qy+qp+2], outline=BK, width=3)
|
||||
|
||||
leave_qr_white(draw, qx, qy, qp)
|
||||
|
||||
# MAC chip below QR
|
||||
mac = "1C:C3:AB:D1:91:F8"
|
||||
bb = draw.textbbox((0,0), mac, font=F_CHIP)
|
||||
mw = bb[2]-bb[0]+20
|
||||
mx = cx - mw//2
|
||||
draw.rectangle([mx, qy+qp+8, mx+mw, qy+qp+26], fill=BK)
|
||||
draw.text((mx+10, qy+qp+11), mac, font=ttf("DejaVuSans-Bold.ttf", 10), fill=WH)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
# ── Save ──────────────────────────────────────────────────────────────────────
|
||||
def save_bin(img, path, preview_path):
|
||||
data = pack(img)
|
||||
with open(path, "wb") as f: f.write(data)
|
||||
print(f"Written {len(data):,} bytes → {os.path.abspath(path)}")
|
||||
|
||||
# Reconstruct preview from packed data for verification
|
||||
prev = Image.new("RGB", (W, H))
|
||||
px = prev.load()
|
||||
for y in range(H):
|
||||
for x in range(0, W, 2):
|
||||
byte = data[y*(W//2) + x//2]
|
||||
px[x, y] = PALETTE_RGB.get(byte >> 4, (128,128,128))
|
||||
px[x+1, y] = PALETTE_RGB.get(byte & 0xF, (128,128,128))
|
||||
prev.save(preview_path)
|
||||
print(f"Preview → {os.path.abspath(preview_path)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
out_dir = os.path.join(os.path.dirname(__file__), "../data")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
print("Generating AP screen…")
|
||||
save_bin(gen_ap(), f"{out_dir}/ap_bg.bin", f"{out_dir}/ap_bg_preview.png")
|
||||
print()
|
||||
print("Generating setup screen…")
|
||||
save_bin(gen_setup(), f"{out_dir}/setup_bg.bin", f"{out_dir}/setup_bg_preview.png")
|
||||
print()
|
||||
print("QR overlay constants for epd.cpp:")
|
||||
print(f" AP_QR_X={AP_QR_X}, AP_QR_Y={AP_QR_Y}, AP_QR_CELL={AP_QR_CELL}, AP_QR_PX={AP_QR_PX}")
|
||||
print(f" SETUP_QR_X={SETUP_QR_X}, SETUP_QR_Y={SETUP_QR_Y}, SETUP_QR_CELL={SETUP_QR_CELL}, SETUP_QR_PX={SETUP_QR_PX}")
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate setup_bg.bin — the 800×480 4bpp background for the device setup screen.
|
||||
The QR code is overlaid at runtime at position (QR_X, QR_Y) by the firmware.
|
||||
Run from the firmware/ directory: python3 scripts/gen_setup_bg.py
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import struct, os, sys
|
||||
|
||||
# ── Display + palette ───────────────────────────────────────────────────────────
|
||||
W, H = 800, 480
|
||||
|
||||
# EPD 4bpp palette nibbles
|
||||
BLACK = 0x0
|
||||
WHITE = 0x1
|
||||
YELLOW = 0x2
|
||||
RED = 0x3
|
||||
BLUE = 0x5
|
||||
GREEN = 0x6
|
||||
|
||||
# PIL RGB for each nibble (used for drawing and for quantisation)
|
||||
PALETTE_RGB = {
|
||||
BLACK: (0, 0, 0 ),
|
||||
WHITE: (255, 255, 255),
|
||||
YELLOW: (255, 230, 0 ),
|
||||
RED: (200, 0, 0 ),
|
||||
BLUE: (0, 0, 220),
|
||||
GREEN: (0, 170, 60 ),
|
||||
}
|
||||
|
||||
# ── QR code placement (must match SETUP_QR_X / SETUP_QR_Y in epd.cpp) ──────────
|
||||
QR_CELL = 5
|
||||
QR_MODS = 41 # version 6, ECC_LOW
|
||||
QR_PX = QR_MODS * QR_CELL # 205 px
|
||||
QR_X = 555
|
||||
QR_Y = (H - QR_PX) // 2 # 137
|
||||
|
||||
# ── Fonts ────────────────────────────────────────────────────────────────────────
|
||||
FONT_DIR = "/usr/share/fonts/truetype/dejavu"
|
||||
def font(name, size):
|
||||
try:
|
||||
return ImageFont.truetype(os.path.join(FONT_DIR, name), size)
|
||||
except Exception:
|
||||
return ImageFont.load_default()
|
||||
|
||||
font_title = font("DejaVuSans-Bold.ttf", 36)
|
||||
font_label = font("DejaVuSans-Bold.ttf", 20)
|
||||
font_sub = font("DejaVuSans.ttf", 15)
|
||||
font_scan = font("DejaVuSans.ttf", 14)
|
||||
|
||||
# ── Draw ─────────────────────────────────────────────────────────────────────────
|
||||
img = Image.new("RGB", (W, H), PALETTE_RGB[WHITE])
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
BK = PALETTE_RGB[BLACK]
|
||||
GR = PALETTE_RGB[GREEN]
|
||||
|
||||
# Title
|
||||
draw.text((40, 32), "pictureFrame", font=font_title, fill=BK)
|
||||
|
||||
# Thin rule under title
|
||||
draw.rectangle([40, 80, 490, 82], fill=BK)
|
||||
|
||||
# ── Landscape diagram ────────────────────────────────────────────────────────────
|
||||
LS_X, LS_Y, LS_W, LS_H = 45, 110, 200, 120
|
||||
RIB_W, RIB_H = 56, 14
|
||||
LS_RX = LS_X + (LS_W - RIB_W) // 2
|
||||
LS_RY = LS_Y + LS_H # ribbon protrudes below
|
||||
|
||||
BORDER = 3
|
||||
draw.rectangle([LS_X, LS_Y, LS_X+LS_W, LS_Y+LS_H], outline=BK, width=BORDER)
|
||||
draw.rectangle([LS_RX, LS_RY, LS_RX+RIB_W, LS_RY+RIB_H], fill=GR)
|
||||
|
||||
draw.text((LS_X, LS_RY + RIB_H + 10), "Landscape", font=font_label, fill=BK)
|
||||
draw.text((LS_X, LS_RY + RIB_H + 34), "Ribbon at bottom", font=font_sub, fill=BK)
|
||||
|
||||
# ── Portrait diagram ──────────────────────────────────────────────────────────────
|
||||
PT_X, PT_Y, PT_W, PT_H = 310, 95, 120, 200
|
||||
RIB2_W, RIB2_H = 14, 56
|
||||
PT_RX = PT_X - RIB2_W # ribbon protrudes left
|
||||
PT_RY = PT_Y + (PT_H - RIB2_H) // 2
|
||||
|
||||
draw.rectangle([PT_X, PT_Y, PT_X+PT_W, PT_Y+PT_H], outline=BK, width=BORDER)
|
||||
draw.rectangle([PT_RX, PT_RY, PT_RX+RIB2_W, PT_RY+RIB2_H], fill=GR)
|
||||
|
||||
draw.text((PT_X, PT_Y + PT_H + 10), "Portrait", font=font_label, fill=BK)
|
||||
draw.text((PT_X, PT_Y + PT_H + 34), "Ribbon on left", font=font_sub, fill=BK)
|
||||
|
||||
# ── Divider ───────────────────────────────────────────────────────────────────────
|
||||
draw.rectangle([520, 0, 522, H], fill=PALETTE_RGB[BLACK])
|
||||
|
||||
# ── QR zone label ─────────────────────────────────────────────────────────────────
|
||||
scan_txt = "Scan to set up"
|
||||
bb = draw.textbbox((0, 0), scan_txt, font=font_scan)
|
||||
tw = bb[2] - bb[0]
|
||||
draw.text((QR_X + (QR_PX - tw) // 2, QR_Y + QR_PX + 12), scan_txt, font=font_scan, fill=BK)
|
||||
|
||||
# Leave QR area pure WHITE so the firmware overlay is clean
|
||||
draw.rectangle([QR_X, QR_Y, QR_X + QR_PX - 1, QR_Y + QR_PX - 1], fill=PALETTE_RGB[WHITE])
|
||||
|
||||
# ── Quantise to EPD palette ───────────────────────────────────────────────────────
|
||||
def nearest(r, g, b):
|
||||
best, best_d = WHITE, float("inf")
|
||||
for nibble, (pr, pg, pb) in PALETTE_RGB.items():
|
||||
d = (r-pr)**2 + (g-pg)**2 + (b-pb)**2
|
||||
if d < best_d:
|
||||
best, best_d = nibble, d
|
||||
return best
|
||||
|
||||
pixels = img.load()
|
||||
out = bytearray()
|
||||
for y in range(H):
|
||||
for x in range(0, W, 2):
|
||||
hi = nearest(*pixels[x, y])
|
||||
lo = nearest(*pixels[x+1, y])
|
||||
out.append((hi << 4) | lo)
|
||||
|
||||
out_path = os.path.join(os.path.dirname(__file__), "../data/setup_bg.bin")
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(out)
|
||||
|
||||
print(f"Written {len(out):,} bytes → {os.path.abspath(out_path)}")
|
||||
print(f"QR overlay position: x={QR_X}, y={QR_Y}, cell={QR_CELL} (copy to SETUP_QR_* in epd.cpp)")
|
||||
|
||||
# ── Preview PNG (for inspection) ─────────────────────────────────────────────────
|
||||
preview = Image.new("RGB", (W, H))
|
||||
pix = preview.load()
|
||||
for y in range(H):
|
||||
for x in range(0, W, 2):
|
||||
byte = out[y * (W // 2) + x // 2]
|
||||
pix[x, y] = PALETTE_RGB.get(byte >> 4, (128,128,128))
|
||||
pix[x+1, y] = PALETTE_RGB.get(byte & 0xF, (128,128,128))
|
||||
preview_path = out_path.replace(".bin", "_preview.png")
|
||||
preview.save(preview_path)
|
||||
print(f"Preview PNG → {os.path.abspath(preview_path)}")
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
// ── EPD pins (Waveshare 7.3" ACeP) ──────────────────────────────────────────
|
||||
#define EPD_WIDTH 800
|
||||
#define EPD_HEIGHT 480
|
||||
#define PIN_SCK 18
|
||||
#define PIN_MOSI 23
|
||||
#define PIN_CS 5
|
||||
#define PIN_DC 17
|
||||
#define PIN_RST 16
|
||||
#define PIN_BUSY 4
|
||||
|
||||
// ── Reset button (BOOT button = GPIO 0 on most dev boards) ──────────────────
|
||||
#define PIN_BTN_RESET 0
|
||||
#define RESET_HOLD_MS 5000
|
||||
|
||||
// ── EPD color nibbles ────────────────────────────────────────────────────────
|
||||
// Verified on Waveshare 7.3" hardware. Same map on 13.3" Spectra 6.
|
||||
#define COLOR_BLACK 0x0
|
||||
#define COLOR_WHITE 0x1
|
||||
#define COLOR_YELLOW 0x2
|
||||
#define COLOR_RED 0x3
|
||||
#define COLOR_BLUE 0x5
|
||||
#define COLOR_GREEN 0x6
|
||||
|
||||
// ── NVS ──────────────────────────────────────────────────────────────────────
|
||||
#define NVS_NAMESPACE "pf"
|
||||
#define NVS_KEY_SSID "ssid"
|
||||
#define NVS_KEY_PASS "pass"
|
||||
#define NVS_KEY_IMG_ID "img_id"
|
||||
#define NVS_KEY_DRAW_NEEDED "draw"
|
||||
|
||||
// ── Network ──────────────────────────────────────────────────────────────────
|
||||
#define APP_BASE_URL "https://pictureframe.edholm.me"
|
||||
#define AP_IP "192.168.4.1"
|
||||
#define WIFI_TIMEOUT_MS 30000
|
||||
#ifndef FETCH_INTERVAL_MS
|
||||
#define FETCH_INTERVAL_MS 60000 // 1 min deep sleep between polls
|
||||
#endif
|
||||
#define IMAGE_PATH "/img.bin"
|
||||
@@ -0,0 +1,150 @@
|
||||
#include "epd.h"
|
||||
#include "config.h"
|
||||
#include <LittleFS.h>
|
||||
#include <qrcode.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
static uint8_t s_row[EPD_WIDTH / 2];
|
||||
|
||||
static void wait_busy() {
|
||||
uint32_t start = millis();
|
||||
while (digitalRead(PIN_BUSY) == LOW) {
|
||||
if (millis() - start > 60000) return; // 6-color refresh takes ~20s
|
||||
delay(5);
|
||||
esp_task_wdt_reset(); // feed WDT — display refresh can take ~20 s
|
||||
}
|
||||
}
|
||||
static void cmd(uint8_t c) {
|
||||
digitalWrite(PIN_DC, LOW);
|
||||
digitalWrite(PIN_CS, LOW);
|
||||
SPI.transfer(c);
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
}
|
||||
static void dat(uint8_t d) {
|
||||
digitalWrite(PIN_DC, HIGH);
|
||||
digitalWrite(PIN_CS, LOW);
|
||||
SPI.transfer(d);
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
}
|
||||
|
||||
void epd_init() {
|
||||
digitalWrite(PIN_RST, HIGH); delay(20);
|
||||
digitalWrite(PIN_RST, LOW); delay(10);
|
||||
digitalWrite(PIN_RST, HIGH); delay(20);
|
||||
wait_busy(); delay(30);
|
||||
|
||||
cmd(0xAA);
|
||||
dat(0x49); dat(0x55); dat(0x20);
|
||||
dat(0x08); dat(0x09); dat(0x18);
|
||||
cmd(0x01); dat(0x3F);
|
||||
cmd(0x00); dat(0x5F); dat(0x69);
|
||||
cmd(0x03); dat(0x00); dat(0x54); dat(0x00); dat(0x44);
|
||||
cmd(0x05); dat(0x40); dat(0x1F); dat(0x1F); dat(0x2C);
|
||||
cmd(0x06); dat(0x6F); dat(0x1F); dat(0x17); dat(0x49);
|
||||
cmd(0x08); dat(0x6F); dat(0x1F); dat(0x1F); dat(0x22);
|
||||
cmd(0x30); dat(0x03);
|
||||
cmd(0x50); dat(0x3F);
|
||||
cmd(0x60); dat(0x02); dat(0x00);
|
||||
cmd(0x61); dat(0x03); dat(0x20); dat(0x01); dat(0xE0);
|
||||
cmd(0x84); dat(0x01);
|
||||
cmd(0xE3); dat(0x2F);
|
||||
cmd(0x04); wait_busy();
|
||||
}
|
||||
|
||||
void epd_sleep() {
|
||||
cmd(0x02); dat(0x00); wait_busy();
|
||||
cmd(0x07); dat(0xA5);
|
||||
}
|
||||
|
||||
static void epd_refresh() {
|
||||
cmd(0x04); wait_busy();
|
||||
cmd(0x12); dat(0x00); wait_busy();
|
||||
}
|
||||
|
||||
void epd_fill(uint8_t color) {
|
||||
uint8_t byte = (color << 4) | color;
|
||||
cmd(0x10);
|
||||
digitalWrite(PIN_DC, HIGH);
|
||||
digitalWrite(PIN_CS, LOW);
|
||||
for (int i = 0; i < EPD_WIDTH * EPD_HEIGHT / 2; i++) SPI.transfer(byte);
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
epd_refresh();
|
||||
}
|
||||
|
||||
void epd_draw_image_from_file(fs::File& f) {
|
||||
cmd(0x10);
|
||||
uint8_t buf[512];
|
||||
digitalWrite(PIN_DC, HIGH);
|
||||
digitalWrite(PIN_CS, LOW);
|
||||
while (f.available()) {
|
||||
size_t n = f.read(buf, sizeof(buf));
|
||||
SPI.writeBytes(buf, n);
|
||||
}
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
epd_refresh();
|
||||
}
|
||||
|
||||
void epd_draw_qr(QRCode* qr, uint8_t cellPx, uint8_t bg, uint8_t fg) {
|
||||
int qrPx = qr->size * cellPx;
|
||||
int offX = (EPD_WIDTH - qrPx) / 2;
|
||||
int offY = (EPD_HEIGHT - qrPx) / 2;
|
||||
|
||||
cmd(0x10);
|
||||
for (int y = 0; y < EPD_HEIGHT; y++) {
|
||||
for (int x = 0; x < EPD_WIDTH; x += 2) {
|
||||
auto nibble = [&](int px) -> uint8_t {
|
||||
int qx = (px - offX) / cellPx, qy = (y - offY) / cellPx;
|
||||
if (qx >= 0 && qx < qr->size && qy >= 0 && qy < qr->size)
|
||||
return qrcode_getModule(qr, qx, qy) ? fg : bg;
|
||||
return bg;
|
||||
};
|
||||
s_row[x/2] = (nibble(x) << 4) | nibble(x+1);
|
||||
}
|
||||
digitalWrite(PIN_DC, HIGH); digitalWrite(PIN_CS, LOW);
|
||||
SPI.writeBytes(s_row, sizeof(s_row));
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
}
|
||||
epd_refresh();
|
||||
}
|
||||
|
||||
// Stream background from LittleFS, overlaying QR at (qr_x, qr_y) with given cell size.
|
||||
// Falls back to a solid fill if the file is missing.
|
||||
static void draw_from_lfs(const char* path, uint8_t fallback_color,
|
||||
QRCode* qr, int qr_x, int qr_y, int qr_cell) {
|
||||
File f = LittleFS.open(path, "r");
|
||||
if (!f) { epd_fill(fallback_color); return; }
|
||||
|
||||
int qr_px = qr->size * qr_cell;
|
||||
|
||||
cmd(0x10);
|
||||
for (int y = 0; y < EPD_HEIGHT; y++) {
|
||||
f.read(s_row, sizeof(s_row));
|
||||
|
||||
if (y >= qr_y && y < qr_y + qr_px) {
|
||||
int qy = (y - qr_y) / qr_cell;
|
||||
int x0 = max(qr_x, 0), x1 = min(qr_x + qr_px, EPD_WIDTH);
|
||||
for (int x = x0; x < x1; x++) {
|
||||
uint8_t c = qrcode_getModule(qr, (x - qr_x) / qr_cell, qy)
|
||||
? COLOR_BLACK : COLOR_WHITE;
|
||||
if (x & 1) s_row[x/2] = (s_row[x/2] & 0xF0) | c;
|
||||
else s_row[x/2] = (s_row[x/2] & 0x0F) | (c << 4);
|
||||
}
|
||||
}
|
||||
|
||||
digitalWrite(PIN_DC, HIGH); digitalWrite(PIN_CS, LOW);
|
||||
SPI.writeBytes(s_row, sizeof(s_row));
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
}
|
||||
f.close();
|
||||
epd_refresh();
|
||||
}
|
||||
|
||||
void epd_draw_ap_screen(QRCode* qr) {
|
||||
// AP_QR_X=563, AP_QR_Y=185, AP_QR_CELL=5 (must match gen_screens.py)
|
||||
draw_from_lfs("/ap_bg.bin", COLOR_YELLOW, qr, 563, 185, 5);
|
||||
}
|
||||
|
||||
void epd_draw_setup_screen(QRCode* qr) {
|
||||
// SETUP_QR_X=553, SETUP_QR_Y=175, SETUP_QR_CELL=5 (must match gen_screens.py)
|
||||
draw_from_lfs("/setup_bg.bin", COLOR_GREEN, qr, 553, 175, 5);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <SPI.h>
|
||||
#include <FS.h>
|
||||
|
||||
void epd_init();
|
||||
void epd_sleep();
|
||||
void epd_fill(uint8_t color);
|
||||
void epd_draw_image_from_file(fs::File& f);
|
||||
|
||||
// Draw a QR code centred on the display.
|
||||
// bg/fg are EPD color nibbles (COLOR_WHITE / COLOR_BLACK).
|
||||
struct QRCode;
|
||||
void epd_draw_qr(QRCode* qr, uint8_t cellPx, uint8_t bg, uint8_t fg);
|
||||
|
||||
// Draw the setup screen: pre-rendered background from LittleFS with QR overlaid.
|
||||
void epd_draw_ap_screen(QRCode* qr);
|
||||
void epd_draw_setup_screen(QRCode* qr);
|
||||
@@ -0,0 +1,272 @@
|
||||
#include <Arduino.h>
|
||||
#include <SPI.h>
|
||||
#include <WiFi.h>
|
||||
#include <WebServer.h>
|
||||
#include <DNSServer.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
#include <Preferences.h>
|
||||
#include <LittleFS.h>
|
||||
#include <qrcode.h>
|
||||
#include "config.h"
|
||||
#include "epd.h"
|
||||
#include "operation.h"
|
||||
|
||||
// ── Globals ──────────────────────────────────────────────────────────────────
|
||||
|
||||
static WebServer server(80);
|
||||
static DNSServer dns;
|
||||
static Preferences prefs;
|
||||
|
||||
static bool g_provisioning = false;
|
||||
static bool g_connect_req = false;
|
||||
static String g_req_ssid;
|
||||
static String g_req_pass;
|
||||
|
||||
// ── QR helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
static void show_ap_qr(const String& apSsid) {
|
||||
String content = "WIFI:S:" + apSsid + ";T:nopass;;";
|
||||
|
||||
QRCode qr;
|
||||
uint8_t buf[qrcode_getBufferSize(5)];
|
||||
qrcode_initText(&qr, buf, 5, ECC_LOW, content.c_str());
|
||||
epd_draw_ap_screen(&qr);
|
||||
}
|
||||
|
||||
static void show_setup_qr(const String& mac) {
|
||||
String url = String(APP_BASE_URL) + "/setup/" + mac;
|
||||
Serial.println("show_setup_qr: " + url);
|
||||
|
||||
QRCode qr;
|
||||
uint8_t buf[qrcode_getBufferSize(6)];
|
||||
qrcode_initText(&qr, buf, 6, ECC_LOW, url.c_str());
|
||||
Serial.println("QR size: " + String(qr.size));
|
||||
epd_draw_setup_screen(&qr);
|
||||
Serial.println("epd_draw_setup_screen done");
|
||||
}
|
||||
|
||||
// ── Captive portal HTML ───────────────────────────────────────────────────────
|
||||
|
||||
static const char PORTAL_HTML[] PROGMEM = R"html(
|
||||
<!DOCTYPE html><html lang="en">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>pictureFrame Setup</title>
|
||||
<style>
|
||||
body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;
|
||||
min-height:100vh;margin:0;background:#fdf6ee;color:#3a2e22}
|
||||
.card{width:100%;max-width:340px;margin:1rem;padding:1.5rem;background:#fff9f2;
|
||||
border:1px solid #e8d9c4;border-radius:16px}
|
||||
h1{font-size:1.25rem;margin:0 0 1.25rem}
|
||||
label{display:block;font-size:.8125rem;font-weight:600;color:#8a7060;margin-bottom:.3rem}
|
||||
input{width:100%;min-height:44px;padding:0 .875rem;border:1px solid #e8d9c4;
|
||||
border-radius:10px;background:#fff;font-size:1rem;color:#3a2e22;box-sizing:border-box;margin-bottom:1rem}
|
||||
button{width:100%;min-height:44px;background:#c97c3a;color:#fff;border:none;
|
||||
border-radius:9999px;font-size:1rem;font-weight:700;cursor:pointer}
|
||||
p{font-size:.875rem;color:#8a7060;margin-top:1rem;text-align:center}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Connect to WiFi</h1>
|
||||
<form method="POST" action="/connect">
|
||||
<label for="s">WiFi network name</label>
|
||||
<input id="s" name="ssid" type="text" autocomplete="off" placeholder="e.g. HomeNetwork">
|
||||
<label for="p">Password</label>
|
||||
<input id="p" name="pass" type="password" autocomplete="off">
|
||||
<button type="submit">Connect</button>
|
||||
</form>
|
||||
<p>pictureFrame will join your network and display a setup QR code.</p>
|
||||
</div>
|
||||
</body></html>
|
||||
)html";
|
||||
|
||||
static const char CONNECTING_HTML[] PROGMEM = R"html(
|
||||
<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<title>Connecting…</title>
|
||||
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;
|
||||
min-height:100vh;margin:0;background:#fdf6ee;color:#3a2e22;text-align:center}
|
||||
.card{padding:2rem;max-width:300px}</style></head>
|
||||
<body><div class="card"><h2>Connecting…</h2>
|
||||
<p>The frame is joining your network.<br>Watch the display for a setup QR code.</p></div></body></html>
|
||||
)html";
|
||||
|
||||
// ── Web server handlers ───────────────────────────────────────────────────────
|
||||
|
||||
static void handle_root() {
|
||||
server.send_P(200, "text/html", PORTAL_HTML);
|
||||
}
|
||||
|
||||
static void handle_connect() {
|
||||
if (!server.hasArg("ssid") || server.arg("ssid").isEmpty()) {
|
||||
server.send(400, "text/plain", "Missing ssid");
|
||||
return;
|
||||
}
|
||||
g_req_ssid = server.arg("ssid");
|
||||
g_req_pass = server.arg("pass");
|
||||
g_connect_req = true;
|
||||
server.send_P(200, "text/html", CONNECTING_HTML);
|
||||
}
|
||||
|
||||
// Captive portal detection endpoints — all redirect to portal
|
||||
static void handle_captive() {
|
||||
server.sendHeader("Location", "http://" AP_IP "/");
|
||||
server.send(302, "text/plain", "");
|
||||
}
|
||||
|
||||
// ── WiFi provisioning ─────────────────────────────────────────────────────────
|
||||
|
||||
static void enter_provisioning(const String& mac) {
|
||||
// Derive AP name from last 4 hex chars of MAC (no colons)
|
||||
String suffix = mac;
|
||||
suffix.replace(":", "");
|
||||
suffix = suffix.substring(suffix.length() - 4);
|
||||
suffix.toUpperCase();
|
||||
String apSsid = "PictureFrame-" + suffix;
|
||||
|
||||
Serial.println("AP: " + apSsid);
|
||||
|
||||
WiFi.disconnect(true);
|
||||
WiFi.mode(WIFI_AP);
|
||||
WiFi.softAP(apSsid.c_str());
|
||||
delay(500);
|
||||
|
||||
// Redirect all DNS to this device
|
||||
dns.setErrorReplyCode(DNSReplyCode::NoError);
|
||||
dns.start(53, "*", WiFi.softAPIP());
|
||||
|
||||
server.on("/", HTTP_GET, handle_root);
|
||||
server.on("/connect", HTTP_POST, handle_connect);
|
||||
server.on("/generate_204", handle_captive);
|
||||
server.on("/hotspot-detect.html", handle_captive);
|
||||
server.on("/ncsi.txt", handle_captive);
|
||||
server.onNotFound(handle_root);
|
||||
server.begin();
|
||||
|
||||
epd_init();
|
||||
show_ap_qr(apSsid);
|
||||
epd_sleep();
|
||||
|
||||
g_provisioning = true;
|
||||
}
|
||||
|
||||
static bool attempt_wifi(const String& ssid, const String& pass) {
|
||||
return ::attempt_wifi(ssid.c_str(), pass.c_str());
|
||||
}
|
||||
|
||||
// ── Normal operation ──────────────────────────────────────────────────────────
|
||||
|
||||
static void normal_operation(const String& mac) {
|
||||
String url = String(APP_BASE_URL) + "/api/device/" + mac + "/image";
|
||||
|
||||
WiFiClientSecure client;
|
||||
client.setInsecure(); // V1: no cert pinning for personal-scale device
|
||||
|
||||
HTTPClient http;
|
||||
http.begin(client, url);
|
||||
|
||||
normal_operation_impl(mac, http, url, prefs);
|
||||
}
|
||||
|
||||
// ── Setup ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
Serial.println("pictureFrame boot");
|
||||
|
||||
// Init GPIO
|
||||
pinMode(PIN_CS, OUTPUT);
|
||||
pinMode(PIN_DC, OUTPUT);
|
||||
pinMode(PIN_RST, OUTPUT);
|
||||
pinMode(PIN_BUSY, INPUT);
|
||||
pinMode(PIN_BTN_RESET, INPUT_PULLUP);
|
||||
|
||||
SPI.begin(PIN_SCK, -1, PIN_MOSI, PIN_CS);
|
||||
SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0));
|
||||
|
||||
LittleFS.begin(true); // format on first use
|
||||
|
||||
// Check reset button: if held at boot, wipe credentials
|
||||
bool clear_creds = check_reset_button();
|
||||
|
||||
prefs.begin(NVS_NAMESPACE, false);
|
||||
if (clear_creds) {
|
||||
prefs.clear();
|
||||
Serial.println("Credentials cleared — entering provisioning");
|
||||
}
|
||||
|
||||
String mac = WiFi.macAddress();
|
||||
String ssid = prefs.getString(NVS_KEY_SSID, "");
|
||||
String pass = prefs.getString(NVS_KEY_PASS, "");
|
||||
prefs.end();
|
||||
|
||||
if (ssid.isEmpty()) {
|
||||
enter_provisioning(mac);
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to join saved network
|
||||
Serial.println("[wifi] connecting to ssid=" + ssid);
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(ssid.c_str(), pass.c_str());
|
||||
uint32_t start = millis();
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
if (millis() - start > WIFI_TIMEOUT_MS) break;
|
||||
delay(200);
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.println("[wifi] connected ip=" + WiFi.localIP().toString());
|
||||
normal_operation(mac);
|
||||
// normal_operation calls deep_sleep — never returns
|
||||
} else {
|
||||
Serial.println("[wifi] failed after " + String(WIFI_TIMEOUT_MS) + " ms — entering provisioning");
|
||||
enter_provisioning(mac);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loop (provisioning mode only) ────────────────────────────────────────────
|
||||
|
||||
void loop() {
|
||||
if (!g_provisioning) return;
|
||||
|
||||
dns.processNextRequest();
|
||||
server.handleClient();
|
||||
|
||||
if (!g_connect_req) return;
|
||||
g_connect_req = false;
|
||||
|
||||
dns.stop();
|
||||
server.stop();
|
||||
|
||||
String mac = WiFi.macAddress();
|
||||
bool ok = attempt_wifi(g_req_ssid, g_req_pass);
|
||||
|
||||
if (ok) {
|
||||
// Save credentials for future boots
|
||||
prefs.begin(NVS_NAMESPACE, false);
|
||||
prefs.putString(NVS_KEY_SSID, g_req_ssid);
|
||||
prefs.putString(NVS_KEY_PASS, g_req_pass);
|
||||
prefs.end();
|
||||
|
||||
// Show Phase 2 QR and transition to polling loop
|
||||
epd_init();
|
||||
show_setup_qr(mac);
|
||||
epd_sleep();
|
||||
|
||||
g_provisioning = false;
|
||||
|
||||
// Give user time to scan the QR, then start normal operation
|
||||
delay(120000); // 2 minutes
|
||||
normal_operation(mac);
|
||||
} else {
|
||||
// Connection failed — fill red, restart AP
|
||||
epd_init();
|
||||
epd_fill(COLOR_RED);
|
||||
epd_sleep();
|
||||
delay(3000);
|
||||
|
||||
// Re-enter provisioning to retry
|
||||
enter_provisioning(mac);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <Preferences.h>
|
||||
#include <LittleFS.h>
|
||||
#include "config.h"
|
||||
|
||||
#ifdef UNIT_TEST
|
||||
// In unit tests, use mock stubs for hardware-dependent headers.
|
||||
// The test build adds test/mocks to the include path via -iquote.
|
||||
#include "epd_mock.h"
|
||||
#include "esp_sleep.h"
|
||||
#else
|
||||
#include "epd.h"
|
||||
#include <esp_sleep.h>
|
||||
#endif
|
||||
|
||||
#ifndef UNIT_TEST
|
||||
// Defined in main.cpp
|
||||
static void show_setup_qr(const String& mac);
|
||||
#else
|
||||
// Stub for native tests — tracks call count
|
||||
extern int g_show_setup_qr_count;
|
||||
inline void show_setup_qr(const String& mac) { g_show_setup_qr_count++; }
|
||||
#endif
|
||||
|
||||
// ── Utility: derive AP SSID from MAC ─────────────────────────────────────────
|
||||
// Strips colons, uppercases, takes the last 4 chars.
|
||||
// Builds via std::string so single-char append is unambiguous on all targets.
|
||||
inline String ap_ssid_from_mac(const String& mac) {
|
||||
std::string cleaned;
|
||||
const char* p = mac.c_str();
|
||||
while (*p) {
|
||||
if (*p != ':') cleaned += (char)toupper((unsigned char)*p);
|
||||
++p;
|
||||
}
|
||||
std::string suffix = cleaned.substr(cleaned.size() - 4);
|
||||
return String(("PictureFrame-" + suffix).c_str());
|
||||
}
|
||||
|
||||
// ── WiFi connection attempt ───────────────────────────────────────────────────
|
||||
inline bool attempt_wifi(const char* ssid, const char* pass) {
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(ssid, pass);
|
||||
uint32_t start = millis();
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
if (millis() - start > WIFI_TIMEOUT_MS) return false;
|
||||
delay(200);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Reset button hold detection ───────────────────────────────────────────────
|
||||
inline bool check_reset_button() {
|
||||
uint32_t hold_start = millis();
|
||||
while (digitalRead(PIN_BTN_RESET) == LOW) {
|
||||
if (millis() - hold_start >= RESET_HOLD_MS) {
|
||||
return true;
|
||||
}
|
||||
delay(50);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
template<typename HTTP>
|
||||
void normal_operation_impl(const String& mac, HTTP& http, const String& url, Preferences& prefs) {
|
||||
prefs.begin(NVS_NAMESPACE, true);
|
||||
int32_t currentImgId = prefs.getInt(NVS_KEY_IMG_ID, -1);
|
||||
bool drawNeeded = prefs.getInt(NVS_KEY_DRAW_NEEDED, 0) != 0;
|
||||
prefs.end();
|
||||
|
||||
if (currentImgId >= 0) {
|
||||
http.addHeader("X-Current-Image-Id", String(currentImgId));
|
||||
}
|
||||
|
||||
const char* collectHeaders[] = { "X-Interval-Ms", "X-Image-Id" };
|
||||
http.collectHeaders(collectHeaders, 2);
|
||||
int code = http.GET();
|
||||
|
||||
uint64_t sleepMs = FETCH_INTERVAL_MS;
|
||||
String intervalHdr = http.header("X-Interval-Ms");
|
||||
if (intervalHdr.length() > 0) {
|
||||
uint64_t v = strtoull(intervalHdr.c_str(), nullptr, 10);
|
||||
if (v > 0) sleepMs = std::min<uint64_t>(v, (uint64_t)FETCH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
bool displayInitialized = false;
|
||||
|
||||
if (code == 200) {
|
||||
String newId = http.header("X-Image-Id");
|
||||
|
||||
File f = LittleFS.open(IMAGE_PATH, "w", true);
|
||||
if (f) { http.writeToStream(&f); f.close(); }
|
||||
http.end();
|
||||
|
||||
// Persist ID and set draw_needed before touching the display.
|
||||
// If the device loses power during the ~20 s refresh, the flag survives
|
||||
// in NVS so the next boot re-draws from LittleFS instead of looping on 200.
|
||||
if (newId.length() > 0) {
|
||||
prefs.begin(NVS_NAMESPACE, false);
|
||||
prefs.putInt(NVS_KEY_DRAW_NEEDED, 1);
|
||||
prefs.putInt(NVS_KEY_IMG_ID, newId.toInt());
|
||||
prefs.end();
|
||||
}
|
||||
|
||||
displayInitialized = true;
|
||||
epd_init();
|
||||
File r = LittleFS.open(IMAGE_PATH, "r");
|
||||
if (r) { epd_draw_image_from_file(r); r.close(); }
|
||||
|
||||
// Draw complete — clear the pending flag.
|
||||
prefs.begin(NVS_NAMESPACE, false);
|
||||
prefs.putInt(NVS_KEY_DRAW_NEEDED, 0);
|
||||
prefs.end();
|
||||
|
||||
} else if (code == 304) {
|
||||
http.end();
|
||||
// If a previous draw was interrupted (power loss mid-refresh), the image
|
||||
// file is in LittleFS and the ID is correct in NVS — just re-draw it.
|
||||
if (drawNeeded) {
|
||||
File r = LittleFS.open(IMAGE_PATH, "r");
|
||||
if (r) {
|
||||
displayInitialized = true;
|
||||
epd_init();
|
||||
epd_draw_image_from_file(r);
|
||||
r.close();
|
||||
prefs.begin(NVS_NAMESPACE, false);
|
||||
prefs.putInt(NVS_KEY_DRAW_NEEDED, 0);
|
||||
prefs.end();
|
||||
}
|
||||
}
|
||||
} else if (code == 204) {
|
||||
http.end();
|
||||
displayInitialized = true;
|
||||
epd_init();
|
||||
show_setup_qr(mac);
|
||||
} else if (code == 404) {
|
||||
http.end();
|
||||
displayInitialized = true;
|
||||
epd_init();
|
||||
show_setup_qr(mac);
|
||||
} else {
|
||||
http.end();
|
||||
displayInitialized = true;
|
||||
epd_init();
|
||||
epd_fill(COLOR_YELLOW);
|
||||
}
|
||||
|
||||
// Only power off the display if it was initialized this cycle. Calling
|
||||
// epd_sleep() when the display is already in hardware deep sleep (from the
|
||||
// previous cycle) causes wait_busy() to time out at 60 s, wasting the
|
||||
// entire poll interval on every 304 response.
|
||||
if (displayInitialized) epd_sleep();
|
||||
|
||||
esp_sleep_enable_timer_wakeup(sleepMs * 1000ULL);
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
#ifdef ENV_TEST_DISPLAY
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SPI.h>
|
||||
#include <LittleFS.h>
|
||||
#include "config.h"
|
||||
#include "epd.h"
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
Serial.println("display test boot");
|
||||
|
||||
pinMode(PIN_CS, OUTPUT);
|
||||
pinMode(PIN_DC, OUTPUT);
|
||||
pinMode(PIN_RST, OUTPUT);
|
||||
pinMode(PIN_BUSY, INPUT);
|
||||
SPI.begin(PIN_SCK, -1, PIN_MOSI, PIN_CS);
|
||||
SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0));
|
||||
|
||||
LittleFS.begin(true);
|
||||
|
||||
File f = LittleFS.open("/img.bin", "r");
|
||||
if (!f) {
|
||||
Serial.println("ERROR: /img.bin not found — did you run uploadfs?");
|
||||
return;
|
||||
}
|
||||
Serial.printf("/img.bin: %u bytes\n", f.size());
|
||||
|
||||
epd_init();
|
||||
epd_draw_image_from_file(f);
|
||||
f.close();
|
||||
epd_sleep();
|
||||
|
||||
Serial.println("done");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
delay(60000);
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,98 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <cstring>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <algorithm>
|
||||
#include <sstream>
|
||||
#include <cctype>
|
||||
|
||||
// Minimal String class that mimics Arduino's String for native tests
|
||||
struct String {
|
||||
std::string _s;
|
||||
|
||||
String() {}
|
||||
String(const char* s) : _s(s ? s : "") {}
|
||||
String(const std::string& s) : _s(s) {}
|
||||
String(int v) { _s = std::to_string(v); }
|
||||
String(unsigned long v) { _s = std::to_string(v); }
|
||||
String(long long v) { _s = std::to_string(v); }
|
||||
String(unsigned long long v) { _s = std::to_string(v); }
|
||||
|
||||
const char* c_str() const { return _s.c_str(); }
|
||||
size_t length() const { return _s.size(); }
|
||||
bool isEmpty() const { return _s.empty(); }
|
||||
bool empty() const { return _s.empty(); }
|
||||
|
||||
int toInt() const { return _s.empty() ? 0 : std::stoi(_s); }
|
||||
|
||||
String substring(size_t from) const { return String(_s.substr(from)); }
|
||||
String substring(size_t from, size_t to) const { return String(_s.substr(from, to - from)); }
|
||||
|
||||
void replace(const char* from, const char* to_str) {
|
||||
std::string result;
|
||||
std::string f(from), t(to_str);
|
||||
size_t pos = 0, found;
|
||||
while ((found = _s.find(f, pos)) != std::string::npos) {
|
||||
result += _s.substr(pos, found - pos);
|
||||
result += t;
|
||||
pos = found + f.size();
|
||||
}
|
||||
result += _s.substr(pos);
|
||||
_s = result;
|
||||
}
|
||||
|
||||
void toUpperCase() {
|
||||
for (char& c : _s) c = (char)toupper((unsigned char)c);
|
||||
}
|
||||
|
||||
bool operator==(const String& o) const { return _s == o._s; }
|
||||
bool operator==(const char* o) const { return _s == o; }
|
||||
bool operator!=(const String& o) const { return _s != o._s; }
|
||||
bool operator!=(const char* o) const { return _s != o; }
|
||||
|
||||
String operator+(const String& o) const { return String(_s + o._s); }
|
||||
String operator+(const char* o) const { return String(_s + o); }
|
||||
String& operator+=(const String& o) { _s += o._s; return *this; }
|
||||
String& operator+=(const char* o) { _s += o; return *this; }
|
||||
|
||||
// Allow use as map key
|
||||
operator std::string() const { return _s; }
|
||||
|
||||
// toString() for IP-like objects that have it
|
||||
String toString() const { return *this; }
|
||||
};
|
||||
|
||||
inline String operator+(const char* a, const String& b) { return String(std::string(a) + b._s); }
|
||||
inline String operator+(const std::string& a, const String& b) { return String(a + b._s); }
|
||||
|
||||
// Controllable millis and digitalRead for timeout / button tests
|
||||
extern uint32_t g_millis_value;
|
||||
extern int g_digital_read_value;
|
||||
|
||||
#ifndef LOW
|
||||
#define LOW 0
|
||||
#define HIGH 1
|
||||
#endif
|
||||
|
||||
inline unsigned long millis() { return g_millis_value += 10; }
|
||||
inline void delay(unsigned long) {}
|
||||
inline void pinMode(int, int) {}
|
||||
inline int digitalRead(int) { return g_digital_read_value; }
|
||||
|
||||
// Color constants (from config.h)
|
||||
#define COLOR_YELLOW 0x2
|
||||
#define COLOR_RED 0x3
|
||||
|
||||
// Serial mock
|
||||
struct SerialMock {
|
||||
void begin(int) {}
|
||||
void println(const String&) {}
|
||||
void println(const char*) {}
|
||||
void println(int) {}
|
||||
void print(const String&) {}
|
||||
void print(const char*) {}
|
||||
void flush() {}
|
||||
} Serial;
|
||||
|
||||
// strtoull is available from <cstdlib> on native
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
enum class DNSReplyCode { NoError };
|
||||
struct DNSServer {
|
||||
void setErrorReplyCode(DNSReplyCode) {}
|
||||
void start(uint16_t, const char*, const std::string&) {}
|
||||
void processNextRequest() {}
|
||||
void stop() {}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
// Minimal FS.h stub for native unit tests
|
||||
// Provides the fs::File type that Arduino's FS library normally defines.
|
||||
|
||||
#include "Arduino.h"
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
|
||||
namespace fs {
|
||||
|
||||
struct File {
|
||||
std::string* _buf = nullptr;
|
||||
bool _valid = false;
|
||||
bool _write = false;
|
||||
size_t _pos = 0;
|
||||
|
||||
explicit operator bool() const { return _valid; }
|
||||
void close() { _valid = false; }
|
||||
size_t write(const uint8_t* data, size_t len) {
|
||||
if (_buf && _write) { _buf->append((const char*)data, len); return len; }
|
||||
return 0;
|
||||
}
|
||||
int read() {
|
||||
if (_buf && _pos < _buf->size()) return (uint8_t)(*_buf)[_pos++];
|
||||
return -1;
|
||||
}
|
||||
size_t size() { return _buf ? _buf->size() : 0; }
|
||||
};
|
||||
|
||||
} // namespace fs
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
#include "Arduino.h"
|
||||
#include "LittleFS.h"
|
||||
#include "WiFiClientSecure.h"
|
||||
#include <map>
|
||||
|
||||
// Global test state for inspecting behavior
|
||||
extern int g_http_get_code;
|
||||
extern std::map<std::string, std::string> g_http_response_headers;
|
||||
extern std::map<std::string, std::string> g_http_request_headers;
|
||||
extern bool g_http_end_called;
|
||||
extern std::string g_http_body;
|
||||
|
||||
struct MockHTTPClient {
|
||||
bool _ended = false;
|
||||
|
||||
void begin(WiFiClientSecure&, const String& url) {}
|
||||
|
||||
void addHeader(const char* name, const String& value) {
|
||||
g_http_request_headers[name] = value._s;
|
||||
}
|
||||
|
||||
void collectHeaders(const char** headers, int count) {}
|
||||
|
||||
int GET() {
|
||||
_ended = false;
|
||||
return g_http_get_code;
|
||||
}
|
||||
|
||||
String header(const char* name) {
|
||||
if (_ended) return String("");
|
||||
auto it = g_http_response_headers.find(name);
|
||||
return it != g_http_response_headers.end() ? String(it->second) : String("");
|
||||
}
|
||||
|
||||
size_t writeToStream(File* f) {
|
||||
if (f && *f) {
|
||||
f->write((const uint8_t*)g_http_body.data(), g_http_body.size());
|
||||
}
|
||||
return g_http_body.size();
|
||||
}
|
||||
|
||||
void end() {
|
||||
_ended = true;
|
||||
g_http_end_called = true;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
#include "Arduino.h"
|
||||
#include <map>
|
||||
#include <vector>
|
||||
|
||||
struct File {
|
||||
std::string* _buf = nullptr;
|
||||
bool _valid = false;
|
||||
bool _write = false;
|
||||
size_t _pos = 0;
|
||||
|
||||
explicit operator bool() const { return _valid; }
|
||||
void close() { _valid = false; }
|
||||
size_t write(const uint8_t* data, size_t len) {
|
||||
if (_buf && _write) { _buf->append((const char*)data, len); return len; }
|
||||
return 0;
|
||||
}
|
||||
int read() {
|
||||
if (_buf && _pos < _buf->size()) return (uint8_t)(*_buf)[_pos++];
|
||||
return -1;
|
||||
}
|
||||
size_t size() { return _buf ? _buf->size() : 0; }
|
||||
};
|
||||
|
||||
struct LittleFSClass {
|
||||
std::map<std::string, std::string> files;
|
||||
|
||||
bool begin(bool) { return true; }
|
||||
|
||||
File open(const char* path, const char* mode, bool create = false) {
|
||||
File f;
|
||||
f._valid = true;
|
||||
f._write = (mode[0] == 'w');
|
||||
f._buf = &files[path];
|
||||
if (f._write) f._buf->clear();
|
||||
f._pos = 0;
|
||||
return f;
|
||||
}
|
||||
} LittleFS;
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
#include "Arduino.h"
|
||||
#include <map>
|
||||
|
||||
// Shared sequence counter — incremented by each instrumented mock call
|
||||
extern int g_call_seq;
|
||||
extern int g_prefs_putint_seq; // sequence position of last putInt call
|
||||
|
||||
struct Preferences {
|
||||
std::map<std::string, int32_t> ints;
|
||||
std::map<std::string, std::string> strings;
|
||||
bool _open = false;
|
||||
|
||||
void begin(const char*, bool) { _open = true; }
|
||||
void end() { _open = false; }
|
||||
|
||||
int32_t getInt(const char* key, int32_t def = 0) {
|
||||
auto it = ints.find(key);
|
||||
return it != ints.end() ? it->second : def;
|
||||
}
|
||||
void putInt(const char* key, int32_t val) {
|
||||
ints[key] = val;
|
||||
// Record the sequence of the FIRST putInt call (ordering test uses this
|
||||
// to verify NVS is written before epd_draw_image_from_file).
|
||||
if (g_prefs_putint_seq < 0) g_prefs_putint_seq = g_call_seq;
|
||||
g_call_seq++;
|
||||
}
|
||||
|
||||
String getString(const char* key, const char* def = "") {
|
||||
auto it = strings.find(key);
|
||||
return it != strings.end() ? String(it->second) : String(def);
|
||||
}
|
||||
void putString(const char* key, const String& val) { strings[key] = val._s; }
|
||||
void clear() { ints.clear(); strings.clear(); }
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
struct SPISettings { SPISettings(uint32_t, uint8_t, uint8_t) {} };
|
||||
struct SPIClass {
|
||||
void begin(int,int,int,int) {}
|
||||
void beginTransaction(SPISettings) {}
|
||||
} SPI;
|
||||
#define MSBFIRST 1
|
||||
#define SPI_MODE0 0
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
struct WebServer {
|
||||
WebServer(int) {}
|
||||
void on(const char*, void(*)()) {}
|
||||
void on(const char*, int, void(*)()) {}
|
||||
void onNotFound(void(*)()) {}
|
||||
void begin() {}
|
||||
void handleClient() {}
|
||||
void stop() {}
|
||||
bool hasArg(const char*) { return false; }
|
||||
std::string arg(const char*) { return ""; }
|
||||
void send(int, const char*, const char*) {}
|
||||
void send_P(int, const char*, const char*) {}
|
||||
void sendHeader(const char*, const char*) {}
|
||||
};
|
||||
#define HTTP_GET 0
|
||||
#define HTTP_POST 1
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
#include "Arduino.h"
|
||||
#define WIFI_STA 0
|
||||
#define WIFI_AP 1
|
||||
#define WL_CONNECTED 3
|
||||
|
||||
extern int g_wifi_status;
|
||||
|
||||
struct WiFiClass {
|
||||
String macAddress() { return String("1C:C3:AB:D1:91:F8"); }
|
||||
int status() { return g_wifi_status; }
|
||||
void mode(int) {}
|
||||
void begin(const char*, const char*) {}
|
||||
void disconnect(bool) {}
|
||||
bool softAP(const char*) { return true; }
|
||||
String softAPIP() { return String("192.168.4.1"); }
|
||||
String localIP() { return String("192.168.1.100"); }
|
||||
} WiFi;
|
||||
@@ -0,0 +1,4 @@
|
||||
#pragma once
|
||||
struct WiFiClientSecure {
|
||||
void setInsecure() {}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
// Mirror of src/config.h for use in native unit tests.
|
||||
// Values must match src/config.h so test assertions stay consistent.
|
||||
#define APP_BASE_URL "https://pictureframe.edholm.me"
|
||||
#define NVS_NAMESPACE "pf"
|
||||
#define NVS_KEY_SSID "ssid"
|
||||
#define NVS_KEY_PASS "pass"
|
||||
#define NVS_KEY_IMG_ID "img_id"
|
||||
#define NVS_KEY_DRAW_NEEDED "draw"
|
||||
#define IMAGE_PATH "/img.bin"
|
||||
#define FETCH_INTERVAL_MS 60000ULL
|
||||
#define WIFI_TIMEOUT_MS 30000
|
||||
#define RESET_HOLD_MS 5000
|
||||
#define AP_IP "192.168.4.1"
|
||||
#define PIN_CS 5
|
||||
#define PIN_DC 17
|
||||
#define PIN_RST 16
|
||||
#define PIN_BUSY 4
|
||||
#define PIN_SCK 18
|
||||
#define PIN_MOSI 23
|
||||
#define PIN_BTN_RESET 0
|
||||
// Color constants (also defined in Arduino mock, repeated here for clarity)
|
||||
#define COLOR_BLACK 0x0
|
||||
#define COLOR_WHITE 0x1
|
||||
#define COLOR_YELLOW 0x2
|
||||
#define COLOR_RED 0x3
|
||||
#define COLOR_BLUE 0x5
|
||||
#define COLOR_GREEN 0x6
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include "Arduino.h"
|
||||
|
||||
// Call counters for assertions
|
||||
extern int g_epd_init_count;
|
||||
extern int g_epd_sleep_count;
|
||||
extern int g_epd_draw_image_count;
|
||||
extern int g_epd_fill_count;
|
||||
extern int g_epd_fill_last_color;
|
||||
extern int g_epd_draw_setup_count;
|
||||
|
||||
inline void epd_init() { g_epd_init_count++; }
|
||||
inline void epd_sleep() { g_epd_sleep_count++; }
|
||||
inline void epd_draw_image_from_file(File& f) { g_epd_draw_image_count++; }
|
||||
inline void epd_fill(int color) { g_epd_fill_count++; g_epd_fill_last_color = color; }
|
||||
inline void epd_draw_ap_screen(void*) {}
|
||||
inline void epd_draw_setup_screen(void*) { g_epd_draw_setup_count++; }
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
#include "Arduino.h"
|
||||
|
||||
// Shared sequence counter — incremented by each instrumented mock call
|
||||
extern int g_call_seq;
|
||||
extern int g_epd_draw_seq; // sequence position of last epd_draw_image_from_file call
|
||||
|
||||
// Call counters for assertions
|
||||
extern int g_epd_init_count;
|
||||
extern int g_epd_sleep_count;
|
||||
extern int g_epd_draw_image_count;
|
||||
extern int g_epd_fill_count;
|
||||
extern int g_epd_fill_last_color;
|
||||
extern int g_epd_draw_setup_count;
|
||||
|
||||
inline void epd_init() { g_epd_init_count++; }
|
||||
inline void epd_sleep() { g_epd_sleep_count++; }
|
||||
inline void epd_draw_image_from_file(File& f) {
|
||||
g_epd_draw_image_count++;
|
||||
if (g_epd_draw_seq < 0) g_epd_draw_seq = g_call_seq;
|
||||
g_call_seq++;
|
||||
}
|
||||
inline void epd_fill(int color) { g_epd_fill_count++; g_epd_fill_last_color = color; }
|
||||
inline void epd_draw_ap_screen(void*) {}
|
||||
inline void epd_draw_setup_screen(void*) { g_epd_draw_setup_count++; }
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
extern uint64_t g_sleep_us;
|
||||
extern bool g_deep_sleep_started;
|
||||
|
||||
inline void esp_sleep_enable_timer_wakeup(uint64_t us) { g_sleep_us = us; }
|
||||
inline void esp_deep_sleep_start() { g_deep_sleep_started = true; }
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
struct QRCode { int size; };
|
||||
inline size_t qrcode_getBufferSize(int) { return 128; }
|
||||
inline void qrcode_initText(QRCode* qr, uint8_t*, int, int, const char*) { qr->size = 21; }
|
||||
#define ECC_LOW 0
|
||||
@@ -0,0 +1,248 @@
|
||||
#include <unity.h>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
#include <cctype>
|
||||
|
||||
// Include mocks first — they shadow system/Arduino headers.
|
||||
// -iquote test/mocks ensures quoted includes from test_main find mocks first.
|
||||
// operation.h uses #ifdef UNIT_TEST to pick epd_mock.h and esp_sleep.h.
|
||||
#include "Arduino.h"
|
||||
#include "WiFi.h"
|
||||
#include "WiFiClientSecure.h"
|
||||
#include "Preferences.h"
|
||||
#include "LittleFS.h"
|
||||
#include "epd_mock.h"
|
||||
#include "esp_sleep.h"
|
||||
#include "HTTPClient.h"
|
||||
#include "SPI.h"
|
||||
#include "WebServer.h"
|
||||
#include "DNSServer.h"
|
||||
#include "qrcode.h"
|
||||
#include "config.h"
|
||||
|
||||
// Define globals referenced as extern in the mock headers
|
||||
int g_http_get_code;
|
||||
std::map<std::string, std::string> g_http_response_headers;
|
||||
std::map<std::string, std::string> g_http_request_headers;
|
||||
bool g_http_end_called;
|
||||
std::string g_http_body;
|
||||
|
||||
int g_epd_init_count, g_epd_sleep_count, g_epd_draw_image_count;
|
||||
int g_epd_fill_count, g_epd_fill_last_color, g_epd_draw_setup_count;
|
||||
|
||||
uint64_t g_sleep_us;
|
||||
bool g_deep_sleep_started;
|
||||
|
||||
// Globals for new mocks
|
||||
int g_show_setup_qr_count;
|
||||
uint32_t g_millis_value;
|
||||
int g_digital_read_value;
|
||||
int g_wifi_status;
|
||||
|
||||
// Ordering / sequencing globals (shared with Preferences.h and epd_mock.h)
|
||||
int g_call_seq = 0;
|
||||
int g_prefs_putint_seq = -1;
|
||||
int g_epd_draw_seq = -1;
|
||||
|
||||
// Include the template under test AFTER all mocks are defined.
|
||||
// operation.h with UNIT_TEST defined will include "epd_mock.h" and "esp_sleep.h"
|
||||
// via -iquote test/mocks path (real src/epd.h is never pulled in).
|
||||
#include "../../src/operation.h"
|
||||
|
||||
// Test fixtures
|
||||
static Preferences prefs;
|
||||
static MockHTTPClient http;
|
||||
|
||||
void reset_state() {
|
||||
g_http_get_code = 200;
|
||||
g_http_response_headers.clear();
|
||||
g_http_request_headers.clear();
|
||||
g_http_end_called = false;
|
||||
g_http_body = "TESTDATA";
|
||||
g_epd_init_count = g_epd_sleep_count = g_epd_draw_image_count = 0;
|
||||
g_epd_fill_count = g_epd_fill_last_color = g_epd_draw_setup_count = 0;
|
||||
g_sleep_us = 0;
|
||||
g_deep_sleep_started = false;
|
||||
g_show_setup_qr_count = 0;
|
||||
g_millis_value = 0;
|
||||
g_digital_read_value = HIGH; // button not pressed by default
|
||||
g_wifi_status = WL_CONNECTED; // connected by default
|
||||
prefs.clear();
|
||||
LittleFS.files.clear();
|
||||
http._ended = false;
|
||||
g_call_seq = 0;
|
||||
g_prefs_putint_seq = -1;
|
||||
g_epd_draw_seq = -1;
|
||||
}
|
||||
|
||||
void setUp() { reset_state(); }
|
||||
void tearDown() {}
|
||||
|
||||
// FW-01: 200 response — file written, epd_draw called, NVS saved, deep sleep started
|
||||
void test_fw01_200_response_happy_path() {
|
||||
// Use an interval < FETCH_INTERVAL_MS so server value is honored
|
||||
g_http_response_headers["X-Image-Id"] = "42";
|
||||
g_http_response_headers["X-Interval-Ms"] = "30000";
|
||||
g_http_body = "BINDATA";
|
||||
|
||||
normal_operation_impl(String("1C:C3:AB:D1:91:F8"), http, String("https://test/api/device/mac/image"), prefs);
|
||||
|
||||
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
|
||||
TEST_ASSERT_EQUAL(42, prefs.getInt("img_id", -1));
|
||||
TEST_ASSERT_EQUAL_UINT64(30000ULL * 1000ULL, g_sleep_us);
|
||||
TEST_ASSERT_TRUE(g_deep_sleep_started);
|
||||
TEST_ASSERT_FALSE(LittleFS.files[IMAGE_PATH].empty());
|
||||
}
|
||||
|
||||
// FW-02: REGRESSION — headers must be read BEFORE http.end(), otherwise newId is empty
|
||||
void test_fw02_headers_read_before_end_regression() {
|
||||
g_http_response_headers["X-Image-Id"] = "99";
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
// If newId was read after end(), NVS img_id would remain -1
|
||||
TEST_ASSERT_EQUAL(99, prefs.getInt("img_id", -1));
|
||||
}
|
||||
|
||||
// FW-03: 304 — no epd draw, no init, deep sleep started
|
||||
void test_fw03_304_no_redraw() {
|
||||
g_http_get_code = 304;
|
||||
// Use an interval < FETCH_INTERVAL_MS so server value is honored
|
||||
g_http_response_headers["X-Interval-Ms"] = "30000";
|
||||
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
|
||||
TEST_ASSERT_EQUAL(0, g_epd_init_count);
|
||||
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
|
||||
TEST_ASSERT_TRUE(g_deep_sleep_started);
|
||||
TEST_ASSERT_EQUAL_UINT64(30000ULL * 1000ULL, g_sleep_us);
|
||||
}
|
||||
|
||||
// FW-04: 204 — show_setup_qr called exactly once
|
||||
void test_fw04_204_shows_setup_qr() {
|
||||
g_http_get_code = 204;
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_EQUAL(1, g_show_setup_qr_count);
|
||||
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
|
||||
}
|
||||
|
||||
// FW-05: 404 — show_setup_qr called exactly once
|
||||
void test_fw05_404_shows_setup_qr() {
|
||||
g_http_get_code = 404;
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_EQUAL(1, g_show_setup_qr_count);
|
||||
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
|
||||
}
|
||||
|
||||
// FW-06: other error — epd_fill yellow
|
||||
void test_fw06_error_fills_yellow() {
|
||||
g_http_get_code = 500;
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_EQUAL(1, g_epd_fill_count);
|
||||
TEST_ASSERT_EQUAL(COLOR_YELLOW, g_epd_fill_last_color);
|
||||
}
|
||||
|
||||
// FW-07: NVS has saved img_id → X-Current-Image-Id header sent
|
||||
void test_fw07_current_image_id_sent_when_saved() {
|
||||
prefs.ints["img_id"] = 99;
|
||||
g_http_response_headers["X-Image-Id"] = "99";
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_EQUAL_STRING("99", g_http_request_headers["X-Current-Image-Id"].c_str());
|
||||
}
|
||||
|
||||
// FW-08: NVS img_id = -1 (default) → X-Current-Image-Id NOT sent
|
||||
void test_fw08_no_current_image_id_when_default() {
|
||||
// prefs has no img_id — getInt returns -1
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_TRUE(g_http_request_headers.find("X-Current-Image-Id") == g_http_request_headers.end());
|
||||
}
|
||||
|
||||
// FW-09: server interval < FETCH_INTERVAL_MS → server value used
|
||||
void test_fw09_server_interval_honored() {
|
||||
g_http_response_headers["X-Interval-Ms"] = "30000";
|
||||
g_http_response_headers["X-Image-Id"] = "1";
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_EQUAL_UINT64(30000ULL * 1000ULL, g_sleep_us);
|
||||
}
|
||||
|
||||
// FW-10: server interval > FETCH_INTERVAL_MS → capped at ceiling
|
||||
void test_fw10_server_interval_capped() {
|
||||
g_http_response_headers["X-Interval-Ms"] = "999999999";
|
||||
g_http_response_headers["X-Image-Id"] = "1";
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_EQUAL_UINT64(FETCH_INTERVAL_MS * 1000ULL, g_sleep_us);
|
||||
}
|
||||
|
||||
// FW-11: no X-Interval-Ms → default ceiling used
|
||||
void test_fw11_default_interval_when_absent() {
|
||||
g_http_response_headers["X-Image-Id"] = "1";
|
||||
// no X-Interval-Ms set
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_EQUAL_UINT64(FETCH_INTERVAL_MS * 1000ULL, g_sleep_us);
|
||||
}
|
||||
|
||||
// FW-14: 304 — epd_sleep NOT called (display already in hardware deep sleep)
|
||||
void test_fw14_304_skips_epd_sleep() {
|
||||
g_http_get_code = 304;
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_EQUAL(0, g_epd_sleep_count);
|
||||
TEST_ASSERT_EQUAL(0, g_epd_init_count);
|
||||
}
|
||||
|
||||
// FW-15: 200 — NVS img_id saved BEFORE epd_draw_image_from_file; draw_needed cleared after
|
||||
void test_fw15_nvs_saved_before_epd_draw_and_flag_cleared() {
|
||||
g_http_response_headers["X-Image-Id"] = "42";
|
||||
g_http_body = "BINDATA";
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
TEST_ASSERT_TRUE_MESSAGE(g_prefs_putint_seq < g_epd_draw_seq,
|
||||
"NVS putInt must be called before epd_draw_image_from_file");
|
||||
TEST_ASSERT_EQUAL(42, prefs.getInt("img_id", -1));
|
||||
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
|
||||
TEST_ASSERT_EQUAL(0, prefs.getInt("draw", -1));
|
||||
}
|
||||
|
||||
// FW-16: 304 with draw_needed=1 (interrupted draw) — re-draws from LittleFS and clears flag
|
||||
void test_fw16_304_with_draw_needed_redraws() {
|
||||
prefs.ints["img_id"] = 42;
|
||||
prefs.ints["draw"] = 1;
|
||||
g_http_get_code = 304;
|
||||
LittleFS.files[IMAGE_PATH] = "IMGDATA";
|
||||
|
||||
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
||||
|
||||
TEST_ASSERT_EQUAL(1, g_epd_init_count);
|
||||
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
|
||||
TEST_ASSERT_EQUAL(1, g_epd_sleep_count);
|
||||
TEST_ASSERT_EQUAL(0, prefs.getInt("draw", -1));
|
||||
}
|
||||
|
||||
// FW-12/13: AP SSID derivation via ap_ssid_from_mac()
|
||||
void test_fw12_ap_ssid_from_mac_aabbcc() {
|
||||
String ssid = ap_ssid_from_mac(String("AA:BB:CC:DD:EE:FF"));
|
||||
TEST_ASSERT_EQUAL_STRING("PictureFrame-EEFF", ssid.c_str());
|
||||
}
|
||||
|
||||
void test_fw13_ap_ssid_from_real_mac() {
|
||||
String ssid = ap_ssid_from_mac(String("1C:C3:AB:D1:91:F8"));
|
||||
TEST_ASSERT_EQUAL_STRING("PictureFrame-91F8", ssid.c_str());
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_fw01_200_response_happy_path);
|
||||
RUN_TEST(test_fw02_headers_read_before_end_regression);
|
||||
RUN_TEST(test_fw03_304_no_redraw);
|
||||
RUN_TEST(test_fw04_204_shows_setup_qr);
|
||||
RUN_TEST(test_fw05_404_shows_setup_qr);
|
||||
RUN_TEST(test_fw06_error_fills_yellow);
|
||||
RUN_TEST(test_fw07_current_image_id_sent_when_saved);
|
||||
RUN_TEST(test_fw08_no_current_image_id_when_default);
|
||||
RUN_TEST(test_fw09_server_interval_honored);
|
||||
RUN_TEST(test_fw10_server_interval_capped);
|
||||
RUN_TEST(test_fw11_default_interval_when_absent);
|
||||
RUN_TEST(test_fw12_ap_ssid_from_mac_aabbcc);
|
||||
RUN_TEST(test_fw13_ap_ssid_from_real_mac);
|
||||
RUN_TEST(test_fw14_304_skips_epd_sleep);
|
||||
RUN_TEST(test_fw15_nvs_saved_before_epd_draw_and_flag_cleared);
|
||||
RUN_TEST(test_fw16_304_with_draw_needed_redraws);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#include <unity.h>
|
||||
#include <cstdint>
|
||||
#include <cctype>
|
||||
|
||||
// Include mocks first
|
||||
#include "Arduino.h"
|
||||
#include "WiFi.h"
|
||||
#include "Preferences.h"
|
||||
#include "config.h"
|
||||
|
||||
// Define all extern globals required by mock headers
|
||||
uint32_t g_millis_value;
|
||||
int g_digital_read_value;
|
||||
int g_wifi_status;
|
||||
|
||||
// operation.h uses g_show_setup_qr_count under UNIT_TEST
|
||||
int g_show_setup_qr_count;
|
||||
|
||||
// Include the functions under test
|
||||
#include "../../src/operation.h"
|
||||
|
||||
void reset_state() {
|
||||
g_millis_value = 0;
|
||||
g_digital_read_value = HIGH; // button not pressed
|
||||
g_wifi_status = WL_CONNECTED;
|
||||
g_show_setup_qr_count = 0;
|
||||
}
|
||||
|
||||
void setUp() { reset_state(); }
|
||||
void tearDown() {}
|
||||
|
||||
// ── FW-14: attempt_wifi returns true when WiFi connects immediately ───────────
|
||||
void test_fw14_attempt_wifi_returns_true_on_connect() {
|
||||
g_wifi_status = WL_CONNECTED;
|
||||
bool result = attempt_wifi("myssid", "mypass");
|
||||
TEST_ASSERT_TRUE(result);
|
||||
}
|
||||
|
||||
// ── FW-15: attempt_wifi returns false after timeout ───────────────────────────
|
||||
// millis() auto-increments by 10 on each call; after enough iterations the
|
||||
// elapsed time exceeds WIFI_TIMEOUT_MS (30000 ms).
|
||||
void test_fw15_attempt_wifi_returns_false_on_timeout() {
|
||||
g_wifi_status = 0; // never WL_CONNECTED
|
||||
g_millis_value = 0;
|
||||
bool result = attempt_wifi("myssid", "mypass");
|
||||
TEST_ASSERT_FALSE(result);
|
||||
}
|
||||
|
||||
// ── FW-16: loop() state-machine (WiFi-credential submission path) ─────────────
|
||||
// This test is deferred: loop() orchestrates DNS, WebServer, and WiFi
|
||||
// together in a single function, making it impractical to unit-test without
|
||||
// a larger integration harness. The provisioning behavior is covered at the
|
||||
// integration / hardware level.
|
||||
// Placeholder: always passes as a reminder.
|
||||
void test_fw16_loop_state_machine_deferred() {
|
||||
TEST_PASS_MESSAGE("FW-16 deferred: loop() state machine requires integration harness");
|
||||
}
|
||||
|
||||
// ── FW-17: check_reset_button returns true when button held past threshold ────
|
||||
// g_digital_read_value = LOW keeps the while-loop spinning; millis()
|
||||
// auto-increments by 10 per call and will exceed RESET_HOLD_MS (5000 ms).
|
||||
void test_fw17_reset_button_held_returns_true() {
|
||||
g_digital_read_value = LOW;
|
||||
g_millis_value = 0;
|
||||
bool result = check_reset_button();
|
||||
TEST_ASSERT_TRUE(result);
|
||||
}
|
||||
|
||||
// ── FW-18: check_reset_button returns false when button not pressed ───────────
|
||||
void test_fw18_reset_button_not_pressed_returns_false() {
|
||||
g_digital_read_value = HIGH; // button not pressed — loop exits immediately
|
||||
bool result = check_reset_button();
|
||||
TEST_ASSERT_FALSE(result);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_fw14_attempt_wifi_returns_true_on_connect);
|
||||
RUN_TEST(test_fw15_attempt_wifi_returns_false_on_timeout);
|
||||
RUN_TEST(test_fw16_loop_state_machine_deferred);
|
||||
RUN_TEST(test_fw17_reset_button_held_returns_true);
|
||||
RUN_TEST(test_fw18_reset_button_not_pressed_returns_false);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"konva": "^10.3.0",
|
||||
"pinia": "^3.0.4",
|
||||
"sass": "^1.99.0",
|
||||
"vue": "^3.5.32",
|
||||
"vue-konva": "^3.4.0",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.2",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@vue/test-utils": "^2.4.10",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"msw": "^2.14.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5",
|
||||
"vue-tsc": "^3.2.7"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
<BottomNav v-if="!route.meta.hideNav" />
|
||||
<BaseToast />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import BottomNav from '@/components/BottomNav.vue'
|
||||
import BaseToast from '@/components/BaseToast.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const { applyTheme } = useTheme()
|
||||
|
||||
onMounted(() => {
|
||||
const stamped = document.documentElement.dataset.theme
|
||||
if (stamped && auth.user) {
|
||||
auth.user.theme = stamped
|
||||
} else if (auth.user?.theme) {
|
||||
applyTheme(auth.user.theme)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,44 @@
|
||||
export interface StickerDef {
|
||||
id: string
|
||||
category: 'seasonal' | 'holidays' | 'fun' | 'family' | 'nature'
|
||||
label: string
|
||||
emoji: string
|
||||
}
|
||||
|
||||
export const STICKER_CATEGORIES = [
|
||||
{ id: 'seasonal', label: 'Seasonal' },
|
||||
{ id: 'holidays', label: 'Holidays' },
|
||||
{ id: 'fun', label: 'Fun' },
|
||||
{ id: 'family', label: 'Family' },
|
||||
{ id: 'nature', label: 'Nature' },
|
||||
] as const
|
||||
|
||||
export type StickerCategory = typeof STICKER_CATEGORIES[number]['id']
|
||||
|
||||
export const STICKERS: StickerDef[] = [
|
||||
{ id: 'sea-snow', category: 'seasonal', label: 'Snowflake', emoji: '❄️' },
|
||||
{ id: 'sea-sun', category: 'seasonal', label: 'Sun', emoji: '☀️' },
|
||||
{ id: 'sea-leaves', category: 'seasonal', label: 'Autumn', emoji: '🍂' },
|
||||
{ id: 'sea-blossom', category: 'seasonal', label: 'Blossom', emoji: '🌸' },
|
||||
{ id: 'sea-snowman', category: 'seasonal', label: 'Snowman', emoji: '⛄' },
|
||||
{ id: 'hol-tree', category: 'holidays', label: 'Tree', emoji: '🎄' },
|
||||
{ id: 'hol-gift', category: 'holidays', label: 'Gift', emoji: '🎁' },
|
||||
{ id: 'hol-heart', category: 'holidays', label: 'Heart', emoji: '❤️' },
|
||||
{ id: 'hol-party', category: 'holidays', label: 'Party', emoji: '🎉' },
|
||||
{ id: 'hol-cake', category: 'holidays', label: 'Cake', emoji: '🎂' },
|
||||
{ id: 'fun-star', category: 'fun', label: 'Star', emoji: '⭐' },
|
||||
{ id: 'fun-rainbow', category: 'fun', label: 'Rainbow', emoji: '🌈' },
|
||||
{ id: 'fun-balloon', category: 'fun', label: 'Balloon', emoji: '🎈' },
|
||||
{ id: 'fun-sparkle', category: 'fun', label: 'Sparkles', emoji: '✨' },
|
||||
{ id: 'fun-fire', category: 'fun', label: 'Fire', emoji: '🔥' },
|
||||
{ id: 'fam-house', category: 'family', label: 'Home', emoji: '🏠' },
|
||||
{ id: 'fam-paw', category: 'family', label: 'Paw', emoji: '🐾' },
|
||||
{ id: 'fam-camera', category: 'family', label: 'Camera', emoji: '📷' },
|
||||
{ id: 'fam-plane', category: 'family', label: 'Airplane', emoji: '✈️' },
|
||||
{ id: 'fam-music', category: 'family', label: 'Music', emoji: '🎵' },
|
||||
{ id: 'nat-tree', category: 'nature', label: 'Tree', emoji: '🌲' },
|
||||
{ id: 'nat-flower', category: 'nature', label: 'Flower', emoji: '🌺' },
|
||||
{ id: 'nat-bee', category: 'nature', label: 'Bee', emoji: '🐝' },
|
||||
{ id: 'nat-fly', category: 'nature', label: 'Butterfly', emoji: '🦋' },
|
||||
{ id: 'nat-moon', category: 'nature', label: 'Moon', emoji: '🌙' },
|
||||
]
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="approve-card">
|
||||
<img :src="item.thumbnailUrl" :alt="`Photo from ${item.sharedBy}`" class="approve-card__thumb" loading="lazy" />
|
||||
|
||||
<div class="approve-card__body">
|
||||
<p class="approve-card__from">From <strong>{{ item.sharedBy }}</strong></p>
|
||||
<p class="approve-card__date">{{ formattedDate }}</p>
|
||||
|
||||
<div class="approve-card__status" v-if="item.status !== 'pending'">
|
||||
<span :class="['approve-card__badge', `approve-card__badge--${item.status}`]">
|
||||
{{ item.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="approve-card__actions">
|
||||
<template v-if="item.status === 'pending' || item.status === 'declined'">
|
||||
<BaseButton variant="primary" size="sm" :disabled="busy" @click="showPicker = true">
|
||||
{{ item.status === 'declined' ? 'Add anyway' : 'Add to frame' }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
<template v-if="item.status === 'pending' || item.status === 'approved'">
|
||||
<BaseButton variant="ghost" size="sm" :disabled="busy" @click="decline">
|
||||
{{ item.status === 'approved' ? 'Remove' : 'Decline' }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DevicePicker
|
||||
v-model="showPicker"
|
||||
:devices="devicesStore.devices"
|
||||
:selected="selectedDeviceIds"
|
||||
:uploading="busy"
|
||||
confirm-label="Add to frames"
|
||||
@update:selected="selectedDeviceIds = $event"
|
||||
@confirm="approve"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { SharedImage } from '@/types'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import DevicePicker from '@/components/DevicePicker.vue'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
|
||||
const props = defineProps<{ item: SharedImage }>()
|
||||
const emit = defineEmits<{ (e: 'updated', v: SharedImage): void }>()
|
||||
|
||||
const imagesStore = useImagesStore()
|
||||
const devicesStore = useDevicesStore()
|
||||
const showPicker = ref(false)
|
||||
const busy = ref(false)
|
||||
const selectedDeviceIds = ref<number[]>([])
|
||||
|
||||
const formattedDate = computed(() =>
|
||||
new Date(props.item.sharedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
)
|
||||
|
||||
async function approve() {
|
||||
showPicker.value = false
|
||||
busy.value = true
|
||||
try {
|
||||
const updated = await imagesStore.approveShared(props.item.id, selectedDeviceIds.value)
|
||||
emit('updated', updated)
|
||||
} finally {
|
||||
busy.value = false
|
||||
selectedDeviceIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function decline() {
|
||||
busy.value = true
|
||||
try {
|
||||
const updated = await imagesStore.declineShared(props.item.id)
|
||||
emit('updated', updated)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.approve-card {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
&__thumb {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
&__from { font-size: var(--text-sm); }
|
||||
&__date { font-size: var(--text-xs); color: var(--color-text-muted); }
|
||||
|
||||
&__badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
|
||||
&--approved { background: #d4edda; color: #1a7f4b; }
|
||||
&--declined { background: #fde8e8; color: #d93025; }
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="sheet">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="sheet-overlay"
|
||||
role="dialog"
|
||||
:aria-label="label"
|
||||
aria-modal="true"
|
||||
@click.self="close"
|
||||
@keydown.esc="close"
|
||||
>
|
||||
<div
|
||||
ref="sheetRef"
|
||||
class="sheet"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="sheet__handle" aria-hidden="true" />
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
label: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const sheetRef = ref<HTMLElement | null>(null)
|
||||
let triggerEl: HTMLElement | null = null
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (open) {
|
||||
triggerEl = document.activeElement as HTMLElement
|
||||
await nextTick()
|
||||
sheetRef.value?.focus()
|
||||
} else {
|
||||
triggerEl?.focus()
|
||||
triggerEl = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sheet-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
width: 100%;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
padding: var(--space-3) var(--space-4) var(--space-6);
|
||||
max-height: 90dvh;
|
||||
overflow-y: auto;
|
||||
outline: none;
|
||||
|
||||
&__handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-border);
|
||||
margin: 0 auto var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.sheet-enter-active {
|
||||
.sheet-overlay { transition: background var(--duration-base) var(--ease-out); }
|
||||
.sheet { transition: transform 250ms var(--ease-out); }
|
||||
}
|
||||
|
||||
.sheet-leave-active {
|
||||
.sheet { transition: transform 200ms ease-in; }
|
||||
transition: background 200ms ease-in;
|
||||
}
|
||||
|
||||
.sheet-enter-from {
|
||||
background: rgba(0, 0, 0, 0);
|
||||
.sheet { transform: translateY(100%); }
|
||||
}
|
||||
|
||||
.sheet-leave-to {
|
||||
background: rgba(0, 0, 0, 0);
|
||||
.sheet { transform: translateY(100%); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<component
|
||||
:is="tag"
|
||||
:type="tag === 'button' ? type : undefined"
|
||||
:disabled="disabled || loading"
|
||||
:class="['btn', `btn--${variant}`, { 'btn--loading': loading }]"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<span v-if="loading" class="btn__spinner" aria-hidden="true" />
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive' | 'icon-pill'
|
||||
tag?: 'button' | 'a' | 'router-link'
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}>(), {
|
||||
variant: 'primary',
|
||||
tag: 'button',
|
||||
type: 'button',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
min-height: var(--touch-min);
|
||||
padding: 0 var(--space-5);
|
||||
border-radius: var(--radius-full);
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: opacity var(--duration-fast) var(--ease-out),
|
||||
transform var(--duration-fast) var(--ease-out);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-fg);
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-secondary-fg);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
&--ghost {
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
&--destructive {
|
||||
background: var(--color-destructive);
|
||||
color: var(--color-destructive-fg);
|
||||
}
|
||||
|
||||
&--icon-pill {
|
||||
width: var(--touch-min);
|
||||
padding: 0;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div class="card" v-bind="$attrs">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
</style>
|
||||