chore: untrack planning artifacts, ignore .superpowers/ docs/ PLAN.md

This commit is contained in:
Arnie via Claude
2026-06-12 00:29:40 +02:00
parent fd877411ef
commit 5e46cd7333
16 changed files with 5 additions and 1527 deletions
+5
View File
@@ -1,6 +1,11 @@
.direnv/ .direnv/
.envrc.local .envrc.local
# Planning / agent artifacts
.superpowers/
docs/
PLAN.md
# Gradle / Android # Gradle / Android
.gradle/ .gradle/
build/ build/
@@ -1,98 +0,0 @@
<h2>Design — Cross-section (top view)</h2>
<p class="subtitle">How the piece sits in the corner. All measurements approximate — confirm before generating.</p>
<div class="mockup">
<div class="mockup-header">Top-down cross section at any height</div>
<div class="mockup-body" style="display:flex;justify-content:center;padding:24px 0;">
<svg width="340" height="300" viewBox="-20 -20 340 300">
<!-- Walls -->
<line x1="0" y1="0" x2="0" y2="280" stroke="#555" stroke-width="8" stroke-linecap="square"/>
<line x1="0" y1="0" x2="320" y2="0" stroke="#555" stroke-width="8" stroke-linecap="square"/>
<text x="8" y="140" fill="#777" font-size="11" font-family="monospace">wall</text>
<text x="100" y="14" fill="#777" font-size="11" font-family="monospace">wall</text>
<!-- Flanges (glued to walls) -->
<rect x="4" y="4" width="8" height="38" fill="#7c3aed" opacity="0.7"/>
<rect x="4" y="4" width="38" height="8" fill="#7c3aed" opacity="0.7"/>
<text x="14" y="28" fill="#e9d5ff" font-size="9" font-family="monospace">flange</text>
<!-- Hollow triangular spine -->
<polygon points="4,42 42,4 42,42" fill="#4c1d95" opacity="0.85" stroke="#a78bfa" stroke-width="1.5"/>
<!-- Cable hole -->
<circle cx="28" cy="28" r="5" fill="#111" stroke="#a78bfa" stroke-width="1" stroke-dasharray="2,1"/>
<text x="35" y="22" fill="#a78bfa" font-size="9" font-family="monospace">cable</text>
<line x1="34" y1="21" x2="32" y2="26" stroke="#a78bfa" stroke-width="0.8"/>
<!-- Spine label -->
<text x="7" y="56" fill="#c4b5fd" font-size="9" font-family="monospace">spine</text>
<!-- Ribbon blade — shown at 0° (flat, extending diagonally) -->
<!-- At 0° the blade lies in the XY plane along the 45° diagonal -->
<line x1="42" y1="42" x2="195" y2="195" stroke="#e879f9" stroke-width="10" stroke-linecap="round" opacity="0.9"/>
<!-- Ribbon label -->
<text x="150" y="175" fill="#f0abfc" font-size="11" font-family="monospace" transform="rotate(45 150 175)">ribbon blade (10cm wide)</text>
<!-- Protrusion depth arrow -->
<line x1="4" y1="260" x2="4" y2="270" stroke="#38bdf8" stroke-width="1"/>
<line x1="4" y1="265" x2="140" y2="265" stroke="#38bdf8" stroke-width="1.5" marker-end="url(#arr)"/>
<text x="50" y="280" fill="#38bdf8" font-size="10" font-family="monospace">~8cm from corner axis</text>
<!-- Spine size annotation -->
<line x1="42" y1="50" x2="90" y2="50" stroke="#6b7280" stroke-width="1" stroke-dasharray="2,2"/>
<text x="46" y="63" fill="#9ca3af" font-size="9" font-family="monospace">spine legs ~20mm</text>
<!-- Ribbon thickness annotation -->
<line x1="200" y1="193" x2="215" y2="178" stroke="#6b7280" stroke-width="1"/>
<text x="216" y="176" fill="#9ca3af" font-size="9" font-family="monospace">4mm thick</text>
<!-- Room diagonal guide -->
<line x1="0" y1="0" x2="220" y2="220" stroke="#333" stroke-width="1" stroke-dasharray="4,3"/>
<text x="190" y="240" fill="#444" font-size="9" font-family="monospace">45° diagonal</text>
<!-- Corner dot -->
<circle cx="4" cy="4" r="4" fill="#f59e0b"/>
</svg>
</div>
</div>
<div class="section">
<h3>Key dimensions</h3>
<table style="width:100%;border-collapse:collapse;font-family:monospace;font-size:13px;">
<tr style="border-bottom:1px solid #333">
<td style="padding:6px 12px;color:#9ca3af">Piece height</td>
<td style="padding:6px 12px;color:#e5e7eb">250mm</td>
</tr>
<tr style="border-bottom:1px solid #333">
<td style="padding:6px 12px;color:#9ca3af">Total stack</td>
<td style="padding:6px 12px;color:#e5e7eb">4 pieces × 250mm = 1000mm</td>
</tr>
<tr style="border-bottom:1px solid #333">
<td style="padding:6px 12px;color:#9ca3af">Ribbon width</td>
<td style="padding:6px 12px;color:#e5e7eb">100mm</td>
</tr>
<tr style="border-bottom:1px solid #333">
<td style="padding:6px 12px;color:#9ca3af">Ribbon thickness</td>
<td style="padding:6px 12px;color:#e5e7eb">4mm</td>
</tr>
<tr style="border-bottom:1px solid #333">
<td style="padding:6px 12px;color:#9ca3af">Protrusion from corner</td>
<td style="padding:6px 12px;color:#e5e7eb">~80mm (tip of ribbon at max extent)</td>
</tr>
<tr style="border-bottom:1px solid #333">
<td style="padding:6px 12px;color:#9ca3af">Twist per piece</td>
<td style="padding:6px 12px;color:#e5e7eb">360° — ribbon returns to same angle at top/bottom</td>
</tr>
<tr style="border-bottom:1px solid #333">
<td style="padding:6px 12px;color:#9ca3af">Spine triangle legs</td>
<td style="padding:6px 12px;color:#e5e7eb">~20mm × 20mm (right isosceles, sits in 90° corner)</td>
</tr>
<tr>
<td style="padding:6px 12px;color:#9ca3af">Cable channel</td>
<td style="padding:6px 12px;color:#e5e7eb">~8mm circular hole through centre of spine</td>
</tr>
</table>
</div>
<p class="subtitle" style="margin-top:16px;">Does this cross-section look right? Let me know in terminal.</p>
@@ -1 +0,0 @@
{"type":"server-started","port":52609,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:52609","screen_dir":"/home/becky/.claude-jail/.superpowers/brainstorm/1131-1781214773/content","state_dir":"/home/becky/.claude-jail/.superpowers/brainstorm/1131-1781214773/state"}
@@ -1,2 +0,0 @@
{"type":"server-started","port":52609,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:52609","screen_dir":"/home/becky/.claude-jail/.superpowers/brainstorm/1131-1781214773/content","state_dir":"/home/becky/.claude-jail/.superpowers/brainstorm/1131-1781214773/state"}
{"type":"screen-added","file":"/home/becky/.claude-jail/.superpowers/brainstorm/1131-1781214773/content/design-section1.html"}
@@ -1 +0,0 @@
1139
@@ -1,89 +0,0 @@
<h2>Cross-section of the twisted spine</h2>
<p class="subtitle">The shape that rotates as it rises — shown end-on, then how twist looks from front</p>
<div class="cards">
<div class="card" data-choice="ribbon" onclick="toggleSelect(this)">
<div class="card-image" style="background:#111;display:flex;align-items:center;justify-content:center;height:180px;gap:24px;">
<!-- end-on -->
<svg width="60" height="60" viewBox="-30 -30 60 60">
<text x="-28" y="-18" fill="#555" font-size="8" font-family="monospace">end-on</text>
<ellipse cx="0" cy="0" rx="22" ry="5" fill="#7c3aed" opacity="0.9"/>
</svg>
<!-- side view of twist -->
<svg width="60" height="140" viewBox="-30 -70 60 140">
<text x="-28" y="-62" fill="#555" font-size="8" font-family="monospace">front</text>
<!-- ribbon twisting -->
<path d="M-20,-60 Q20,-40 20,-20 Q-20,0 -20,20 Q20,40 20,60" fill="none" stroke="#7c3aed" stroke-width="3" stroke-linecap="round"/>
<path d="M20,-60 Q-20,-40 -20,-20 Q20,0 20,20 Q-20,40 -20,60" fill="none" stroke="#a78bfa" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="4,2"/>
</svg>
</div>
<div class="card-body">
<h3>Flat Ribbon / Blade</h3>
<p>Thin wide strip — like a Möbius blade. Very dramatic from the side (wide face) but nearly disappears when viewed edge-on. Strong light/shadow contrast.</p>
</div>
</div>
<div class="card" data-choice="oval" onclick="toggleSelect(this)">
<div class="card-image" style="background:#111;display:flex;align-items:center;justify-content:center;height:180px;gap:24px;">
<svg width="60" height="60" viewBox="-30 -30 60 60">
<text x="-28" y="-18" fill="#555" font-size="8" font-family="monospace">end-on</text>
<ellipse cx="0" cy="0" rx="14" ry="14" fill="#0891b2"/>
</svg>
<svg width="60" height="140" viewBox="-30 -70 60 140">
<text x="-28" y="-62" fill="#555" font-size="8" font-family="monospace">front</text>
<!-- round rod — twist is subtle, gentle S curve outline -->
<path d="M-10,-60 Q12,-30 12,0 Q12,30 -10,60" fill="none" stroke="#0891b2" stroke-width="14" stroke-linecap="round" opacity="0.5"/>
<path d="M-10,-60 Q12,-30 12,0 Q12,30 -10,60" fill="none" stroke="#22d3ee" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<div class="card-body">
<h3>Round Rod / Cylinder</h3>
<p>Circular cross-section spirals around corner axis. Twist is subtle — the silhouette curves gently. Smooth and elegant, easiest to print.</p>
</div>
</div>
<div class="card" data-choice="star" onclick="toggleSelect(this)">
<div class="card-image" style="background:#111;display:flex;align-items:center;justify-content:center;height:180px;gap:24px;">
<svg width="60" height="60" viewBox="-30 -30 60 60">
<text x="-28" y="-18" fill="#555" font-size="8" font-family="monospace">end-on</text>
<polygon points="0,-18 5,-7 17,-7 8,2 12,15 0,8 -12,15 -8,2 -17,-7 -5,-7" fill="#d97706"/>
</svg>
<svg width="60" height="140" viewBox="-30 -70 60 140">
<text x="-28" y="-62" fill="#555" font-size="8" font-family="monospace">front</text>
<!-- star cross-section twisting — shows alternating spike silhouette -->
<path d="M0,-60 Q18,-40 0,-20 Q-18,0 0,20 Q18,40 0,60" fill="none" stroke="#d97706" stroke-width="3"/>
<line x1="-18" y1="-40" x2="18" y2="-40" stroke="#d97706" stroke-width="1" opacity="0.5"/>
<line x1="-18" y1="0" x2="18" y2="0" stroke="#d97706" stroke-width="1" opacity="0.5"/>
<line x1="-18" y1="40" x2="18" y2="40" stroke="#d97706" stroke-width="1" opacity="0.5"/>
</svg>
</div>
<div class="card-body">
<h3>Star / Multi-Point</h3>
<p>35 pointed star cross-section. As it twists the spikes create a dynamic rippling silhouette. Most visually complex, high detail on print.</p>
</div>
</div>
<div class="card" data-choice="triangle" onclick="toggleSelect(this)">
<div class="card-image" style="background:#111;display:flex;align-items:center;justify-content:center;height:180px;gap:24px;">
<svg width="60" height="60" viewBox="-30 -30 60 60">
<text x="-28" y="-18" fill="#555" font-size="8" font-family="monospace">end-on</text>
<polygon points="0,-18 18,12 -18,12" fill="#16a34a"/>
</svg>
<svg width="60" height="140" viewBox="-30 -70 60 140">
<text x="-28" y="-62" fill="#555" font-size="8" font-family="monospace">front</text>
<!-- triangle twisting: alternating wide/narrow faces -->
<path d="M-16,-60 Q16,-30 -16,0 Q16,30 -16,60" fill="none" stroke="#16a34a" stroke-width="2.5"/>
<path d="M16,-60 Q-16,-30 16,0 Q-16,30 16,60" fill="none" stroke="#4ade80" stroke-width="1.5" stroke-dasharray="3,3"/>
<line x1="-16" y1="-60" x2="16" y2="-60" stroke="#16a34a" stroke-width="2"/>
<line x1="-16" y1="0" x2="16" y2="0" stroke="#16a34a" stroke-width="2"/>
<line x1="-16" y1="60" x2="16" y2="60" stroke="#16a34a" stroke-width="2"/>
</svg>
</div>
<div class="card-body">
<h3>Triangle / Prism</h3>
<p>3-sided prism twisting. Faces alternate between facing you and turning away — creates a strong angular rhythm as you walk past.</p>
</div>
</div>
</div>
@@ -1,98 +0,0 @@
<h2>Design — Cross-section (top view)</h2>
<p class="subtitle">How the piece sits in the corner. All measurements approximate — confirm before generating.</p>
<div class="mockup">
<div class="mockup-header">Top-down cross section at any height</div>
<div class="mockup-body" style="display:flex;justify-content:center;padding:24px 0;">
<svg width="340" height="300" viewBox="-20 -20 340 300">
<!-- Walls -->
<line x1="0" y1="0" x2="0" y2="280" stroke="#555" stroke-width="8" stroke-linecap="square"/>
<line x1="0" y1="0" x2="320" y2="0" stroke="#555" stroke-width="8" stroke-linecap="square"/>
<text x="8" y="140" fill="#777" font-size="11" font-family="monospace">wall</text>
<text x="100" y="14" fill="#777" font-size="11" font-family="monospace">wall</text>
<!-- Flanges (glued to walls) -->
<rect x="4" y="4" width="8" height="38" fill="#7c3aed" opacity="0.7"/>
<rect x="4" y="4" width="38" height="8" fill="#7c3aed" opacity="0.7"/>
<text x="14" y="28" fill="#e9d5ff" font-size="9" font-family="monospace">flange</text>
<!-- Hollow triangular spine -->
<polygon points="4,42 42,4 42,42" fill="#4c1d95" opacity="0.85" stroke="#a78bfa" stroke-width="1.5"/>
<!-- Cable hole -->
<circle cx="28" cy="28" r="5" fill="#111" stroke="#a78bfa" stroke-width="1" stroke-dasharray="2,1"/>
<text x="35" y="22" fill="#a78bfa" font-size="9" font-family="monospace">cable</text>
<line x1="34" y1="21" x2="32" y2="26" stroke="#a78bfa" stroke-width="0.8"/>
<!-- Spine label -->
<text x="7" y="56" fill="#c4b5fd" font-size="9" font-family="monospace">spine</text>
<!-- Ribbon blade — shown at 0° (flat, extending diagonally) -->
<!-- At 0° the blade lies in the XY plane along the 45° diagonal -->
<line x1="42" y1="42" x2="195" y2="195" stroke="#e879f9" stroke-width="10" stroke-linecap="round" opacity="0.9"/>
<!-- Ribbon label -->
<text x="150" y="175" fill="#f0abfc" font-size="11" font-family="monospace" transform="rotate(45 150 175)">ribbon blade (10cm wide)</text>
<!-- Protrusion depth arrow -->
<line x1="4" y1="260" x2="4" y2="270" stroke="#38bdf8" stroke-width="1"/>
<line x1="4" y1="265" x2="140" y2="265" stroke="#38bdf8" stroke-width="1.5" marker-end="url(#arr)"/>
<text x="50" y="280" fill="#38bdf8" font-size="10" font-family="monospace">~8cm from corner axis</text>
<!-- Spine size annotation -->
<line x1="42" y1="50" x2="90" y2="50" stroke="#6b7280" stroke-width="1" stroke-dasharray="2,2"/>
<text x="46" y="63" fill="#9ca3af" font-size="9" font-family="monospace">spine legs ~20mm</text>
<!-- Ribbon thickness annotation -->
<line x1="200" y1="193" x2="215" y2="178" stroke="#6b7280" stroke-width="1"/>
<text x="216" y="176" fill="#9ca3af" font-size="9" font-family="monospace">4mm thick</text>
<!-- Room diagonal guide -->
<line x1="0" y1="0" x2="220" y2="220" stroke="#333" stroke-width="1" stroke-dasharray="4,3"/>
<text x="190" y="240" fill="#444" font-size="9" font-family="monospace">45° diagonal</text>
<!-- Corner dot -->
<circle cx="4" cy="4" r="4" fill="#f59e0b"/>
</svg>
</div>
</div>
<div class="section">
<h3>Key dimensions</h3>
<table style="width:100%;border-collapse:collapse;font-family:monospace;font-size:13px;">
<tr style="border-bottom:1px solid #333">
<td style="padding:6px 12px;color:#9ca3af">Piece height</td>
<td style="padding:6px 12px;color:#e5e7eb">250mm</td>
</tr>
<tr style="border-bottom:1px solid #333">
<td style="padding:6px 12px;color:#9ca3af">Total stack</td>
<td style="padding:6px 12px;color:#e5e7eb">4 pieces × 250mm = 1000mm</td>
</tr>
<tr style="border-bottom:1px solid #333">
<td style="padding:6px 12px;color:#9ca3af">Ribbon width</td>
<td style="padding:6px 12px;color:#e5e7eb">100mm</td>
</tr>
<tr style="border-bottom:1px solid #333">
<td style="padding:6px 12px;color:#9ca3af">Ribbon thickness</td>
<td style="padding:6px 12px;color:#e5e7eb">4mm</td>
</tr>
<tr style="border-bottom:1px solid #333">
<td style="padding:6px 12px;color:#9ca3af">Protrusion from corner</td>
<td style="padding:6px 12px;color:#e5e7eb">~80mm (tip of ribbon at max extent)</td>
</tr>
<tr style="border-bottom:1px solid #333">
<td style="padding:6px 12px;color:#9ca3af">Twist per piece</td>
<td style="padding:6px 12px;color:#e5e7eb">360° — ribbon returns to same angle at top/bottom</td>
</tr>
<tr style="border-bottom:1px solid #333">
<td style="padding:6px 12px;color:#9ca3af">Spine triangle legs</td>
<td style="padding:6px 12px;color:#e5e7eb">~20mm × 20mm (right isosceles, sits in 90° corner)</td>
</tr>
<tr>
<td style="padding:6px 12px;color:#9ca3af">Cable channel</td>
<td style="padding:6px 12px;color:#e5e7eb">~8mm circular hole through centre of spine</td>
</tr>
</table>
</div>
<p class="subtitle" style="margin-top:16px;">Does this cross-section look right? Let me know in terminal.</p>
@@ -1,122 +0,0 @@
<h2>More abstract — protrudes into room space</h2>
<p class="subtitle">Pieces extend outward from corner (x+y axis), not just flat trim</p>
<div class="cards">
<div class="card" data-choice="twist" onclick="toggleSelect(this)">
<div class="card-image" style="background:#111;display:flex;align-items:center;justify-content:center;height:180px;overflow:hidden;">
<svg width="120" height="160" viewBox="0 0 120 160">
<!-- top-down hint showing corner + protrusion -->
<text x="6" y="14" fill="#555" font-size="9" font-family="monospace">top view →</text>
<!-- wall lines -->
<line x1="10" y1="20" x2="10" y2="155" stroke="#333" stroke-width="2"/>
<line x1="10" y1="20" x2="110" y2="20" stroke="#333" stroke-width="2"/>
<!-- twisted ribbon protruding diagonally -->
<path d="M10,20 Q40,35 55,55 Q70,75 60,100 Q50,125 65,145" fill="none" stroke="#7c3aed" stroke-width="4" stroke-linecap="round"/>
<path d="M10,20 Q45,30 65,48 Q85,66 75,90 Q65,115 80,135" fill="none" stroke="#a78bfa" stroke-width="2" stroke-linecap="round" stroke-dasharray="4,3"/>
<!-- cross sections showing twist -->
<ellipse cx="55" cy="55" rx="12" ry="5" fill="none" stroke="#7c3aed" stroke-width="1" transform="rotate(-30 55 55)"/>
<ellipse cx="60" cy="100" rx="12" ry="5" fill="none" stroke="#7c3aed" stroke-width="1" transform="rotate(20 60 100)"/>
<circle cx="10" cy="20" r="4" fill="#7c3aed"/>
</svg>
</div>
<div class="card-body">
<h3>Twisted Spine</h3>
<p>Ribbon or helix that spirals out from corner as it rises. Each 25cm piece is one full twist. Dramatic shadow play.</p>
</div>
</div>
<div class="card" data-choice="wave" onclick="toggleSelect(this)">
<div class="card-image" style="background:#111;display:flex;align-items:center;justify-content:center;height:180px;overflow:hidden;">
<svg width="120" height="160" viewBox="0 0 120 160">
<text x="6" y="14" fill="#555" font-size="9" font-family="monospace">top view →</text>
<line x1="10" y1="20" x2="10" y2="155" stroke="#333" stroke-width="2"/>
<line x1="10" y1="20" x2="110" y2="20" stroke="#333" stroke-width="2"/>
<!-- wave peaks alternating x/y -->
<path d="M10,30 Q50,30 50,50 Q50,70 10,70 Q50,70 50,90 Q50,110 10,110 Q50,110 50,130 Q50,150 10,150" fill="none" stroke="#0891b2" stroke-width="3"/>
<path d="M10,30 Q10,60 30,60 Q10,60 10,90 Q10,90 30,90 Q10,90 10,120 Q10,120 30,120" fill="none" stroke="#22d3ee" stroke-width="1.5" stroke-dasharray="3,3"/>
<!-- depth indication -->
<circle cx="50" cy="50" r="3" fill="#0891b2"/>
<circle cx="50" cy="90" r="3" fill="#0891b2"/>
<circle cx="50" cy="130" r="3" fill="#0891b2"/>
<circle cx="30" cy="60" r="3" fill="#22d3ee"/>
<circle cx="30" cy="100" r="3" fill="#22d3ee"/>
</svg>
</div>
<div class="card-body">
<h3>Wave / Oscillation</h3>
<p>Alternating peaks that swing out along x-axis then y-axis as you go up. Smooth sinusoidal form, organic feel despite being parametric.</p>
</div>
</div>
<div class="card" data-choice="fins" onclick="toggleSelect(this)">
<div class="card-image" style="background:#111;display:flex;align-items:center;justify-content:center;height:180px;overflow:hidden;">
<svg width="120" height="160" viewBox="0 0 120 160">
<text x="6" y="14" fill="#555" font-size="9" font-family="monospace">top view →</text>
<line x1="10" y1="20" x2="10" y2="155" stroke="#333" stroke-width="2"/>
<line x1="10" y1="20" x2="110" y2="20" stroke="#333" stroke-width="2"/>
<!-- stacked fins radiating outward -->
<g stroke="#d97706" stroke-width="2" fill="none">
<!-- fin 1 -->
<path d="M10,35 L55,35 L55,45 L10,45"/>
<!-- fin 2 rotated -->
<path d="M10,55 L10,65 L20,65"/>
<path d="M10,55 L55,55 L55,65"/>
<!-- fin 3 -->
<path d="M10,80 L55,80 L55,90 L10,90"/>
<!-- fin 4 rotated -->
<path d="M10,105 L10,115 L20,115"/>
<path d="M10,105 L55,105 L55,115"/>
<!-- fin 5 -->
<path d="M10,130 L55,130 L55,140 L10,140"/>
</g>
<text x="58" y="40" fill="#d97706" font-size="8">→ x</text>
<text x="22" y="68" fill="#f59e0b" font-size="8">↓ y</text>
</svg>
</div>
<div class="card-body">
<h3>Rotating Fins</h3>
<p>Horizontal blades that rotate 90° with each piece — alternating x-axis and y-axis protrusion. Strong geometric rhythm, industrial-abstract feel.</p>
</div>
</div>
<div class="card" data-choice="fractal" onclick="toggleSelect(this)">
<div class="card-image" style="background:#111;display:flex;align-items:center;justify-content:center;height:180px;overflow:hidden;">
<svg width="120" height="160" viewBox="0 0 120 160">
<text x="6" y="14" fill="#555" font-size="9" font-family="monospace">front view →</text>
<line x1="10" y1="20" x2="10" y2="155" stroke="#333" stroke-width="2"/>
<!-- branching / fractal-like structure growing from corner -->
<g stroke="#16a34a" fill="none">
<!-- trunk -->
<line x1="10" y1="150" x2="10" y2="100" stroke-width="5"/>
<!-- level 1 branches -->
<line x1="10" y1="120" x2="35" y2="100" stroke-width="3"/>
<line x1="10" y1="120" x2="35" y2="140" stroke-width="3"/>
<!-- level 2 -->
<line x1="35" y1="100" x2="55" y2="88" stroke-width="2"/>
<line x1="35" y1="100" x2="55" y2="112" stroke-width="2"/>
<line x1="35" y1="140" x2="55" y2="128" stroke-width="2"/>
<line x1="35" y1="140" x2="55" y2="152" stroke-width="2"/>
<!-- level 3 -->
<line x1="55" y1="88" x2="70" y2="82" stroke-width="1.5"/>
<line x1="55" y1="88" x2="70" y2="94" stroke-width="1.5"/>
<line x1="55" y1="112" x2="70" y2="106" stroke-width="1.5"/>
<line x1="55" y1="112" x2="70" y2="118" stroke-width="1.5"/>
<!-- upper section repeat -->
<line x1="10" y1="80" x2="10" y2="50" stroke-width="4"/>
<line x1="10" y1="65" x2="30" y2="50" stroke-width="2.5"/>
<line x1="10" y1="65" x2="30" y2="80" stroke-width="2.5"/>
<line x1="30" y1="50" x2="48" y2="42" stroke-width="1.5"/>
<line x1="30" y1="50" x2="48" y2="58" stroke-width="1.5"/>
<line x1="30" y1="80" x2="48" y2="72" stroke-width="1.5"/>
<line x1="30" y1="80" x2="48" y2="88" stroke-width="1.5"/>
</g>
</svg>
</div>
<div class="card-body">
<h3>Branching / Fractal</h3>
<p>Tree-like structure grows from corner outward. Each piece has a self-similar branching pattern at different scale. Bold, sculptural.</p>
</div>
</div>
</div>
@@ -1,86 +0,0 @@
<h2>What style for the corner decoration?</h2>
<p class="subtitle">1 meter tall corner trim, wall-to-wall 90° corner, split into 4 × 25cm pieces</p>
<div class="cards">
<div class="card" data-choice="geometric" onclick="toggleSelect(this)">
<div class="card-image" style="background:#1a1a2e;display:flex;align-items:center;justify-content:center;height:160px;">
<svg width="80" height="140" viewBox="0 0 80 140">
<!-- corner silhouette with geometric pattern -->
<polygon points="40,0 80,140 0,140" fill="#16213e" stroke="#0f3460" stroke-width="1"/>
<line x1="40" y1="20" x2="20" y2="110" stroke="#e94560" stroke-width="1.5"/>
<line x1="40" y1="20" x2="60" y2="110" stroke="#e94560" stroke-width="1.5"/>
<line x1="28" y1="55" x2="52" y2="55" stroke="#e94560" stroke-width="1"/>
<line x1="24" y1="75" x2="56" y2="75" stroke="#e94560" stroke-width="1"/>
<line x1="20" y1="95" x2="60" y2="95" stroke="#e94560" stroke-width="1"/>
<circle cx="40" cy="20" r="3" fill="#e94560"/>
<circle cx="20" cy="110" r="3" fill="#e94560"/>
<circle cx="60" cy="110" r="3" fill="#e94560"/>
</svg>
</div>
<div class="card-body">
<h3>Geometric / Modern</h3>
<p>Clean angular lines, diamond or triangular facets, sharp repeating pattern. Looks great in contemporary interiors.</p>
</div>
</div>
<div class="card" data-choice="classic" onclick="toggleSelect(this)">
<div class="card-image" style="background:#1a1a2e;display:flex;align-items:center;justify-content:center;height:160px;">
<svg width="80" height="140" viewBox="0 0 80 140">
<!-- classical molding profile suggestion -->
<path d="M40,5 C30,15 20,25 20,40 C20,55 30,60 30,75 C30,90 20,95 20,110 C20,125 30,130 40,135 C50,130 60,125 60,110 C60,95 50,90 50,75 C50,60 60,55 60,40 C60,25 50,15 40,5Z" fill="#2a2a4a" stroke="#c9a84c" stroke-width="1.5"/>
<ellipse cx="40" cy="35" rx="8" ry="5" fill="none" stroke="#c9a84c" stroke-width="1"/>
<ellipse cx="40" cy="75" rx="8" ry="5" fill="none" stroke="#c9a84c" stroke-width="1"/>
<ellipse cx="40" cy="115" rx="8" ry="5" fill="none" stroke="#c9a84c" stroke-width="1"/>
<line x1="30" y1="55" x2="50" y2="55" stroke="#c9a84c" stroke-width="0.5"/>
<line x1="30" y1="95" x2="50" y2="95" stroke="#c9a84c" stroke-width="0.5"/>
</svg>
</div>
<div class="card-body">
<h3>Classical / Ornate</h3>
<p>Curved profiles, egg-and-dart or bead motifs, traditional molding style. Suits older or formal interiors.</p>
</div>
</div>
<div class="card" data-choice="nature" onclick="toggleSelect(this)">
<div class="card-image" style="background:#1a1a2e;display:flex;align-items:center;justify-content:center;height:160px;">
<svg width="80" height="140" viewBox="0 0 80 140">
<!-- organic / vine -->
<path d="M40,10 Q35,30 38,50 Q42,65 36,85 Q30,105 38,130" fill="none" stroke="#4a9e6a" stroke-width="2.5"/>
<!-- leaves -->
<path d="M38,30 Q25,25 22,35 Q30,38 38,30Z" fill="#4a9e6a" opacity="0.8"/>
<path d="M39,30 Q52,22 55,32 Q47,38 39,30Z" fill="#6ab87a" opacity="0.7"/>
<path d="M36,60 Q22,52 20,64 Q28,68 36,60Z" fill="#4a9e6a" opacity="0.8"/>
<path d="M37,60 Q50,50 54,62 Q46,68 37,60Z" fill="#6ab87a" opacity="0.7"/>
<path d="M36,95 Q20,88 18,100 Q28,105 36,95Z" fill="#4a9e6a" opacity="0.8"/>
<path d="M37,95 Q52,86 56,99 Q47,106 37,95Z" fill="#6ab87a" opacity="0.7"/>
<circle cx="38" cy="30" r="2" fill="#c9a84c"/>
<circle cx="36" cy="60" r="2" fill="#c9a84c"/>
<circle cx="36" cy="95" r="2" fill="#c9a84c"/>
</svg>
</div>
<div class="card-body">
<h3>Nature / Organic</h3>
<p>Flowing vines, leaves, or botanical motifs. Soft curves, asymmetric detail. Pairs with eclectic or boho spaces.</p>
</div>
</div>
<div class="card" data-choice="minimal" onclick="toggleSelect(this)">
<div class="card-image" style="background:#1a1a2e;display:flex;align-items:center;justify-content:center;height:160px;">
<svg width="80" height="140" viewBox="0 0 80 140">
<!-- minimal rounded corner bead -->
<rect x="30" y="5" width="20" height="130" rx="10" fill="#2a2a4a" stroke="#888" stroke-width="1"/>
<rect x="33" y="5" width="14" height="130" rx="7" fill="#3a3a5a" stroke="none"/>
<!-- subtle groove lines -->
<line x1="40" y1="35" x2="40" y2="45" stroke="#555" stroke-width="3" stroke-linecap="round"/>
<line x1="40" y1="65" x2="40" y2="75" stroke="#555" stroke-width="3" stroke-linecap="round"/>
<line x1="40" y1="95" x2="40" y2="105" stroke="#555" stroke-width="3" stroke-linecap="round"/>
</svg>
</div>
<div class="card-body">
<h3>Minimalist</h3>
<p>Smooth rounded bead or simple stepped profile, maybe subtle groove accents. Blends into almost any interior.</p>
</div>
</div>
</div>
@@ -1,82 +0,0 @@
<h2>Twist rate + protrusion depth</h2>
<p class="subtitle">Per 25cm piece — front view. Ribbon is ~5cm wide, ~4mm thick. Corner is the left edge.</p>
<div class="cards">
<div class="card" data-choice="half-shallow" onclick="toggleSelect(this)">
<div class="card-image" style="background:#111;display:flex;align-items:center;justify-content:center;height:200px;">
<svg width="130" height="180" viewBox="0 0 130 180">
<!-- wall corner -->
<line x1="15" y1="0" x2="15" y2="180" stroke="#333" stroke-width="3"/>
<!-- ribbon: 180° twist, protrudes ~4cm -->
<text x="18" y="12" fill="#555" font-size="8" font-family="monospace">180° / 4cm out</text>
<!-- front edge of ribbon -->
<path d="M15,20 Q55,50 15,90 Q55,130 15,160" fill="none" stroke="#7c3aed" stroke-width="4" stroke-linecap="round"/>
<!-- back edge -->
<path d="M15,20 Q25,50 15,90 Q25,130 15,160" fill="none" stroke="#a78bfa" stroke-width="1.5" stroke-dasharray="3,2"/>
<!-- protrusion depth arrow -->
<line x1="15" y1="50" x2="55" y2="50" stroke="#555" stroke-width="1" stroke-dasharray="2,2"/>
<text x="58" y="53" fill="#555" font-size="8">4cm</text>
</svg>
</div>
<div class="card-body">
<h3>Gentle — 180° / 4cm</h3>
<p>One half-turn per piece. Subtle reach. Elegant, understated. Good if it sits close to the corner.</p>
</div>
</div>
<div class="card" data-choice="full-medium" onclick="toggleSelect(this)">
<div class="card-image" style="background:#111;display:flex;align-items:center;justify-content:center;height:200px;">
<svg width="130" height="180" viewBox="0 0 130 180">
<line x1="15" y1="0" x2="15" y2="180" stroke="#333" stroke-width="3"/>
<text x="18" y="12" fill="#555" font-size="8" font-family="monospace">360° / 8cm out</text>
<!-- full twist, medium protrusion -->
<path d="M15,20 Q75,40 75,55 Q75,70 15,90 Q75,110 75,125 Q75,140 15,160" fill="none" stroke="#7c3aed" stroke-width="4" stroke-linecap="round"/>
<path d="M15,20 Q20,40 20,55 Q20,70 15,90 Q20,110 20,125 Q20,140 15,160" fill="none" stroke="#a78bfa" stroke-width="1.5" stroke-dasharray="3,2"/>
<line x1="15" y1="45" x2="75" y2="45" stroke="#555" stroke-width="1" stroke-dasharray="2,2"/>
<text x="78" y="48" fill="#555" font-size="8">8cm</text>
</svg>
</div>
<div class="card-body">
<h3>Balanced — 360° / 8cm</h3>
<p>One full turn per piece. Confident reach into the room. You clearly see the twist from any angle.</p>
</div>
</div>
<div class="card" data-choice="full-deep" onclick="toggleSelect(this)">
<div class="card-image" style="background:#111;display:flex;align-items:center;justify-content:center;height:200px;">
<svg width="130" height="180" viewBox="0 0 130 180">
<line x1="15" y1="0" x2="15" y2="180" stroke="#333" stroke-width="3"/>
<text x="18" y="12" fill="#555" font-size="8" font-family="monospace">360° / 14cm out</text>
<!-- full twist, deep protrusion -->
<path d="M15,20 Q110,38 110,55 Q110,72 15,90 Q110,108 110,125 Q110,142 15,160" fill="none" stroke="#7c3aed" stroke-width="4" stroke-linecap="round"/>
<path d="M15,20 Q22,38 22,55 Q22,72 15,90 Q22,108 22,125 Q22,142 15,160" fill="none" stroke="#a78bfa" stroke-width="1.5" stroke-dasharray="3,2"/>
<line x1="15" y1="42" x2="110" y2="42" stroke="#555" stroke-width="1" stroke-dasharray="2,2"/>
<text x="95" y="38" fill="#555" font-size="8">14cm</text>
</svg>
</div>
<div class="card-body">
<h3>Bold — 360° / 14cm</h3>
<p>One full turn, reaches deep into room. Real sculptural presence. Dominant in the space.</p>
</div>
</div>
<div class="card" data-choice="double-deep" onclick="toggleSelect(this)">
<div class="card-image" style="background:#111;display:flex;align-items:center;justify-content:center;height:200px;">
<svg width="130" height="180" viewBox="0 0 130 180">
<line x1="15" y1="0" x2="15" y2="180" stroke="#333" stroke-width="3"/>
<text x="18" y="12" fill="#555" font-size="8" font-family="monospace">720° / 14cm out</text>
<!-- double twist, deep -->
<path d="M15,20 Q110,30 110,42 Q110,55 15,65 Q110,75 110,87 Q110,100 15,110 Q110,120 110,132 Q110,145 15,155" fill="none" stroke="#7c3aed" stroke-width="4" stroke-linecap="round"/>
<path d="M15,20 Q20,30 20,42 Q20,55 15,65 Q20,75 20,87 Q20,100 15,110 Q20,120 20,132 Q20,145 15,155" fill="none" stroke="#a78bfa" stroke-width="1.5" stroke-dasharray="3,2"/>
<line x1="15" y1="35" x2="110" y2="35" stroke="#555" stroke-width="1" stroke-dasharray="2,2"/>
<text x="95" y="31" fill="#555" font-size="8">14cm</text>
</svg>
</div>
<div class="card-body">
<h3>Intense — 720° / 14cm</h3>
<p>Two full turns per piece. Rapid tight twisting with bold reach. Very dynamic — almost turbulent energy.</p>
</div>
</div>
</div>
@@ -1,3 +0,0 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>
@@ -1 +0,0 @@
{"reason":"idle timeout","timestamp":1781212895650}
@@ -1,12 +0,0 @@
{"type":"server-started","port":49435,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:49435","screen_dir":"/home/becky/.claude-jail/.superpowers/brainstorm/421-1781209654/content","state_dir":"/home/becky/.claude-jail/.superpowers/brainstorm/421-1781209654/state"}
{"type":"screen-added","file":"/home/becky/.claude-jail/.superpowers/brainstorm/421-1781209654/content/style.html"}
{"type":"screen-added","file":"/home/becky/.claude-jail/.superpowers/brainstorm/421-1781209654/content/style-v2.html"}
{"source":"user-event","type":"click","text":"top view →\n \n \n \n \n \n \n \n \n \n \n \n \n \n Twisted Spine\n Ribbon or helix that spirals out from corner as it rises. Each 25cm piece is one full twist. Dramatic shadow play.","choice":"twist","id":null,"timestamp":1781210786563}
{"source":"user-event","type":"click","text":"top view →\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n Wave / Oscillation\n Alternating peaks that swing out along x-axis then y-axis as you go up. Smooth sinusoidal form, organic feel despite being parametric.","choice":"wave","id":null,"timestamp":1781210796933}
{"source":"user-event","type":"click","text":"top view →\n \n \n \n \n \n \n \n \n \n \n \n \n \n Twisted Spine\n Ribbon or helix that spirals out from corner as it rises. Each 25cm piece is one full twist. Dramatic shadow play.","choice":"twist","id":null,"timestamp":1781210797729}
{"type":"screen-added","file":"/home/becky/.claude-jail/.superpowers/brainstorm/421-1781209654/content/cross-section.html"}
{"source":"user-event","type":"click","text":"end-on\n \n \n \n \n front\n \n \n \n \n \n \n Flat Ribbon / Blade\n Thin wide strip — like a Möbius blade. Very dramatic from the side (wide face) but nearly disappears when viewed edge-on. Strong light/shadow contrast.","choice":"ribbon","id":null,"timestamp":1781210944918}
{"type":"screen-added","file":"/home/becky/.claude-jail/.superpowers/brainstorm/421-1781209654/content/twist-depth.html"}
{"source":"user-event","type":"click","text":"360° / 8cm out\n \n \n \n \n 8cm\n \n \n \n Balanced — 360° / 8cm\n One full turn per piece. Confident reach into the room. You clearly see the twist from any angle.","choice":"full-medium","id":null,"timestamp":1781211055275}
{"type":"screen-added","file":"/home/becky/.claude-jail/.superpowers/brainstorm/421-1781209654/content/waiting.html"}
{"type":"server-stopped","reason":"idle timeout"}
@@ -1 +0,0 @@
449
-850
View File
@@ -1,850 +0,0 @@
# android-webview-kiosk Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
>
> **REQUIRED SKILL:** Load the `lab` skill before starting — this is a lab project and the lab conventions are the source of truth for anything this plan doesn't cover.
**Goal:** Android TV app that fullscreen-renders one hardcoded Grafana dashboard URL in a WebView, launchable remotely on a Sony Bravia via its REST IP-control API (which Control4 will drive).
**Architecture:** Single-Activity Kotlin app, no androidx dependencies — `android.app.Activity` + `android.webkit.WebView` with JS enabled, immersive fullscreen, keep-screen-on, and exponential-backoff reload on load failure. `LEANBACK_LAUNCHER` intent filter makes it appear in the TV launcher and in the Bravia API's `getApplicationList`, so `setActiveApp` can launch it directly. Built with Gradle inside a nix dev shell (Android SDK via `androidenv`), distributed as a sideloaded APK.
**Tech Stack:** Kotlin 2.0.21, Android Gradle Plugin 8.7.3, compileSdk/targetSdk 34, minSdk 26 (TV's final firmware is Android 8.0), JDK 17, Gradle from nixpkgs, JUnit 4 for unit tests.
---
## Background (why this exists)
- TV: Sony Bravia KD-65XE9305, final firmware Android 8.0, **Android System WebView v138** (modern Chromium — renders current Grafana fine; verified on the TV).
- The TV's built-in `webappruntime` is too old for Grafana, so a custom WebView app is the rendering vehicle.
- Launch pipeline already proven with curl against the TV: `setPowerStatus` (wake) → `setActiveApp` (launch). Control4 driver will issue these; that driver is a **separate project, not in this plan**. This plan only documents the curl sequence the driver will replicate.
- Dashboard URL (hardcoded in the app): `https://grafana.c3c.cz/public-dashboards/381fe3e71e164eb99dd0b10e246a36e2`
## Lab-convention deviations (intentional, pre-approved)
This is an Android APK, not a Go service. The following lab defaults do not apply:
- **No container image, no Docker Hub push, no helm chart, no `/livez`/`/readyz`.** The artifact is an APK uploaded by CI.
- **No `nix build` of the app.** Gradle resolves Maven dependencies from the network, which pure nix builds can't do without heavyweight lockfile tooling. The flake provides a **dev shell only**; the build command is `gradle` inside `nix develop`. CI does the same.
- **Go conventions (slog etc.) don't apply** — Kotlin/Android project.
Everything else holds: git from commit 0, origin on Gitea, flake + `.envrc` at root, `.gitea/workflows/` CI, `CLAUDE.md` in repo.
## Execution constraints
- **The agent cannot run `nix`, `gradle`, `adb`, or `curl`-against-the-TV in the jail.** Every build/test/install step below is phrased as "Ask the user to run X and report output." Do not fabricate results; wait for the user's paste.
- The user runs nix commands per lab convention anyway.
## Repo location
```
<lab>/code/git.c3c.cz/c3c/android-webview-kiosk
```
`<lab>` is whichever of `/workspace/extras/lab` or `/mnt/storage/.lab` exists. All file paths below are relative to the repo root. Origin: `git@git.c3c.cz:c3c/android-webview-kiosk.git`.
## File structure
```
android-webview-kiosk/
├── flake.nix # dev shell: Android SDK, JDK 17, gradle, imagemagick
├── flake.lock # committed after first `nix flake lock`
├── .envrc # exactly: use flake .
├── .gitignore
├── CLAUDE.md # repo context for future agents
├── README.md # sideload + Sony API + Control4 integration notes
├── .gitea/workflows/build.yaml
├── settings.gradle.kts
├── build.gradle.kts # root: plugin versions only
├── gradle.properties
├── signing/release.keystore # committed; see Task 6 rationale
└── app/
├── build.gradle.kts
└── src/
├── main/
│ ├── AndroidManifest.xml
│ ├── java/cz/c3c/webviewkiosk/MainActivity.kt
│ ├── java/cz/c3c/webviewkiosk/BackoffPolicy.kt
│ └── res/drawable/{banner.png,icon.png}
└── test/java/cz/c3c/webviewkiosk/BackoffPolicyTest.kt
```
Android package name is `cz.c3c.webviewkiosk` (hyphens are illegal in package names, so the repo name's `android-` prefix is dropped there).
---
### Task 1: Repo skeleton + git init
**Files:**
- Create: `.envrc`, `.gitignore`
- [ ] **Step 1: Resolve lab root and verify target path is empty/missing**
Run: `ls /workspace/extras/lab 2>/dev/null || ls /mnt/storage/.lab 2>/dev/null` to pick `<lab>`. Then verify `<lab>/code/git.c3c.cz/c3c/android-webview-kiosk` does not exist or is empty (lab bootstrap-mode rule). If it has content, **stop and ask the user**.
- [ ] **Step 2: Create directories**
```bash
mkdir -p <lab>/code/git.c3c.cz/c3c/android-webview-kiosk
cd <lab>/code/git.c3c.cz/c3c/android-webview-kiosk
mkdir -p app/src/main/java/cz/c3c/webviewkiosk \
app/src/main/res/drawable \
app/src/test/java/cz/c3c/webviewkiosk \
.gitea/workflows signing
```
- [ ] **Step 3: Write `.envrc`**
```
use flake .
```
- [ ] **Step 4: Write `.gitignore`**
```
.direnv/
.envrc.local
# Gradle / Android
.gradle/
build/
local.properties
*.apk.idsig
# Editor / OS
.idea/
.vscode/
.DS_Store
```
Note: `build/` (no leading slash) intentionally matches both root and `app/build/`. `signing/release.keystore` is **not** ignored — it gets committed (Task 6).
- [ ] **Step 5: Init git and wire origin**
```bash
git init
git remote add origin git@git.c3c.cz:c3c/android-webview-kiosk.git
git add .envrc .gitignore
git commit -m "chore: repo skeleton"
```
### Task 2: Nix dev shell
**Files:**
- Create: `flake.nix`
- [ ] **Step 1: Write `flake.nix`**
```nix
{
# android-webview-kiosk — Android TV fullscreen WebView kiosk showing a
# hardcoded Grafana dashboard, remote-launched via Sony Bravia IP control.
#
# Deviation from lab Go template: this flake provides a DEV SHELL ONLY.
# Gradle fetches Maven deps from the network, so the APK is built impurely:
# nix develop --command gradle :app:assembleRelease
# There is no `nix build` output and no container/push pipeline.
description = "Android TV fullscreen WebView kiosk for the Grafana house dashboard";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/release-25.11";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs {
inherit system;
config = {
allowUnfree = true; # Android SDK
android_sdk.accept_license = true;
};
};
buildToolsVersion = "34.0.0";
androidComposition = pkgs.androidenv.composeAndroidPackages {
platformVersions = [ "34" ];
buildToolsVersions = [ buildToolsVersion ];
includeEmulator = false;
includeSystemImages = false;
includeNDK = false;
};
androidSdk = androidComposition.androidsdk;
sdkRoot = "${androidSdk}/libexec/android-sdk";
in
{
devShells.default = pkgs.mkShell {
packages = [
androidSdk
pkgs.jdk17
pkgs.gradle
pkgs.imagemagick # banner/icon generation
pkgs.android-tools # adb for sideloading
];
ANDROID_HOME = sdkRoot;
ANDROID_SDK_ROOT = sdkRoot;
# NixOS gotcha: AGP downloads a dynamically-linked aapt2 from Maven
# that can't run on NixOS. Point it at the SDK's own aapt2 instead.
GRADLE_OPTS = "-Dandroid.aapt2FromMavenOverride=${sdkRoot}/build-tools/${buildToolsVersion}/aapt2";
};
}
);
}
```
- [ ] **Step 2: Ask user to run, paste output**
```bash
nix flake lock
nix develop --command bash -c 'java -version && gradle --version && echo "ANDROID_HOME=$ANDROID_HOME" && ls "$ANDROID_HOME/build-tools"'
```
Expected: JDK 17, Gradle 8.x, build-tools `34.0.0` listed. If `androidenv` rejects `platformVersions = [ "34" ]` (nixpkgs pin too new/old), ask the user for the error and adjust the version set — do not guess silently.
- [ ] **Step 3: Commit**
```bash
git add flake.nix flake.lock
git commit -m "feat: nix dev shell with Android SDK 34, JDK 17, gradle"
```
### Task 3: Gradle project files
**Files:**
- Create: `settings.gradle.kts`, `build.gradle.kts`, `gradle.properties`, `app/build.gradle.kts`
- [ ] **Step 1: Write `settings.gradle.kts`**
```kotlin
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "android-webview-kiosk"
include(":app")
```
- [ ] **Step 2: Write root `build.gradle.kts`**
```kotlin
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
}
```
- [ ] **Step 3: Write `gradle.properties`**
```properties
org.gradle.jvmargs=-Xmx2g
android.useAndroidX=true
```
- [ ] **Step 4: Write `app/build.gradle.kts`**
```kotlin
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "cz.c3c.webviewkiosk"
compileSdk = 34
defaultConfig {
applicationId = "cz.c3c.webviewkiosk"
minSdk = 26 // Sony KD-65XE9305 final firmware = Android 8.0
targetSdk = 34
versionCode = 1 // bump on every release; adb install -r refuses downgrades
versionName = "0.1.0"
}
signingConfigs {
create("release") {
// Keystore is committed (private repo, LAN kiosk app) so every
// machine/CI produces the same signature and `adb install -r`
// upgrades work without uninstalling. Created in Task 6.
storeFile = rootProject.file("signing/release.keystore")
storePassword = "android-webview-kiosk"
keyAlias = "kiosk"
keyPassword = "android-webview-kiosk"
}
}
buildTypes {
release {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
testImplementation("junit:junit:4.13.2")
}
```
- [ ] **Step 5: Ask user to run, paste output**
```bash
nix develop --command gradle --no-daemon :app:tasks
```
Expected: task list prints, no configuration errors. Known failure mode: AGP 8.7.3 vs the nixpkgs Gradle version being too new — the error message names the supported range; bump the AGP version in root `build.gradle.kts` to the newest 8.x the message allows and re-run.
- [ ] **Step 6: Commit**
```bash
git add settings.gradle.kts build.gradle.kts gradle.properties app/build.gradle.kts
git commit -m "feat: gradle project, AGP 8.7.3, sdk 34/min 26"
```
### Task 4: BackoffPolicy (TDD)
Reload-retry delay logic — the only pure logic in the app, so it gets a unit test. Exponential backoff stops a dead Grafana from being hammered every 2 s forever by a 24/7 TV.
**Files:**
- Test: `app/src/test/java/cz/c3c/webviewkiosk/BackoffPolicyTest.kt`
- Create: `app/src/main/java/cz/c3c/webviewkiosk/BackoffPolicy.kt`
- [ ] **Step 1: Write the failing test**
```kotlin
package cz.c3c.webviewkiosk
import org.junit.Assert.assertEquals
import org.junit.Test
class BackoffPolicyTest {
@Test
fun firstDelayIsInitial() {
val b = BackoffPolicy(initialDelayMs = 2_000, maxDelayMs = 60_000)
assertEquals(2_000, b.nextDelayMs())
}
@Test
fun delayDoublesOnEachCall() {
val b = BackoffPolicy(initialDelayMs = 2_000, maxDelayMs = 60_000)
b.nextDelayMs()
assertEquals(4_000, b.nextDelayMs())
assertEquals(8_000, b.nextDelayMs())
}
@Test
fun delayCapsAtMax() {
val b = BackoffPolicy(initialDelayMs = 2_000, maxDelayMs = 5_000)
b.nextDelayMs() // 2000
b.nextDelayMs() // 4000
assertEquals(5_000, b.nextDelayMs())
assertEquals(5_000, b.nextDelayMs())
}
@Test
fun resetReturnsToInitial() {
val b = BackoffPolicy(initialDelayMs = 2_000, maxDelayMs = 60_000)
b.nextDelayMs()
b.nextDelayMs()
b.reset()
assertEquals(2_000, b.nextDelayMs())
}
}
```
- [ ] **Step 2: Ask user to run, verify it fails**
```bash
nix develop --command gradle --no-daemon :app:testReleaseUnitTest
```
Expected: compilation FAILS with unresolved reference `BackoffPolicy`.
- [ ] **Step 3: Write minimal implementation**
```kotlin
package cz.c3c.webviewkiosk
class BackoffPolicy(
private val initialDelayMs: Long,
private val maxDelayMs: Long,
private val factor: Double = 2.0,
) {
private var nextMs = initialDelayMs
fun nextDelayMs(): Long {
val current = nextMs
nextMs = (nextMs * factor).toLong().coerceAtMost(maxDelayMs)
return current
}
fun reset() {
nextMs = initialDelayMs
}
}
```
- [ ] **Step 4: Ask user to run, verify it passes**
```bash
nix develop --command gradle --no-daemon :app:testReleaseUnitTest
```
Expected: `BUILD SUCCESSFUL`, 4 tests passed.
- [ ] **Step 5: Commit**
```bash
git add app/src/test/java/cz/c3c/webviewkiosk/BackoffPolicyTest.kt \
app/src/main/java/cz/c3c/webviewkiosk/BackoffPolicy.kt
git commit -m "feat: exponential backoff policy for webview reload"
```
### Task 5: Manifest, resources, MainActivity
No unit tests here — pure Android framework glue; verification is on-device in Task 7.
**Files:**
- Create: `app/src/main/AndroidManifest.xml`
- Create: `app/src/main/res/drawable/banner.png`, `app/src/main/res/drawable/icon.png`
- Create: `app/src/main/java/cz/c3c/webviewkiosk/MainActivity.kt`
- [ ] **Step 1: Generate banner and icon**
`android:banner` (320×180) is **mandatory** for `LEANBACK_LAUNCHER` apps — without it the TV launcher shows nothing usable. Ask user to run inside the dev shell:
```bash
nix develop --command bash -c '
magick -size 320x180 xc:"#1f2430" -gravity center -fill white \
-pointsize 32 -annotate 0 "Grafana Kiosk" app/src/main/res/drawable/banner.png
magick -size 160x160 xc:"#1f2430" -gravity center -fill "#f46800" \
-pointsize 90 -annotate 0 "G" app/src/main/res/drawable/icon.png
'
```
- [ ] **Step 2: Write `app/src/main/AndroidManifest.xml`**
```xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- TV-only app: leanback required, touchscreen not -->
<uses-feature
android:name="android.software.leanback"
android:required="true" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<application
android:allowBackup="false"
android:banner="@drawable/banner"
android:icon="@drawable/icon"
android:label="Grafana Kiosk"
android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="landscape"
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|density">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
```
`usesCleartextTraffic="true"`: dashboard is HTTPS today, but home-LAN dashboards are often plain HTTP — this keeps a future URL swap to `http://` working without a manifest change. Acceptable for a LAN kiosk device.
- [ ] **Step 3: Write `app/src/main/java/cz/c3c/webviewkiosk/MainActivity.kt`**
```kotlin
package cz.c3c.webviewkiosk
import android.annotation.SuppressLint
import android.app.Activity
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.view.WindowManager
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
class MainActivity : Activity() {
companion object {
const val DASHBOARD_URL =
"https://grafana.c3c.cz/public-dashboards/381fe3e71e164eb99dd0b10e246a36e2"
}
private lateinit var webView: WebView
private val handler = Handler(Looper.getMainLooper())
private val backoff = BackoffPolicy(initialDelayMs = 2_000, maxDelayMs = 60_000)
private var lastLoadFailed = false
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
webView = WebView(this)
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
cacheMode = WebSettings.LOAD_DEFAULT
mediaPlaybackRequiresUserGesture = false
}
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
// onPageFinished also fires after a failed load (error page),
// so only treat a clean finish as recovery.
if (!lastLoadFailed) backoff.reset()
lastLoadFailed = false
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
// Subresource failures (one panel's query, a font) must not
// tear down the whole page; only main-frame failures retry.
if (!request.isForMainFrame) return
lastLoadFailed = true
handler.postDelayed(
{ view.loadUrl(DASHBOARD_URL) },
backoff.nextDelayMs(),
)
}
}
setContentView(webView)
hideSystemUi()
webView.loadUrl(DASHBOARD_URL)
}
override fun onResume() {
super.onResume()
hideSystemUi()
}
private fun hideSystemUi() {
@Suppress("DEPRECATION") // WindowInsetsController needs API 30; minSdk is 26
window.decorView.systemUiVisibility =
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
webView.destroy()
super.onDestroy()
}
}
```
- [ ] **Step 4: Ask user to run a debug build to verify it compiles**
```bash
nix develop --command gradle --no-daemon :app:assembleDebug
```
Expected: `BUILD SUCCESSFUL`, APK at `app/build/outputs/apk/debug/app-debug.apk`. (Release build waits for the keystore in Task 6.)
- [ ] **Step 5: Commit**
```bash
git add app/src/main/AndroidManifest.xml app/src/main/res \
app/src/main/java/cz/c3c/webviewkiosk/MainActivity.kt
git commit -m "feat: leanback webview kiosk activity"
```
### Task 6: Release keystore + signed APK
**Files:**
- Create: `signing/release.keystore`
- [ ] **Step 1: Ask user to generate the keystore**
```bash
nix develop --command keytool -genkeypair \
-keystore signing/release.keystore \
-alias kiosk -keyalg RSA -keysize 2048 -validity 10000 \
-storepass android-webview-kiosk -keypass android-webview-kiosk \
-dname "CN=android-webview-kiosk"
```
Rationale for committing it with a plaintext password: the repo is private, the key signs a LAN-only kiosk APK (no Play Store, no trust anchored to it), and a stable committed key means CI and any machine produce upgrade-compatible signatures. Do not reuse this key for anything else.
- [ ] **Step 2: Ask user to build + verify release APK**
```bash
nix develop --command gradle --no-daemon :app:assembleRelease
nix develop --command bash -c \
'apksigner verify --print-certs app/build/outputs/apk/release/app-release.apk'
```
Expected: `BUILD SUCCESSFUL`; apksigner prints the `CN=android-webview-kiosk` cert. (`apksigner` lives in SDK build-tools, already on the dev-shell PATH.)
- [ ] **Step 3: Commit**
```bash
git add signing/release.keystore
git commit -m "chore: committed release keystore for stable sideload signature"
```
### Task 7: Sideload and on-TV verification
No files — this is the acceptance test of Tasks 16. All commands run by the user. `$TV_IP` = the TV's LAN address, `$YOUR_PSK` = the IP-control pre-shared key already configured on the TV.
- [ ] **Step 1: Enable ADB on the TV (one-time, user, on the TV)**
Settings → About → press *Build* 7× (unlocks Developer options) → Developer options → enable **ADB debugging** / network debugging.
- [ ] **Step 2: Ask user to install**
```bash
nix develop --command bash -c "
adb connect \$TV_IP:5555 &&
adb install -r app/build/outputs/apk/release/app-release.apk
"
```
Expected: `Success`. If the TV shows an authorization dialog, accept it and re-run. Future upgrades: bump `versionCode` in `app/build.gradle.kts`, rebuild, same `adb install -r`.
- [ ] **Step 3: Verify launcher + manual launch**
"Grafana Kiosk" tile appears in the TV's app row; opening it shows the dashboard fullscreen.
- [ ] **Step 4: Verify Sony API sees the app**
```bash
curl -s -X POST http://$TV_IP/sony/appControl \
-H "X-Auth-PSK: $YOUR_PSK" -H 'Content-Type: application/json' \
-d '{"method":"getApplicationList","id":1,"params":[],"version":"1.0"}' \
| grep -o '"title":"Grafana Kiosk","uri":"[^"]*"'
```
Expected URI shape: `com.sony.dtv.cz.c3c.webviewkiosk.cz.c3c.webviewkiosk.MainActivity` — but **use whatever the list actually returns**; record the exact value in README.md (Task 8).
- [ ] **Step 5: Verify the full remote sequence from standby**
TV in standby, then:
```bash
# wake
curl -s -X POST http://$TV_IP/sony/system \
-H "X-Auth-PSK: $YOUR_PSK" -H 'Content-Type: application/json' \
-d '{"method":"setPowerStatus","id":55,"version":"1.0","params":[{"status":true}]}'
sleep 6
# launch kiosk (URI from Step 4)
curl -s -X POST http://$TV_IP/sony/appControl \
-H "X-Auth-PSK: $YOUR_PSK" -H 'Content-Type: application/json' \
-d '{"method":"setActiveApp","id":601,"version":"1.0","params":[{"uri":"<URI-FROM-STEP-4>"}]}'
```
Expected: TV wakes, dashboard appears. Unlike `webappruntime`, a real installed app may wake the TV implicitly on `setActiveApp` alone — test that too and note the result in README; if it does, the Control4 driver gets simpler.
### Task 8: CI workflow + repo docs
**Files:**
- Create: `.gitea/workflows/build.yaml`, `CLAUDE.md`, `README.md`
- [ ] **Step 1: Write `.gitea/workflows/build.yaml`**
Adapted from the lab Go template: same trigger/concurrency/runner-image shape, but tests + APK artifact instead of container push, and no `bump-chart` job (no chart).
```yaml
name: build
on:
push:
branches: [main]
tags: ['*']
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
container:
image: docksee/nixos-gitea:${{ vars.NIX_IMAGE_VERSION }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Unit tests
run: nix develop --command gradle --no-daemon :app:testReleaseUnitTest
- name: Build release APK
run: nix develop --command gradle --no-daemon :app:assembleRelease
- uses: actions/upload-artifact@v3
with:
name: android-webview-kiosk-apk
path: app/build/outputs/apk/release/app-release.apk
```
Known cost: Gradle re-downloads Maven deps every run (no cache step). Acceptable for a rarely-changing project; add a cache later only if it hurts.
- [ ] **Step 2: Write `CLAUDE.md`**
```markdown
# android-webview-kiosk
Android TV app: fullscreen WebView showing one hardcoded Grafana dashboard.
Runs on the living-room Sony Bravia KD-65XE9305 (Android 8.0, API 26;
Android System WebView updates independently via Play Store — currently v138).
Launched remotely through the Bravia REST IP-control API by Control4
(driver lives in a separate project). See README.md for the API sequence.
## Lab project — with deviations
This is a lab project (`lab` skill conventions apply) EXCEPT:
- No container image, no helm chart, no /livez//readyz — artifact is an APK.
- No `nix build`: gradle needs network for Maven deps. The flake is a dev
shell only. All builds run as `nix develop --command gradle ...`.
- Agents cannot run nix/gradle/adb here — ask the user to run commands
and report output.
## Commands (user-run, from repo root)
- Tests: `nix develop --command gradle --no-daemon :app:testReleaseUnitTest`
- Release APK: `nix develop --command gradle --no-daemon :app:assembleRelease`
`app/build/outputs/apk/release/app-release.apk`
- Sideload: `adb connect <tv-ip>:5555 && adb install -r <apk>`
(bump `versionCode` in app/build.gradle.kts first for upgrades)
## Key facts
- Package/applicationId: `cz.c3c.webviewkiosk`; repo name keeps the
`android-` prefix, the package can't (hyphens illegal).
- Dashboard URL hardcoded in `MainActivity.DASHBOARD_URL` — changing it
means rebuild + sideload. Intentional: keeps Control4 integration to a
single parameterless launch call.
- `signing/release.keystore` is committed on purpose (private repo,
LAN-only kiosk) so every build is upgrade-compatible. Don't reuse the key.
- minSdk 26 is a hard floor — the TV never gets newer Android.
- `LEANBACK_LAUNCHER` category + `android:banner` are what make the app
visible to the Bravia API's `getApplicationList`/`setActiveApp`. Don't
remove either.
```
- [ ] **Step 3: Write `README.md`**
```markdown
# android-webview-kiosk
Fullscreen WebView kiosk for Android TV. Shows the house Grafana dashboard:
<https://grafana.c3c.cz/public-dashboards/381fe3e71e164eb99dd0b10e246a36e2>
Target device: Sony Bravia KD-65XE9305 (Android 8.0). Built for remote
launch via the Bravia REST IP-control API, driven by Control4.
## Build
nix develop --command gradle --no-daemon :app:assembleRelease
APK: `app/build/outputs/apk/release/app-release.apk`
## Sideload
1. TV one-time: Settings → About → press *Build* 7× → Developer options →
enable ADB debugging.
2. `adb connect <tv-ip>:5555`
3. `adb install -r app/build/outputs/apk/release/app-release.apk`
Upgrades: bump `versionCode` in `app/build.gradle.kts`, rebuild, reinstall
with `-r`. Same committed keystore = no uninstall needed.
## Remote launch (Sony Bravia IP control)
TV prerequisite: IP control auth = "Normal and Pre-Shared Key", Remote
start enabled (wake from deep standby).
# 1. wake
curl -s -X POST http://$TV_IP/sony/system \
-H "X-Auth-PSK: $PSK" -H 'Content-Type: application/json' \
-d '{"method":"setPowerStatus","id":55,"version":"1.0","params":[{"status":true}]}'
# 2. launch (after TV reports active; ~6 s from quick standby,
# up to ~30 s from deep eco standby — poll getPowerStatus instead
# of sleeping blind in real integrations)
curl -s -X POST http://$TV_IP/sony/appControl \
-H "X-Auth-PSK: $PSK" -H 'Content-Type: application/json' \
-d '{"method":"setActiveApp","id":601,"version":"1.0","params":[{"uri":"<APP_URI>"}]}'
`<APP_URI>`: from `getApplicationList`, recorded after first install:
(fill in after Task 7 Step 4 — expected shape:
com.sony.dtv.cz.c3c.webviewkiosk.cz.c3c.webviewkiosk.MainActivity)
Whether `setActiveApp` alone wakes the TV from standby (skipping step 1):
(fill in after Task 7 Step 5)
Control4: a DriverWorks driver issuing this sequence is a separate project.
```
The two "(fill in after Task 7 …)" lines are the only deliberate blanks — they record device-measured facts and must be filled during Task 7, not invented.
- [ ] **Step 4: Commit**
```bash
git add .gitea/workflows/build.yaml CLAUDE.md README.md
git commit -m "feat: gitea CI (test + apk artifact), repo docs"
```
### Task 9: Handoff checklist (user actions)
Print this for the user at the end; none of it is automatable from the jail:
1. Create the Gitea repo `c3c/android-webview-kiosk` (no chart repo, no `chart-service-account` collaborator needed).
2. `git push -u origin main` — CI should run tests, build the APK, upload the artifact. Check the Actions run.
3. Fill the two README blanks with values measured in Task 7 (app URI, wake behavior), commit, push.
4. Follow-up project (separate plan, separate session): Control4 DriverWorks driver — one Composer command "Show Dashboard" → `setPowerStatus` → poll `getPowerStatus` until `active``setActiveApp` with the recorded URI. Load the `control4-driverworks` skill when planning it.
---
## Self-review notes
- Spec coverage: hardcoded-URL WebView app ✓, leanback launchability for `setActiveApp` ✓, fullscreen/keep-awake/error-recovery ✓, lab conventions (flake, envrc, gitea CI, CLAUDE.md, git from commit 0) ✓, sideload + remote-launch verification ✓. Control4 driver explicitly out of scope.
- Versions (AGP 8.7.3 / Kotlin 2.0.21 / Gradle-from-nixpkgs) are a known-good family but the nixpkgs pin moves; Tasks 23 contain explicit verify-and-adjust steps instead of assuming.
- Type consistency: `BackoffPolicy(initialDelayMs, maxDelayMs)`, `nextDelayMs()`, `reset()` match between Task 4 test, Task 4 impl, and Task 5 usage.
@@ -1,81 +0,0 @@
# Corner Decoration — Design Spec
**Date:** 2026-06-11
## Overview
A 1-meter-tall decorative piece for a 90° wall corner. Twisted flat ribbon blade emerging from a hollow triangular spine. Split into 4 × 25cm 3D-printed pieces. Cable routing channel through the spine.
## Geometry
### Overall
- Total height: 1000mm (4 × 250mm pieces)
- Corner angle: 90°
- Ribbon protrusion: 80mm from corner to ribbon centre axis (along 45° diagonal); ribbon extends 50mm either side → near edge at 30mm, far edge at 130mm from corner
- Twist rate: 360° per 250mm piece (ribbon returns to same orientation at each joint)
### Ribbon Blade
- Width: 100mm
- Thickness: 4mm
- Cross-section: flat rectangle (blade / ribbon profile)
- Path: helical, centered on the 45° diagonal axis of the corner
- One full twist (360°) per piece — top and bottom faces of each piece are geometrically identical
### Triangular Spine
- Profile: right isosceles triangle, legs ~20mm × 20mm
- Sits flush in the 90° corner; hypotenuse face at 45° to both walls
- Two flanges (~5mm thick, 20mm wide) extend along each wall surface for gluing
- Cable channel: 12mm diameter circular hole through the centroid of the triangle, running full height
- Ribbon attaches at the midpoint of the hypotenuse face
### Piece Joinery
- Alignment peg: 4mm square peg on top face, matching socket on bottom face
- Cable hole aligns automatically via peg
- Pieces glued wall-to-wall and to each other with construction adhesive
## Print Settings
| Parameter | Value |
|-----------|-------|
| Orientation | Vertical (upright) |
| Supports | Required (ribbon overhangs during twist) |
| Layer height | 0.15mm |
| Infill | 15% gyroid |
| Material | PLA |
| Pieces | 4 identical pieces |
Fallback if supports leave bad finish: split each piece into spine + ribbon, print separately, glue.
## Generation
Python script using **cadquery**. All dimensions as named constants at top of file. Outputs one STL per piece (all 4 identical).
### Key construction steps
1. Build spine: extrude right-isosceles triangle profile × 250mm, subtract 12mm cylinder for cable hole, add flanges on two legs
2. Build ribbon: sweep a 100mm × 4mm rectangle along a 250mm helix path (pitch = 250mm for 360°/piece), centered on the 45° diagonal axis
3. Boolean union spine + ribbon
4. Add alignment peg (top face) and socket (bottom face)
5. Export STL
### Parameters (all tunable)
```python
PIECE_HEIGHT = 250 # mm
NUM_PIECES = 4
RIBBON_WIDTH = 100 # mm
RIBBON_THICKNESS = 4 # mm
PROTRUSION = 80 # mm — corner to ribbon centre axis along 45° diagonal
TWIST_DEG = 360 # degrees per piece
SPINE_LEG = 20 # mm — triangle leg length
FLANGE_WIDTH = 20 # mm
FLANGE_THICKNESS = 5 # mm
CABLE_HOLE_DIA = 12 # mm
PEG_SIZE = 4 # mm square
PEG_HEIGHT = 5 # mm
```
## Mounting
- Glue flanges to both walls with construction adhesive (e.g. Liquid Nails)
- Stack pieces bottom-up, aligning pegs
- Thread cable through spine channel before gluing upper pieces
- No visible hardware