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:
Binary file not shown.
|
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>
|
||||
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
|
||||
Reference in New Issue
Block a user