diff --git a/.gitignore b/.gitignore
index f97bb76..fdc3837 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,11 @@
.direnv/
.envrc.local
+# Planning / agent artifacts
+.superpowers/
+docs/
+PLAN.md
+
# Gradle / Android
.gradle/
build/
diff --git a/.superpowers/brainstorm/1131-1781214773/content/design-section1.html b/.superpowers/brainstorm/1131-1781214773/content/design-section1.html
deleted file mode 100644
index d51ee14..0000000
--- a/.superpowers/brainstorm/1131-1781214773/content/design-section1.html
+++ /dev/null
@@ -1,98 +0,0 @@
-
Design — Cross-section (top view)
-How the piece sits in the corner. All measurements approximate — confirm before generating.
-
-
-
-
-
-
-
-
-
- wall
- wall
-
-
-
-
- flange
-
-
-
-
-
- cable
-
-
-
- spine
-
-
-
-
-
- ribbon blade (10cm wide)
-
-
-
-
- ~8cm from corner axis
-
-
-
- spine legs ~20mm
-
-
-
- 4mm thick
-
-
-
- 45° diagonal
-
-
-
-
-
-
-
-
-
-
Key dimensions
-
-
- Piece height
- 250mm
-
-
- Total stack
- 4 pieces × 250mm = 1000mm
-
-
- Ribbon width
- 100mm
-
-
- Ribbon thickness
- 4mm
-
-
- Protrusion from corner
- ~80mm (tip of ribbon at max extent)
-
-
- Twist per piece
- 360° — ribbon returns to same angle at top/bottom
-
-
- Spine triangle legs
- ~20mm × 20mm (right isosceles, sits in 90° corner)
-
-
- Cable channel
- ~8mm circular hole through centre of spine
-
-
-
-
-Does this cross-section look right? Let me know in terminal.
diff --git a/.superpowers/brainstorm/1131-1781214773/state/server-info b/.superpowers/brainstorm/1131-1781214773/state/server-info
deleted file mode 100644
index 59f0058..0000000
--- a/.superpowers/brainstorm/1131-1781214773/state/server-info
+++ /dev/null
@@ -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"}
diff --git a/.superpowers/brainstorm/1131-1781214773/state/server.log b/.superpowers/brainstorm/1131-1781214773/state/server.log
deleted file mode 100644
index 1319ffe..0000000
--- a/.superpowers/brainstorm/1131-1781214773/state/server.log
+++ /dev/null
@@ -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"}
diff --git a/.superpowers/brainstorm/1131-1781214773/state/server.pid b/.superpowers/brainstorm/1131-1781214773/state/server.pid
deleted file mode 100644
index 43cff8a..0000000
--- a/.superpowers/brainstorm/1131-1781214773/state/server.pid
+++ /dev/null
@@ -1 +0,0 @@
-1139
diff --git a/.superpowers/brainstorm/421-1781209654/content/cross-section.html b/.superpowers/brainstorm/421-1781209654/content/cross-section.html
deleted file mode 100644
index 919f74c..0000000
--- a/.superpowers/brainstorm/421-1781209654/content/cross-section.html
+++ /dev/null
@@ -1,89 +0,0 @@
-Cross-section of the twisted spine
-The shape that rotates as it rises — shown end-on, then how twist looks from front
-
-
-
-
-
-
-
- end-on
-
-
-
-
- front
-
-
-
-
-
-
-
Flat Ribbon / Blade
-
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.
-
-
-
-
-
-
- end-on
-
-
-
- front
-
-
-
-
-
-
-
Round Rod / Cylinder
-
Circular cross-section spirals around corner axis. Twist is subtle — the silhouette curves gently. Smooth and elegant, easiest to print.
-
-
-
-
-
-
- end-on
-
-
-
- front
-
-
-
-
-
-
-
-
-
Star / Multi-Point
-
3–5 pointed star cross-section. As it twists the spikes create a dynamic rippling silhouette. Most visually complex, high detail on print.
-
-
-
-
-
-
- end-on
-
-
-
- front
-
-
-
-
-
-
-
-
-
-
Triangle / Prism
-
3-sided prism twisting. Faces alternate between facing you and turning away — creates a strong angular rhythm as you walk past.
-
-
-
-
diff --git a/.superpowers/brainstorm/421-1781209654/content/design-section1.html b/.superpowers/brainstorm/421-1781209654/content/design-section1.html
deleted file mode 100644
index d51ee14..0000000
--- a/.superpowers/brainstorm/421-1781209654/content/design-section1.html
+++ /dev/null
@@ -1,98 +0,0 @@
-Design — Cross-section (top view)
-How the piece sits in the corner. All measurements approximate — confirm before generating.
-
-
-
-
-
-
-
-
-
- wall
- wall
-
-
-
-
- flange
-
-
-
-
-
- cable
-
-
-
- spine
-
-
-
-
-
- ribbon blade (10cm wide)
-
-
-
-
- ~8cm from corner axis
-
-
-
- spine legs ~20mm
-
-
-
- 4mm thick
-
-
-
- 45° diagonal
-
-
-
-
-
-
-
-
-
-
Key dimensions
-
-
- Piece height
- 250mm
-
-
- Total stack
- 4 pieces × 250mm = 1000mm
-
-
- Ribbon width
- 100mm
-
-
- Ribbon thickness
- 4mm
-
-
- Protrusion from corner
- ~80mm (tip of ribbon at max extent)
-
-
- Twist per piece
- 360° — ribbon returns to same angle at top/bottom
-
-
- Spine triangle legs
- ~20mm × 20mm (right isosceles, sits in 90° corner)
-
-
- Cable channel
- ~8mm circular hole through centre of spine
-
-
-
-
-Does this cross-section look right? Let me know in terminal.
diff --git a/.superpowers/brainstorm/421-1781209654/content/style-v2.html b/.superpowers/brainstorm/421-1781209654/content/style-v2.html
deleted file mode 100644
index 8c0d228..0000000
--- a/.superpowers/brainstorm/421-1781209654/content/style-v2.html
+++ /dev/null
@@ -1,122 +0,0 @@
-More abstract — protrudes into room space
-Pieces extend outward from corner (x+y axis), not just flat trim
-
-
-
-
-
-
-
- top view →
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Twisted Spine
-
Ribbon or helix that spirals out from corner as it rises. Each 25cm piece is one full twist. Dramatic shadow play.
-
-
-
-
-
-
- top view →
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Wave / Oscillation
-
Alternating peaks that swing out along x-axis then y-axis as you go up. Smooth sinusoidal form, organic feel despite being parametric.
-
-
-
-
-
-
- top view →
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- → x
- ↓ y
-
-
-
-
Rotating Fins
-
Horizontal blades that rotate 90° with each piece — alternating x-axis and y-axis protrusion. Strong geometric rhythm, industrial-abstract feel.
-
-
-
-
-
-
- front view →
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Branching / Fractal
-
Tree-like structure grows from corner outward. Each piece has a self-similar branching pattern at different scale. Bold, sculptural.
-
-
-
-
diff --git a/.superpowers/brainstorm/421-1781209654/content/style.html b/.superpowers/brainstorm/421-1781209654/content/style.html
deleted file mode 100644
index 669d8d9..0000000
--- a/.superpowers/brainstorm/421-1781209654/content/style.html
+++ /dev/null
@@ -1,86 +0,0 @@
-What style for the corner decoration?
-1 meter tall corner trim, wall-to-wall 90° corner, split into 4 × 25cm pieces
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Geometric / Modern
-
Clean angular lines, diamond or triangular facets, sharp repeating pattern. Looks great in contemporary interiors.
-
-
-
-
-
-
-
Classical / Ornate
-
Curved profiles, egg-and-dart or bead motifs, traditional molding style. Suits older or formal interiors.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Nature / Organic
-
Flowing vines, leaves, or botanical motifs. Soft curves, asymmetric detail. Pairs with eclectic or boho spaces.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Minimalist
-
Smooth rounded bead or simple stepped profile, maybe subtle groove accents. Blends into almost any interior.
-
-
-
-
diff --git a/.superpowers/brainstorm/421-1781209654/content/twist-depth.html b/.superpowers/brainstorm/421-1781209654/content/twist-depth.html
deleted file mode 100644
index 35b1128..0000000
--- a/.superpowers/brainstorm/421-1781209654/content/twist-depth.html
+++ /dev/null
@@ -1,82 +0,0 @@
-Twist rate + protrusion depth
-Per 25cm piece — front view. Ribbon is ~5cm wide, ~4mm thick. Corner is the left edge.
-
-
-
-
-
-
-
-
-
- 180° / 4cm out
-
-
-
-
-
-
- 4cm
-
-
-
-
Gentle — 180° / 4cm
-
One half-turn per piece. Subtle reach. Elegant, understated. Good if it sits close to the corner.
-
-
-
-
-
-
-
- 360° / 8cm out
-
-
-
-
- 8cm
-
-
-
-
Balanced — 360° / 8cm
-
One full turn per piece. Confident reach into the room. You clearly see the twist from any angle.
-
-
-
-
-
-
-
- 360° / 14cm out
-
-
-
-
- 14cm
-
-
-
-
Bold — 360° / 14cm
-
One full turn, reaches deep into room. Real sculptural presence. Dominant in the space.
-
-
-
-
-
-
-
- 720° / 14cm out
-
-
-
-
- 14cm
-
-
-
-
Intense — 720° / 14cm
-
Two full turns per piece. Rapid tight twisting with bold reach. Very dynamic — almost turbulent energy.
-
-
-
-
diff --git a/.superpowers/brainstorm/421-1781209654/content/waiting.html b/.superpowers/brainstorm/421-1781209654/content/waiting.html
deleted file mode 100644
index ef07652..0000000
--- a/.superpowers/brainstorm/421-1781209654/content/waiting.html
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
Continuing in terminal...
-
diff --git a/.superpowers/brainstorm/421-1781209654/state/server-stopped b/.superpowers/brainstorm/421-1781209654/state/server-stopped
deleted file mode 100644
index f0d9aac..0000000
--- a/.superpowers/brainstorm/421-1781209654/state/server-stopped
+++ /dev/null
@@ -1 +0,0 @@
-{"reason":"idle timeout","timestamp":1781212895650}
diff --git a/.superpowers/brainstorm/421-1781209654/state/server.log b/.superpowers/brainstorm/421-1781209654/state/server.log
deleted file mode 100644
index e3f6d0a..0000000
--- a/.superpowers/brainstorm/421-1781209654/state/server.log
+++ /dev/null
@@ -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"}
diff --git a/.superpowers/brainstorm/421-1781209654/state/server.pid b/.superpowers/brainstorm/421-1781209654/state/server.pid
deleted file mode 100644
index 2b20fd0..0000000
--- a/.superpowers/brainstorm/421-1781209654/state/server.pid
+++ /dev/null
@@ -1 +0,0 @@
-449
diff --git a/PLAN.md b/PLAN.md
deleted file mode 100644
index d49bc9c..0000000
--- a/PLAN.md
+++ /dev/null
@@ -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
-
-```
-/code/git.c3c.cz/c3c/android-webview-kiosk
-```
-
-`` 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 ``. Then verify `/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 /code/git.c3c.cz/c3c/android-webview-kiosk
-cd /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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-```
-
-`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 1–6. 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":""}]}'
-```
-
-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 :5555 && adb install -r `
- (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:
-
-
-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 :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":""}]}'
-
-``: 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 2–3 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.
diff --git a/docs/superpowers/specs/2026-06-11-corner-decoration-design.md b/docs/superpowers/specs/2026-06-11-corner-decoration-design.md
deleted file mode 100644
index 8f09441..0000000
--- a/docs/superpowers/specs/2026-06-11-corner-decoration-design.md
+++ /dev/null
@@ -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