chore: stage all in-progress work before repo split
CI / test (push) Has been cancelled

Web app: new entities (Image, RenderedAsset, SharedImage, Token,
DeviceImageHistory), enums, repositories, controllers, message handlers,
migrations, tests, frontend upload/library/sticker UI, Vue components.

Firmware: EPD background screen binaries + gen scripts, setup_bg header.

Infra: ddev config, test bundle, gitignore coverage dir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:11:31 -04:00
parent dd0970ed7c
commit 4002ff9fbf
156 changed files with 27333 additions and 92 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+820
View File
@@ -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 &mdash; 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 &rarr;<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">&#10003;</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 3339 (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 &mdash; 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">&#10003;</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 3439) -->
<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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -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