From 5e46cd7333ce0627a445c8cb9ea3e0da5130feb1 Mon Sep 17 00:00:00 2001 From: Arnie via Claude Date: Fri, 12 Jun 2026 00:29:40 +0200 Subject: [PATCH] chore: untrack planning artifacts, ignore .superpowers/ docs/ PLAN.md --- .gitignore | 5 + .../content/design-section1.html | 98 -- .../1131-1781214773/state/server-info | 1 - .../1131-1781214773/state/server.log | 2 - .../1131-1781214773/state/server.pid | 1 - .../421-1781209654/content/cross-section.html | 89 -- .../content/design-section1.html | 98 -- .../421-1781209654/content/style-v2.html | 122 --- .../421-1781209654/content/style.html | 86 -- .../421-1781209654/content/twist-depth.html | 82 -- .../421-1781209654/content/waiting.html | 3 - .../421-1781209654/state/server-stopped | 1 - .../421-1781209654/state/server.log | 12 - .../421-1781209654/state/server.pid | 1 - PLAN.md | 850 ------------------ .../2026-06-11-corner-decoration-design.md | 81 -- 16 files changed, 5 insertions(+), 1527 deletions(-) delete mode 100644 .superpowers/brainstorm/1131-1781214773/content/design-section1.html delete mode 100644 .superpowers/brainstorm/1131-1781214773/state/server-info delete mode 100644 .superpowers/brainstorm/1131-1781214773/state/server.log delete mode 100644 .superpowers/brainstorm/1131-1781214773/state/server.pid delete mode 100644 .superpowers/brainstorm/421-1781209654/content/cross-section.html delete mode 100644 .superpowers/brainstorm/421-1781209654/content/design-section1.html delete mode 100644 .superpowers/brainstorm/421-1781209654/content/style-v2.html delete mode 100644 .superpowers/brainstorm/421-1781209654/content/style.html delete mode 100644 .superpowers/brainstorm/421-1781209654/content/twist-depth.html delete mode 100644 .superpowers/brainstorm/421-1781209654/content/waiting.html delete mode 100644 .superpowers/brainstorm/421-1781209654/state/server-stopped delete mode 100644 .superpowers/brainstorm/421-1781209654/state/server.log delete mode 100644 .superpowers/brainstorm/421-1781209654/state/server.pid delete mode 100644 PLAN.md delete mode 100644 docs/superpowers/specs/2026-06-11-corner-decoration-design.md 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.

- -
-
Top-down cross section at any height
-
- - - - - - wall - wall - - - - - flange - - - - - - cable - - - - spine - - - - - - ribbon blade (10cm wide) - - - - - ~8cm from corner axis - - - - spine legs ~20mm - - - - 4mm thick - - - - 45° diagonal - - - - - -
-
- -
-

Key dimensions

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Piece height250mm
Total stack4 pieces × 250mm = 1000mm
Ribbon width100mm
Ribbon thickness4mm
Protrusion from corner~80mm (tip of ribbon at max extent)
Twist per piece360° — 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.

- -
-
Top-down cross section at any height
-
- - - - - - wall - wall - - - - - flange - - - - - - cable - - - - spine - - - - - - ribbon blade (10cm wide) - - - - - ~8cm from corner axis - - - - spine legs ~20mm - - - - 4mm thick - - - - 45° diagonal - - - - - -
-
- -
-

Key dimensions

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Piece height250mm
Total stack4 pieces × 250mm = 1000mm
Ribbon width100mm
Ribbon thickness4mm
Protrusion from corner~80mm (tip of ribbon at max extent)
Twist per piece360° — 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