diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md
index 7d33b90..d9a9b3e 100644
--- a/.ai/HANDOFF.md
+++ b/.ai/HANDOFF.md
@@ -114,3 +114,30 @@ Append-only role handoff log. Each role adds one entry when its step is complete
- Status: done. Commit message read from the T-005 next_task entry.
- All five board tasks are done; T-005 is the only task completed in this invocation and receives its own Conventional Commit.
+
+### T-006 — plan — 2026-10-13T00:00:00Z
+
+| Field | Value |
+|-------|-------|
+| Agent | claude |
+| Summary | Planned Android home-screen support: cartoon washing-machine icon set, web manifest "Home Lanundry", embedded routes, head tags, README section and tests; no service worker or TLS |
+| Files Changed | .ai/PLAN.md, .ai/TASKS.md, .ai/HANDOFF.md, ROADMAP.md |
+| Next Role | implement |
+
+### T-006 — next_task (dev/all_task) — 2026-10-13T09:08:01Z
+
+| Field | Value |
+|-------|-------|
+| Agent | codex |
+| Summary | Added Home Lanundry home-screen manifest, SVG/PNG washing-machine icons, embedded routes, page head tags, tests first, and Android shortcut documentation |
+| Validation | fmt/vet/test and web race tests PASS; Docker build/runtime and 360px Chrome manifest/icon smoke PASS; review PASS; E2E NOT_CONFIGURED |
+| Commit | feat(web): add Home Lanundry home-screen icon and web manifest |
+| Next Role | dev commit |
+
+- Includes the existing T-006 roadmap and planning artifacts. No new dependencies; SVG is the PNG source of truth.
+- PR limitation: `aide pr --dry-run` refuses main; repository instructions require staying on the current branch.
+
+### T-006 — commit_task (all_task) — 2026-10-13T09:09:05Z
+
+- Status: done. Commit message read from the T-006 next_task entry.
+- All six board tasks are done; T-006 is the only task completed in this invocation and receives its own Conventional Commit.
diff --git a/.ai/PLAN.md b/.ai/PLAN.md
index 5b6fcae..65f4594 100644
--- a/.ai/PLAN.md
+++ b/.ai/PLAN.md
@@ -2,25 +2,44 @@
Status: **ready_for_implement**
-Goal: implement `ROADMAP.md` Priority 3 — show the "Laundry done" status text in green (`#1a7f37`). T-001..T-004 are done and unchanged in behavior.
+Goal: implement `ROADMAP.md` Priority 4 — make the page addable to an Android home screen as "Home Lanundry" with a cartoon washing-machine icon. T-001..T-005 are done and unchanged in behavior.
## Scope
-- CSS-only change in the page plus docs and a test. No backend, JSON contract, status rule or message text changes.
-- Out of scope: dark mode, other state colors, layout changes.
-
-## T-005 — Green "Laundry done" text
-
-Background: `internal/web/static/index.html` sets the `#status` element's class to `data.state`, so the done state gets class `done` (currently unstyled, i.e. default `#111`).
-
-### Files to change
-1. `internal/web/handler_test.go` (tests first): in `TestPageAndRouting`, add `.done { color: #1a7f37; }` (match the exact rule text used in the page) to the list of expected page substrings. Run `go test ./internal/web` and confirm it fails before the page change.
-2. `internal/web/static/index.html`: add `.done { color: #1a7f37; }` next to the existing `.running` / `.waiting` rules (around line 12).
-3. `README.md`: in the status table (line ~26) change the "Laundry done" color from "Default text color" to "Green". Check the rest of the README for any other mention of the done color and keep it consistent.
-4. `ROADMAP.md`: already updated by the planner (Priority 1 table row and Priority 3); no further edit needed.
+- New static assets (manifest and icons), embedded routes, three `
` tags, tests, README.
+- Out of scope: service worker, offline mode, TLS, install prompt UI, any change to status rule, JSON contract, SSE, page layout or the ``.
+- Transport stays plain HTTP, so Chrome offers "Add to Home screen" (shortcut), not "Install app". This is accepted; document it.
+
+## T-006 — Home screen icon and web manifest
+
+### Design decisions
+- Assets live in `internal/web/static/` and are embedded with `go:embed` (an `embed.FS` over a fixed file list, or individual `[]byte` vars). No new dependencies.
+- Routes (all `GET`, exact paths, registered on the existing mux):
+ - `/manifest.webmanifest` — `Content-Type: application/manifest+json`
+ - `/icons/icon.svg` — `image/svg+xml`
+ - `/icons/icon-192.png`, `/icons/icon-512.png`, `/icons/icon-maskable-512.png` — `image/png`
+ - `/apple-touch-icon.png` (180x180) — `image/png`
+- Static assets use `Cache-Control: no-cache` (revalidate); the existing `no-store` on page/API stays.
+- Manifest content: `name` and `short_name` "Home Lanundry", `start_url` `/`, `scope` `/`, `display` `standalone`, `theme_color` and `background_color` `#ffffff`, `icons` = 192 and 512 (`purpose: any`) plus maskable 512 (`purpose: maskable`).
+- Icon art: flat cartoon front-loading washing machine — light-blue rounded-square background, white/pastel body, round porthole with blue water and a few bubbles, two or three control knobs and a small display on top. For the maskable variant, extend the background full-bleed and keep all artwork inside the central 80% safe zone.
+
+### Files to change (tests first)
+1. `internal/web/handler_test.go` (write first, confirm failing):
+ - Table test for each new route: 200, expected `Content-Type`, non-empty body; PNG routes start with the PNG signature; `POST` returns 405.
+ - Manifest test: decode JSON; assert `name`/`short_name` == "Home Lanundry", `display` == "standalone", `start_url` == "/", and that every `icons[].src` resolves to a route that returns 200 and has the declared `sizes` (decode PNG config) and `type`; one icon has `purpose` "maskable".
+ - Extend `TestPageAndRouting` expected substrings with `rel="manifest"`, `name="theme-color"`, `rel="apple-touch-icon"`.
+2. `internal/web/static/icon.svg` (new): source art, 512x512 viewBox.
+3. `internal/web/static/icon-192.png`, `icon-512.png`, `icon-maskable-512.png`, `apple-touch-icon.png` (new): rendered from the SVG (full-bleed variant for maskable). Suggested tools: headless `google-chrome --screenshot` or Pillow; keep the generation script out of the repo, or add it as `internal/web/static/README`-free `scripts/` only if small — implementer's choice, but the committed SVG must be the source of truth and PNGs must visually match it. Visually check the result (view the PNGs, including a circle-crop check of the maskable one).
+4. `internal/web/static/manifest.webmanifest` (new): content as above.
+5. `internal/web/handler.go`: embed the new assets, register the routes with correct content types and cache header, update the package/handler doc comment to mention the manifest and icons.
+6. `internal/web/static/index.html`: add to ``: ``, ``, ``, ``. Do not change title, layout or script.
+7. `Dockerfile`: no change expected (it already copies `internal`); verify the build still embeds the new files (`.dockerignore` must not exclude `*.png`/`*.svg`/`*.webmanifest` — check and fix if it does).
+8. `README.md`: new "Add to Android home screen" subsection (before "Limitations"): open the page in Chrome on Android, menu → "Add to Home screen"/"Install app" if offered, name "Home Lanundry". State the plain-HTTP limitation: without HTTPS Chrome only offers a home-screen shortcut with the icon, not a full install, and there is no offline mode. Add the new routes (`/manifest.webmanifest`, `/icons/*`, `/apple-touch-icon.png`) to the endpoint documentation. Do not touch the AI Workflow section.
+9. `ROADMAP.md`: already updated by the planner; no further edit.
### Validation
-- `go fmt ./...`, `go vet ./...`, `go test ./...`.
-- Optional smoke: run the service, force a done state, and confirm the text renders green on a 360px viewport.
+- `go fmt ./...`, `go vet ./...`, `go test ./...` (plus `-race` on `./internal/web`).
+- Smoke: run the service, `curl -sI` each new route for status and content type; open the page in Chrome DevTools (Application → Manifest) at a 360px viewport and confirm no manifest or icon errors and a correct icon preview.
+- Docker build if available (confirm assets are embedded).
### Commit message
-`feat(web): show "Laundry done" status text in green`
+`feat(web): add Home Lanundry home-screen icon and web manifest`
diff --git a/.ai/REVIEW.md b/.ai/REVIEW.md
index b67deff..152a78d 100644
--- a/.ai/REVIEW.md
+++ b/.ai/REVIEW.md
@@ -66,3 +66,17 @@
- Environment retry during implementation: sandbox blocked the Go build-cache write; approved rerun reached the expected failing test. No review retries or weakened assertions.
- Workflow limitation: `aide pr --dry-run` refuses the current `main` branch. Kept the current branch as instructed; no PR created.
- Verdict: PASS, approved for the per-task commit under `all_task`.
+
+## Task: T-006
+
+### Review Round 1 — 2026-10-13T09:08:01Z
+
+- Findings: no required fixes or semantic concerns. The complete staged/unstaged diff matches the manifest, exact asset routes, head tags, icon design, and documentation plan. Status logic, API JSON, SSE, page title/layout/script, and the README AI Workflow section are unchanged.
+- Tests first: `go test ./internal/web` failed on all six missing routes, missing page tags, and missing manifest before implementation; all assertions passed after implementation and were retained.
+- Validation: `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./internal/web`, and staged/unstaged diff checks PASS.
+- Assets: PNGs rendered from the committed SVG using headless Chrome; inspected all PNGs plus a circular mask preview. Pixel checks confirm the maskable icon is fully opaque and all foreground artwork is inside the central safe circle. All declared PNG dimensions decode correctly.
+- Packaging/browser smoke: Docker build and temporary `node /tmp/mqtt-washingmachine-t006-smoke.mjs` PASS. The scratch image serves byte-identical embedded assets; curl HEAD, GET, content types, cache headers, and POST 405 pass for all six routes. Chrome at 360px reports no DevTools manifest errors and decodes every icon at its declared size. Page, API, initial SSE event, and clean shutdown pass. Screenshot: `/tmp/mqtt-washingmachine-t006-mobile.png`.
+- E2E: NOT_CONFIGURED as a repository command. Temporary Docker/Chrome smoke passed; physical Android home-screen installation was not exercised.
+- Environment retries: Docker build metadata and the Git index were read-only in the sandbox; approved reruns passed. No review validation retries or weakened assertions.
+- Workflow limitation: `aide pr --dry-run` refuses the current `main` branch. Kept the current branch as instructed; no PR created.
+- Verdict: PASS, approved for the per-task commit under `all_task`.
diff --git a/.ai/TASKS.md b/.ai/TASKS.md
index a28ce7b..508e0d8 100644
--- a/.ai/TASKS.md
+++ b/.ai/TASKS.md
@@ -25,3 +25,4 @@ Command expectations:
| T-003 | Dockerfile, docker-compose example, .dockerignore, README documentation | done | Image builds (or noted if Docker unavailable); README documents rules, env vars, deployment, limitations; existing AI Workflow section untouched | 2026-10-12T11:22:18Z: fmt/vet/test PASS; Docker build/runtime PASS; README workflow preserved | none |
| T-004 | SSE push (`GET /api/events`) so the page refreshes on each valid MQTT message: sensor Hub, `Sampler.OnReceive`, SSE handler with keep-alive and write-deadline override, page EventSource with 60 s polling fallback, README/comment updates, tests first | done | Valid message -> `update` event to all clients; invalid -> none; immediate `update` on connect; keep-alive sent; stream outlives 10 s WriteTimeout; slow subscriber never blocks Receive; disconnects/shutdown clean up; `/api/status` unchanged; README documents endpoint and updated refresh behavior; fmt/vet/test (+race) pass | 2026-10-12T13:18:18Z: fmt/vet/test/race PASS; MQTT/SSE/Chrome smoke PASS; review PASS | none |
| T-005 | Show "Laundry done" status text in green (`.done { color: #1a7f37 }` in index.html), page test first, README status table update | done | Page contains the `.done` green rule (test fails before, passes after); text and status rule unchanged; README table says Green; fmt/vet/test pass | 2026-10-12T13:24:25Z: tests first, fmt/vet/test PASS; review PASS; E2E NOT_CONFIGURED | none |
+| T-006 | Home screen support: cartoon washing-machine icon (SVG + 192/512/maskable/apple-touch PNGs), `manifest.webmanifest` ("Home Lanundry", standalone), embedded routes with correct content types, head tags in index.html, README section, tests first | done | New routes return 200 with correct content types and valid PNG sizes; manifest JSON has name/short_name "Home Lanundry", display standalone, start_url /, 192/512 and maskable icons that all resolve; page has manifest/theme-color/apple-touch-icon tags; page layout, title, status rule, JSON and SSE unchanged; README documents the home-screen steps and plain-HTTP limitation; fmt/vet/test pass | 2026-10-13T09:08:01Z: tests first; fmt/vet/test/web-race PASS; Docker/Chrome/icon smoke PASS; review PASS | none |
diff --git a/README.md b/README.md
index 4941487..1e6443f 100644
--- a/README.md
+++ b/README.md
@@ -169,6 +169,30 @@ idle connections longer than the 25-second keep-alive interval.
To inspect events manually, run `curl -N http://localhost:8080/api/events` while
publishing valid and invalid payloads to the test topic.
+### Add to Android home screen
+
+1. Open `http://:8080/` in Chrome on your Android phone.
+2. Open Chrome's menu and choose **Add to Home screen** (or **Install app** if
+ offered).
+3. Use the name **Home Lanundry** and confirm. The shortcut uses the cartoon
+ washing-machine icon.
+
+On plain HTTP on the home LAN, Chrome offers a home-screen shortcut rather than
+a full app installation, which requires HTTPS. The manifest requests a standalone
+window where supported; a shortcut may open in a regular browser tab. There is no
+offline mode: the phone must be able to reach the running service.
+
+The binary embeds these home-screen assets; no extra files need to be deployed.
+All routes below accept `GET` and send `Cache-Control: no-cache` for revalidation:
+
+| Route | Content type | Purpose |
+|-------|--------------|---------|
+| `/manifest.webmanifest` | `application/manifest+json` | Home Lanundry name, launch settings, and icon references |
+| `/icons/icon.svg` | `image/svg+xml` | SVG source and browser icon |
+| `/icons/icon-192.png`, `/icons/icon-512.png` | `image/png` | 192×192 and 512×512 home-screen icons |
+| `/icons/icon-maskable-512.png` | `image/png` | 512×512 full-bleed icon with artwork inside the maskable safe zone |
+| `/apple-touch-icon.png` | `image/png` | 180×180 touch icon |
+
### Limitations
- In-memory state only: a restart clears the window. With the default interval,
diff --git a/ROADMAP.md b/ROADMAP.md
index 964342e..5c2b158 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -69,3 +69,23 @@ Objective: show the "Laundry done" status text in green.
### Open
- None.
+
+## Priority 4
+
+Objective: let the page be added to an Android phone's home screen with a cartoon washing-machine icon, named "Home Lanundry".
+
+### Decided
+
+- Mechanism: web app manifest (`manifest.webmanifest`) plus icons, served by the Go backend and embedded in the binary like `index.html`.
+- Name: "Home Lanundry" (both `name` and `short_name`).
+- Icon: cartoon front-loading washing machine (round porthole with blue water and bubbles, a few control knobs, flat pastel style) on a light-blue rounded background. Delivered as SVG source plus PNG at 192x192 and 512x512, and a maskable variant with the drawing inside the safe zone.
+- Manifest: `display: standalone`, white `theme_color` and `background_color`, `start_url` `/`.
+- Page: add ``, `theme-color` meta and `apple-touch-icon`.
+- Transport: plain HTTP on the home LAN, no TLS (decided out of scope). Chrome on Android therefore offers "Add to Home screen" (a shortcut with the icon), not a full "Install app". This is accepted.
+- No service worker and no offline support.
+- Status rule, JSON contract, SSE behavior and page layout stay unchanged.
+- Docs: README gets a short section on adding the page to the home screen and the plain-HTTP limitation; handler tests cover the new routes and content types.
+
+### Open
+
+- None.
diff --git a/internal/web/handler.go b/internal/web/handler.go
index 2b943e4..37f16e2 100644
--- a/internal/web/handler.go
+++ b/internal/web/handler.go
@@ -1,4 +1,5 @@
-// Package web serves the laundry status API and embedded phone-first page.
+// Package web serves the laundry status API, phone-first page, and embedded
+// home-screen manifest and icons.
package web
import (
@@ -15,6 +16,24 @@ import (
//go:embed static/index.html
var indexHTML []byte
+//go:embed static/manifest.webmanifest
+var manifest []byte
+
+//go:embed static/icon.svg
+var iconSVG []byte
+
+//go:embed static/icon-192.png
+var icon192 []byte
+
+//go:embed static/icon-512.png
+var icon512 []byte
+
+//go:embed static/icon-maskable-512.png
+var iconMaskable512 []byte
+
+//go:embed static/apple-touch-icon.png
+var appleTouchIcon []byte
+
var keepAliveInterval = 25 * time.Second
// EventSource provides coalesced refresh notifications and subscriber cleanup.
@@ -24,6 +43,7 @@ type EventSource interface {
// NewHandler fetches a fresh snapshot for each API request. Disabling caching
// keeps reloads, push refreshes, and polling from showing a previous state.
+// The embedded home-screen manifest and icons require cache revalidation.
// The server must cancel request contexts on shutdown to close active streams.
func NewHandler(snapshot func() status.Result, events EventSource) http.Handler {
mux := http.NewServeMux()
@@ -40,6 +60,23 @@ func NewHandler(snapshot func() status.Result, events EventSource) http.Handler
mux.HandleFunc("GET /api/events", func(w http.ResponseWriter, r *http.Request) {
serveEvents(w, r, events)
})
+ for _, asset := range []struct {
+ path, contentType string
+ data []byte
+ }{
+ {"/manifest.webmanifest", "application/manifest+json", manifest},
+ {"/icons/icon.svg", "image/svg+xml", iconSVG},
+ {"/icons/icon-192.png", "image/png", icon192},
+ {"/icons/icon-512.png", "image/png", icon512},
+ {"/icons/icon-maskable-512.png", "image/png", iconMaskable512},
+ {"/apple-touch-icon.png", "image/png", appleTouchIcon},
+ } {
+ mux.HandleFunc("GET "+asset.path, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", asset.contentType)
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Write(asset.data)
+ })
+ }
return mux
}
diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go
index 03c69bf..48d131b 100644
--- a/internal/web/handler_test.go
+++ b/internal/web/handler_test.go
@@ -2,9 +2,12 @@ package web
import (
"bufio"
+ "bytes"
"context"
"encoding/json"
"errors"
+ "fmt"
+ "image/png"
"io"
"net/http"
"net/http/httptest"
@@ -62,7 +65,7 @@ func TestPageAndRouting(t *testing.T) {
if page.Code != http.StatusOK || !strings.HasPrefix(page.Header().Get("Content-Type"), "text/html") {
t.Fatalf("page response: %+v", page.Result())
}
- for _, text := range []string{`name="viewport"`, "Waiting for data...", "/api/status", "60000", "EventSource", "/api/events", "toFixed(2)", "--", "clamp(", ".done { color: #1a7f37; }"} {
+ for _, text := range []string{`name="viewport"`, "Waiting for data...", "/api/status", "60000", "EventSource", "/api/events", "toFixed(2)", "--", "clamp(", ".done { color: #1a7f37; }", `Laundry status`, `rel="manifest" href="/manifest.webmanifest"`, `name="theme-color" content="#ffffff"`, `rel="apple-touch-icon" href="/apple-touch-icon.png"`, `rel="icon" href="/icons/icon.svg" type="image/svg+xml"`} {
if !strings.Contains(page.Body.String(), text) {
t.Errorf("page missing %q", text)
}
@@ -84,6 +87,104 @@ func TestPageAndRouting(t *testing.T) {
}
}
+func TestHomeScreenAssets(t *testing.T) {
+ handler := NewHandler(func() status.Result { return status.Evaluate(nil) }, &sensor.Hub{})
+ for _, tt := range []struct {
+ path, contentType string
+ size int
+ }{
+ {"/manifest.webmanifest", "application/manifest+json", 0},
+ {"/icons/icon.svg", "image/svg+xml", 0},
+ {"/icons/icon-192.png", "image/png", 192},
+ {"/icons/icon-512.png", "image/png", 512},
+ {"/icons/icon-maskable-512.png", "image/png", 512},
+ {"/apple-touch-icon.png", "image/png", 180},
+ } {
+ t.Run(tt.path, func(t *testing.T) {
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, tt.path, nil))
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d; want 200", response.Code)
+ }
+ if got := response.Header().Get("Content-Type"); got != tt.contentType {
+ t.Errorf("content type = %q; want %q", got, tt.contentType)
+ }
+ if got := response.Header().Get("Cache-Control"); got != "no-cache" {
+ t.Errorf("cache control = %q; want no-cache", got)
+ }
+ if response.Body.Len() == 0 {
+ t.Fatal("empty asset")
+ }
+ if tt.size != 0 {
+ if !bytes.HasPrefix(response.Body.Bytes(), []byte("\x89PNG\r\n\x1a\n")) {
+ t.Fatal("missing PNG signature")
+ }
+ img, err := png.Decode(response.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := img.Bounds(); got.Dx() != tt.size || got.Dy() != tt.size {
+ t.Errorf("PNG bounds = %v; want %dx%d", got, tt.size, tt.size)
+ }
+ }
+ for _, request := range []struct {
+ method, path string
+ code int
+ }{
+ {http.MethodPost, tt.path, http.StatusMethodNotAllowed},
+ {http.MethodGet, tt.path + "/missing", http.StatusNotFound},
+ } {
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, httptest.NewRequest(request.method, request.path, nil))
+ if response.Code != request.code {
+ t.Errorf("%s %s = %d; want %d", request.method, request.path, response.Code, request.code)
+ }
+ }
+ })
+ }
+}
+
+func TestHomeScreenManifest(t *testing.T) {
+ handler := NewHandler(func() status.Result { return status.Evaluate(nil) }, &sensor.Hub{})
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/manifest.webmanifest", nil))
+ var manifest struct {
+ Name string `json:"name"`
+ ShortName string `json:"short_name"`
+ StartURL string `json:"start_url"`
+ Scope string `json:"scope"`
+ Display string `json:"display"`
+ ThemeColor string `json:"theme_color"`
+ BackgroundColor string `json:"background_color"`
+ Icons []struct{ Src, Sizes, Type, Purpose string } `json:"icons"`
+ }
+ if err := json.Unmarshal(response.Body.Bytes(), &manifest); err != nil {
+ t.Fatal(err)
+ }
+ if manifest.Name != "Home Lanundry" || manifest.ShortName != "Home Lanundry" || manifest.StartURL != "/" || manifest.Scope != "/" || manifest.Display != "standalone" || manifest.ThemeColor != "#ffffff" || manifest.BackgroundColor != "#ffffff" {
+ t.Errorf("unexpected manifest: %+v", manifest)
+ }
+ var variants []string
+ for _, icon := range manifest.Icons {
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, icon.Src, nil))
+ if response.Code != http.StatusOK || icon.Type != "image/png" || response.Header().Get("Content-Type") != icon.Type {
+ t.Fatalf("icon %+v: status %d, content type %q", icon, response.Code, response.Header().Get("Content-Type"))
+ }
+ cfg, err := png.DecodeConfig(response.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := fmt.Sprintf("%dx%d", cfg.Width, cfg.Height); got != icon.Sizes {
+ t.Errorf("icon %s: size %s; declared %s", icon.Src, got, icon.Sizes)
+ }
+ variants = append(variants, icon.Sizes+" "+icon.Purpose)
+ }
+ if want := []string{"192x192 any", "512x512 any", "512x512 maskable"}; !reflect.DeepEqual(variants, want) {
+ t.Errorf("icon variants = %v; want %v", variants, want)
+ }
+}
+
func TestStatusAPIReadsCurrentSnapshot(t *testing.T) {
var window status.Window
handler := NewHandler(func() status.Result { return status.Evaluate(window.Readings()) }, &sensor.Hub{})
diff --git a/internal/web/static/apple-touch-icon.png b/internal/web/static/apple-touch-icon.png
new file mode 100644
index 0000000..d92f6d9
Binary files /dev/null and b/internal/web/static/apple-touch-icon.png differ
diff --git a/internal/web/static/icon-192.png b/internal/web/static/icon-192.png
new file mode 100644
index 0000000..580f4b5
Binary files /dev/null and b/internal/web/static/icon-192.png differ
diff --git a/internal/web/static/icon-512.png b/internal/web/static/icon-512.png
new file mode 100644
index 0000000..97007d2
Binary files /dev/null and b/internal/web/static/icon-512.png differ
diff --git a/internal/web/static/icon-maskable-512.png b/internal/web/static/icon-maskable-512.png
new file mode 100644
index 0000000..6364859
Binary files /dev/null and b/internal/web/static/icon-maskable-512.png differ
diff --git a/internal/web/static/icon.svg b/internal/web/static/icon.svg
new file mode 100644
index 0000000..67a858c
--- /dev/null
+++ b/internal/web/static/icon.svg
@@ -0,0 +1,33 @@
+
diff --git a/internal/web/static/index.html b/internal/web/static/index.html
index 2462ae0..a311405 100644
--- a/internal/web/static/index.html
+++ b/internal/web/static/index.html
@@ -4,6 +4,10 @@
Laundry status
+
+
+
+