docs(report): v0.147.0-.3 feedback slice 1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
This commit is contained in:
2026-07-19 10:03:17 +02:00
parent 63a22e5911
commit 10ca8ac884
+151 -68
View File
@@ -1,87 +1,170 @@
# REPORT — most recent implementation
# REPORT — controller v0.147.0 → v0.147.3: feedback slice 1 (2026-07-19)
## felhom-controller v0.146.0 — nav polish: styled scrollbars + collapsible sidebar groups — 2026-07-18
**Baseline v0.146.0 → LIVE v0.147.3 on demo guest 9201.** Green at every version:
`go build ./... && go vet ./... && go test ./...` (23 packages) plus `template_id_gate`,
`emoji_gate`, `native_confirm_gate`, `offbox_rename_gate`, `app_row_dedup_gate`, `mojibake_gate`.
**Commits:** `24d23b8` (feature) + `fd93020` (render tests). **UI-only** — no behavioural, backup or
restore surface touched. **Deployed and live on demo guest 9201** (`0.146.0 Up (healthy)`).
> `docker_run_volume_path_gate` fails on `internal/appexport/estimate.go:179`. **Pre-existing on
> HEAD and untouched by this release** — verified by stashing this work and re-running the gate.
> Not fixed here (out of scope, and it is someone's call whether that `-v :ro` du probe gets an
> allowlist entry or a rewrite).
**Green:** `go build ./... && go vet ./... && go test ./...` all pass. Design-v2 gates
`template_id_gate`, `emoji_gate`, `native_confirm_gate`, `offbox_rename_gate`, `mojibake_gate`,
`app_row_dedup_gate`**all PASS**.
## The problem
### 1. Scrollbars (`style.css`)
The systemic complaint, twice in one evening: **you press a button and nothing happens.** No
progress, no ETA, no named result. Three worst offenders fixed on the two patterns already in the
codebase — the deploy 3-step panel and the storage-init status poll. **No new framework**; that is
now ROADMAP **R-45**, because three targeted cards were worth shipping tonight and a framework was
not.
The platform default is a light, chunky bar that reads as a bright stripe against the navy and
competes with the content it is scrolling. Now thin and hairline-coloured: `scrollbar-width: thin` +
`scrollbar-color` for Firefox **and** `::-webkit-scrollbar` (8px, thumb `--line`, hover `--text-3`,
`--radius`) for WebKit/Blink — both declared, because neither alone covers the browsers customers
actually use. The two surfaces that really scroll take their own panel background as the track
(`.sidebar``--bg-2`, `html``--bg-0`) so the gutter never shows through as a lighter channel.
Tokens only, no raw hexes, no inline styles.
Three of the four versions below exist because the cards were watched against **real runs on the
demo box**. Each follow-up is a thing no unit test would have surfaced.
### 2. Collapsible nav groups (`layout.html` + `style.css`, vanilla JS)
---
Tárhely, Biztonsági mentés and Megosztás are accordions with a chevron indicator; **exactly one open
at a time**, and clicking the open one closes it. Groups without sub-items (Vezérlőpult,
Alkalmazások, Rendszermonitor, Debug) are untouched plain links. Hungarian labels unchanged. No nav
restructuring beyond the accordion behaviour; no design-token changes.
## 4a — a verification restore names its result
Four decisions worth recording:
The completion flash said the app had been restored „ellenőrző mappába a meghajtón". *Which* folder,
on *which* drive, was invisible — so the customer could not go and look at the thing they had just
asked for. And nothing anywhere listed what these restores had accumulated, so copies piled up and
the only way to find them was SSH.
- **The header is a real `<button>`**, so keyboard and assistive-tech reachability come for free
rather than being simulated with `tabindex`/`role` on a div. `aria-expanded` + `aria-controls` +
a `:focus-visible` outline.
- **Nothing became unreachable when the header stopped being a link.** Every group's own landing
page is *also* its first sub-item (`/storage` → Meghajtók, `/backups` → Áttekintés, `/sharing`
Hálózati megosztás). This was checked before the conversion, not assumed — and it is now asserted
by a test, because if someone later drops one of those sub-links the destination becomes
unreachable **silently**, the header still looking clickable.
- **Progressive enhancement.** The group containing the active page is rendered open **server-side**
(`.is-open`), so the correct group is open before any JS executes and stays open if JS never runs.
The listener only handles clicks.
- **No layout jump.** The collapse animates `grid-template-rows: 0fr → 1fr` (with `min-height: 0` +
`overflow: hidden` on the sub-list) rather than `max-height`. That animates to the content's REAL
height, so there is no magic number to drift when a group gains or loses an item — the specific way
a `max-height` accordion rots. The toggle reserves its 3px active border as `transparent` so
becoming active adds no width shift. Transitions are .18s, and both the collapse and the chevron
rotation are disabled under `prefers-reduced-motion: reduce`.
- The flash now carries the **full path**.
- The restore page gained **„Meglévő ellenőrző másolatok"** — app · size · date · path — each with a
double-confirmed **„Másolat törlése"** (the house inline `felhomConfirm` idiom, never native
`confirm()`).
- `backups/offsite-restore` was open-coded in three places; it now has one home
(`offsiteRestoreRootFor`). A test pins the path in the flash to the path in the listing, so the
customer can never be told about a directory the page cannot show or remove.
### 3. Verification — and what could not be verified
**The delete is the only one this release adds**, so it names a **stack, never a path**: the Manager
resolves that name inside a root it computed itself and refuses anything landing outside.
Red-proofed — neutralise the name guard and `stack: ""` resolves to the offsite-restore **root** and
`os.RemoveAll` takes every copy with it. Every refusal is asserted as a **non-effect** (the
neighbouring copy and the live data are still on disk afterwards).
**The screenshot leg was not done, and the reason is worth recording.** The demo controller's
password is **customer-owned** since the claim flow — Viktor set it as customer zero during the
2026-07-18 rehearsal — so the credentials on the build server are stale. A curl-login against the
live container returns HTTP 200 with `<title>Bejelentkezés — Felhom</title>` and sets no session
cookie, i.e. the login is refused. The visual leg needs Viktor's browser.
Honest note: the `HasPrefix` containment check behind that name guard could **not** be red-proofed
independently — with `isSafeStackName` in front of it, no input the API accepts can reach it with an
escaping path. It is kept as defence-in-depth and labelled in the test file as unproven-by-test
rather than dressed up as a red-proof it does not have.
Rather than assert nothing, `internal/web/nav_accordion_test.go` pins the **server-side half**
the part a screenshot would confirm only for whichever page happened to be open — rendering through
the real shared layout via the same `loadTemplates()` path the server uses:
### Live (endpoint-exact, through the real handlers)
| Test | Property |
|---|---|
| `TestNavGroup_ActiveGroupRendersOpenServerSide` | all 7 sub-pages open their own group, with `aria-expanded=true` and an `.active` toggle, and **exactly one** group open — the count is asserted, not just the group we expected |
| `TestNavGroup_PageOutsideAnyGroupOpensNothing` | a top-level page (dashboard) forces nothing open |
| `TestNavGroup_EveryGroupLandingPageSurvivesAsASubItem` | each group's landing page still exists as a sub-link — the property the `<a>``<button>` conversion depended on |
| `TestNavGroup_HeaderIsARealButtonWithAControlsTarget` | the toggle is a real button and its `aria-controls` targets an element that exists |
Real verification restore of `calibre-web`, then the guard matrix:
**Red-proofed:** removing `{{if $storageOpen}} is-open{{end}}` from `layout.html` fails both the
open-group assertion and the exactly-one-open count on `storage` and `storage-network`, then passes
again when restored.
```
message: "A(z) calibre-web visszaállítva ellenőrző mappába:
/mnt/felhom-drives/hdd_1/backups/offsite-restore/calibre-web
(a meglévő adatok változatlanok)."
listing: calibre-web · 430.4 KB · …/offsite-restore/calibre-web · [Másolat törlése]
Independently, the deployed 0.146.0 container was confirmed to be **serving the new stylesheet**
(the `nav-group-toggle` / `scrollbar-width` rules are present in the CSS it returns), so the live
image really does carry this change.
stack=../primary -> refused; copy AND backups/primary both still present
missing confirm -> refused; nothing changed
real delete -> copy gone; backups/primary SURVIVES; page -> „Nincs ellenőrző másolat"
```
### 4. Pre-existing, deliberately not bundled
---
`docker_run_volume_path_gate.py` still fails on `internal/appexport/estimate.go:179`. That is
ROADMAP **R-29**, unrelated to this change, verified to fail identically on the untouched tree — and
R-29 itself says not to bundle its fix into an unrelated feature commit.
## 4b — Megosztás enable shows what it is waiting for
### 5. Fleet note — the golden still bakes 0.143.0
Enabling sharing ran `ReconcileSamba()` **synchronously inside the POST handler**. On a box whose
golden had not baked `felhom-samba` that is `compose up -d` pulling ~100MB from a private registry:
minutes of an apparently-hung form post, then „Beállítás mentve." whether or not anything came up.
v0.146.0 is live on the demo box, but the golden image was **not** rebuilt this session (deferred
rather than half-run — it is a nested-VM snapshot-revert procedure). So a freshly installed box
would land on 0.143.0 and self-update. Tracked on the felhom.eu ROADMAP pre-invite checklist, whose
target moved from 0.145.x to **0.146.0**.
Detached + polled now, with a card that distinguishes **„képfájl letöltése"** (image genuinely
absent — the multi-minute case) from **„indítás"** (already baked — seconds). The distinction is
decided *before* the work starts, because afterwards the image is always present and the card could
never truthfully say „letöltés".
- Success is **probed, not inferred**`compose up -d` exits 0 on a crash-loop, so the terminal
state is container liveness.
- `nil` from reconcile also covers "deliberately deployed nothing because there is no household
password yet"; that now gets its own message instead of a card spinning forever.
- The **password** form starts the same job — with `UserSet` false reconcile deploys nothing, so on a
fresh box *that*, not the enable toggle, is where the pull actually happens.
**Test scope, stated honestly:** the unit tests cover the single-flight slot, snapshot isolation and
the phase vocabulary. They do **not** drive `runSambaEnsureJob` end-to-end — the Server field is the
concrete `*stacks.Manager`, not an interface, and introducing one purely for this card was more churn
than the slice warranted. Pulling-vs-starting and the probed terminal state are covered by live
validation instead. That seam belongs to R-45.
Live: `/sharing/status` returns `{"ok":true,"data":{"phase":"running","running":true,"error":""}}`;
the page carries the card and the poll.
---
## 4c — „Távoli mentés most" streams real progress
restic was already reporting bytes and percentages; the runner seam used `CombinedOutput()` and threw
them away. The manual run now passes `--json`, scans stdout line-by-line, and the page shows total
bytes, percent and the app being pushed. **Manual only** — the nightly stays silent, pinned by a test
that fails if the scheduled path ever passes `--json` or publishes progress.
The poll also now **arms unconditionally**. It used to start only if the page already rendered
„Fut…", which loses a race the manual trigger always runs: the POST redirects and the page renders
before the detached goroutine writes `LastStatus=running`, so the poll never armed and the customer
watched a static page during the very run they had just started.
Red-proofed twice: break the parser -> the percent assertion fails; drop the wiring -> the `--json`
assertion fails.
### What three live runs actually taught (v0.147.1 -> .3)
**Run 1 — immich reported `0%` for 40+ seconds.** The parser was not broken. On an incremental run
where nothing changed restic transfers nothing: `bytes_done` is `omitempty`, so it is not even in the
JSON, and `percent_done` stays 0. Confirmed by capturing the real schema from
`backup --dry-run --json` on restic 0.14.0 in the controller image rather than guessing; those lines
are now quoted verbatim in the type's doc comment. **A byte-only bar is indistinguishable from a hang
in the common case** — shipping it would have traded "no feedback" for "feedback that says 0% and
looks stuck". -> **v0.147.1** parses and publishes `files_done`/`total_files`.
**Run 2 — bookstack showed clean byte progress (100%, 154.0 MB, 7/7), immich sat at `1 of 46 files,
0 bytes` for 42 seconds.** restic 0.14 only counts a file when it **completes**, so an app dominated
by one large archive (immich's ~430MB volume tar) freezes *both* counters. No percentage can move.
-> **v0.147.2** shows what restic *does* keep reporting: the file being processed and elapsed
seconds. „1 / 46 fájl (430.2 MB) · feldolgozás alatt: immich_upload.tar · 42 mp" is a different
message from „0%".
**Run 3 — the per-app legs finished in ~15s; the remaining 40 of a 57-second run was the shares leg
and `forget --prune`,** during which the card sat frozen on „calibre-web — 8 / 8 fájl". The same
silence, relocated to the end of the run. -> **v0.147.3** adds a run **phase**; the post-app stages
announce themselves and app-scoped counters clear when a phase starts.
Final live trace — the whole 57-second run is narrated, no frozen state:
```
t+6s app=immich
t+9s app=bookstack
t+12s app=calibre-web
t+15s phase=shares
t+18s ... t+54s phase=retention
t+57s done
```
---
## Also: golden/controller infra-image drift closed at the source
`infra.Images()` derives the list from the existing pins; `--print-infra-images` prints it; the golden
bake now asks the controller binary it is about to bake instead of carrying its own copy. That copy
had already drifted — `felhom-samba` was never added to it, so the golden baked 3 of 4, **which is
why enabling Megosztás pulled at runtime in the first place**. A test parses the const block out of
the source with `go/ast` and fails if a pin is added without reaching `Images()`; red-proofed.
Verified against the real published image on the live box — all four returned.
## Validation method
Live validation was **endpoint-exact** (the exact URLs the UI posts to, through the real handlers and
the real restic/samba paths), not browser click-through: the controller UI is password-gated and I do
not enter credentials into web forms. No server logic was skipped — only rendering. The rendered
markup was checked by fetching the pages themselves and asserting on the emitted HTML.
## Not done
- **Floor not raised** — Viktor decides floor timing.
- No golden rebuild, no manifest saves, nothing on Peti's arc.
- No SMB server-side changes — the `\\FELHOM` access issue stays client-side diagnosis first.
- ROADMAP: **R-45** (unified async-job feedback) and **R-46** (verification-copy browse + expiry)
registered in `felhom.eu/documentation/backlog/ROADMAP.md`.