diff --git a/.superpowers/brainstorm/1131-1781214773/content/design-section1.html b/.superpowers/brainstorm/1131-1781214773/content/design-section1.html
new file mode 100644
index 0000000..d51ee14
--- /dev/null
+++ b/.superpowers/brainstorm/1131-1781214773/content/design-section1.html
@@ -0,0 +1,98 @@
+
Design — Cross-section (top view)
+
How the piece sits in the corner. All measurements approximate — confirm before generating.
+
+
+
Top-down cross section at any height
+
+
+
+
+
+
+
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
new file mode 100644
index 0000000..59f0058
--- /dev/null
+++ b/.superpowers/brainstorm/1131-1781214773/state/server-info
@@ -0,0 +1 @@
+{"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
new file mode 100644
index 0000000..1319ffe
--- /dev/null
+++ b/.superpowers/brainstorm/1131-1781214773/state/server.log
@@ -0,0 +1,2 @@
+{"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
new file mode 100644
index 0000000..43cff8a
--- /dev/null
+++ b/.superpowers/brainstorm/1131-1781214773/state/server.pid
@@ -0,0 +1 @@
+1139
diff --git a/.superpowers/brainstorm/421-1781209654/content/cross-section.html b/.superpowers/brainstorm/421-1781209654/content/cross-section.html
new file mode 100644
index 0000000..919f74c
--- /dev/null
+++ b/.superpowers/brainstorm/421-1781209654/content/cross-section.html
@@ -0,0 +1,89 @@
+
Cross-section of the twisted spine
+
The shape that rotates as it rises — shown end-on, then how twist looks from 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.
+
+
+
+
+
+
+
+
+
+
Round Rod / Cylinder
+
Circular cross-section spirals around corner axis. Twist is subtle — the silhouette curves gently. Smooth and elegant, easiest to print.
+
+
+
+
+
+
+
+
+
+
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.
+
+
+
+
+
+
+
+
+
+
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
new file mode 100644
index 0000000..d51ee14
--- /dev/null
+++ b/.superpowers/brainstorm/421-1781209654/content/design-section1.html
@@ -0,0 +1,98 @@
+
Design — Cross-section (top view)
+
How the piece sits in the corner. All measurements approximate — confirm before generating.
+
+
+
Top-down cross section at any height
+
+
+
+
+
+
+
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
new file mode 100644
index 0000000..8c0d228
--- /dev/null
+++ b/.superpowers/brainstorm/421-1781209654/content/style-v2.html
@@ -0,0 +1,122 @@
+
More abstract — protrudes into room space
+
Pieces extend outward from corner (x+y axis), not just flat trim
+
+
+
+
+
+
+
+
+
Twisted Spine
+
Ribbon or helix that spirals out from corner as it rises. Each 25cm piece is one full twist. Dramatic shadow play.
+
+
+
+
+
+
+
+
+
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.
+
+
+
+
+
+
+
+
+
Rotating Fins
+
Horizontal blades that rotate 90° with each piece — alternating x-axis and y-axis protrusion. Strong geometric rhythm, industrial-abstract feel.
+
+
+
+
+
+
+
+
+
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
new file mode 100644
index 0000000..669d8d9
--- /dev/null
+++ b/.superpowers/brainstorm/421-1781209654/content/style.html
@@ -0,0 +1,86 @@
+
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
new file mode 100644
index 0000000..35b1128
--- /dev/null
+++ b/.superpowers/brainstorm/421-1781209654/content/twist-depth.html
@@ -0,0 +1,82 @@
+
Twist rate + protrusion depth
+
Per 25cm piece — front view. Ribbon is ~5cm wide, ~4mm thick. Corner is the left edge.
+
+
+
+
+
+
+
+
+
Gentle — 180° / 4cm
+
One half-turn per piece. Subtle reach. Elegant, understated. Good if it sits close to the corner.
+
+
+
+
+
+
+
+
+
Balanced — 360° / 8cm
+
One full turn per piece. Confident reach into the room. You clearly see the twist from any angle.
+
+
+
+
+
+
+
+
+
Bold — 360° / 14cm
+
One full turn, reaches deep into room. Real sculptural presence. Dominant in the space.
+
+
+
+
+
+
+
+
+
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
new file mode 100644
index 0000000..ef07652
--- /dev/null
+++ b/.superpowers/brainstorm/421-1781209654/content/waiting.html
@@ -0,0 +1,3 @@
+
+
Continuing in terminal...
+
diff --git a/.superpowers/brainstorm/421-1781209654/state/server-stopped b/.superpowers/brainstorm/421-1781209654/state/server-stopped
new file mode 100644
index 0000000..f0d9aac
--- /dev/null
+++ b/.superpowers/brainstorm/421-1781209654/state/server-stopped
@@ -0,0 +1 @@
+{"reason":"idle timeout","timestamp":1781212895650}
diff --git a/.superpowers/brainstorm/421-1781209654/state/server.log b/.superpowers/brainstorm/421-1781209654/state/server.log
new file mode 100644
index 0000000..e3f6d0a
--- /dev/null
+++ b/.superpowers/brainstorm/421-1781209654/state/server.log
@@ -0,0 +1,12 @@
+{"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
new file mode 100644
index 0000000..2b20fd0
--- /dev/null
+++ b/.superpowers/brainstorm/421-1781209654/state/server.pid
@@ -0,0 +1 @@
+449
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..d49bc9c
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,850 @@
+# 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
new file mode 100644
index 0000000..8f09441
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-11-corner-decoration-design.md
@@ -0,0 +1,81 @@
+# 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
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..9be3f88
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,61 @@
+{
+ "nodes": {
+ "flake-utils": {
+ "inputs": {
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1731533236,
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1781214844,
+ "narHash": "sha256-GiKi1nonuwTHG1mTrwFTllfMSN2rvHQZUgpW0nQX/qM=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "9bc9b4b4e7b1e7ce7ec2a8355b4369541b90cd6a",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "release-25.11",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "flake-utils": "flake-utils",
+ "nixpkgs": "nixpkgs"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..eb0ca79
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,63 @@
+{
+ # 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";
+ };
+ }
+ );
+}