fix(services): 500 from @disabled on <x-btn>; add R12 browser-verify rule

/services returned HTTP 500: @disabled() on a Blade *component* injects an
if/endif into the component tag and breaks compilation ("unexpected endif").
@disabled/@checked/@required only work on real HTML elements — switched to the
bound :disabled="…" attribute (the component attribute bag drops a false value).

Caught by a real browser probe (HTTP status + console errors per route), which I
should have run before. Documented that as R12 in rules.md + the CLAUDE.md verify
checklist: every touched page must load at HTTP 200 with zero console errors, in
its *loaded* (post-wire:init) state — a green Livewire::test is not sufficient.

Re-probed all six routes: dashboard/servers/detail/services/files/audit all 200,
zero console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-12 23:43:21 +02:00
parent 5479896bf3
commit 5134fe740b
3 changed files with 44 additions and 5 deletions

View File

@ -227,7 +227,9 @@ docker compose -f docker-compose.prod.yml exec app php artisan migrate --force
- **Work on a feature branch, not `main`.** Commit in sensible steps. - **Work on a feature branch, not `main`.** Commit in sensible steps.
- **Secret hygiene:** `.env*` (incl. `.env.gitea`) + keys are gitignored; never commit/echo the - **Secret hygiene:** `.env*` (incl. `.env.gitea`) + keys are gitignored; never commit/echo the
Gitea token (read it only at push time). See `rules.md` → Appendix. Gitea token (read it only at push time). See `rules.md` → Appendix.
- **Verify before "done":** container builds, page renders, no console errors. Test the 3 breakpoints. - **Verify before "done" (R12):** load **every touched page in a real browser** (headless ok) →
**HTTP 200** (no 500) + **zero console/network errors**; for lazy `wire:init` pages check the
**loaded** state, not the skeleton. A green `Livewire::test` is **not** enough. Test the 3 breakpoints.
--- ---
@ -243,5 +245,7 @@ docker compose -f docker-compose.prod.yml exec app php artisan migrate --force
7. Run all tooling **inside the container** (R8). 7. Run all tooling **inside the container** (R8).
8. UI copy **German, no emoji**; status via color/dots/pills (R9). 8. UI copy **German, no emoji**; status via color/dots/pills (R9).
9. **Reuse** the token set + Blade component kit (R10). 9. **Reuse** the token set + Blade component kit (R10).
10. **Verify** (build, render, no console errors, 3 breakpoints), then `git status` for stray 10. **Verify in a browser (R12)**: every touched route loads at **HTTP 200** with **zero console
errors** (loaded state of lazy pages, not the skeleton) — a green `Livewire::test` is not enough;
build + 3 breakpoints; then `git status` for stray
secrets, commit on the feature branch. secrets, commit on the feature branch.

View File

@ -70,9 +70,9 @@
{{-- R5: wire to wire-elements/modal --}} {{-- R5: wire to wire-elements/modal --}}
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<x-btn variant="secondary" wire:click="confirm('start', @js($svc['name']))" wire:loading.attr="disabled" @disabled($svc['status'] === 'online')>Starten</x-btn> <x-btn variant="secondary" wire:click="confirm('start', @js($svc['name']))" wire:loading.attr="disabled" :disabled="$svc['status'] === 'online'">Starten</x-btn>
<x-btn variant="secondary" wire:click="confirm('stop', @js($svc['name']))" wire:loading.attr="disabled" @disabled($svc['status'] === 'offline')>Stopp</x-btn> <x-btn variant="secondary" wire:click="confirm('stop', @js($svc['name']))" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">Stopp</x-btn>
<x-btn variant="accent" wire:click="confirm('restart', @js($svc['name']))" wire:loading.attr="disabled" @disabled($svc['status'] === 'offline')>Neustart</x-btn> <x-btn variant="accent" wire:click="confirm('restart', @js($svc['name']))" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">Neustart</x-btn>
</div> </div>
</div> </div>
</div> </div>

View File

@ -21,6 +21,7 @@
- [ ] **R9** — UI copy is **German**, **no emoji**; status via color/dots/pills. - [ ] **R9** — UI copy is **German**, **no emoji**; status via color/dots/pills.
- [ ] **R10** — Reused existing `@theme` tokens + Blade components (no ad-hoc colors/widgets). - [ ] **R10** — Reused existing `@theme` tokens + Blade components (no ad-hoc colors/widgets).
- [ ] **R11** — Records exposed in URLs are addressed by **UUID**, never the integer PK. - [ ] **R11** — Records exposed in URLs are addressed by **UUID**, never the integer PK.
- [ ] **R12** — every touched page loads in a **browser** at **HTTP 200** with **zero console errors** (the **loaded** state of `wire:init` pages, not just the skeleton).
- [ ] **Secrets**`git status` shows **no** `.env*`, token, key, or home-dir dotfile staged. - [ ] **Secrets**`git status` shows **no** `.env*`, token, key, or home-dir dotfile staged.
--- ---
@ -336,6 +337,40 @@ route('servers.show', $server->id); // exposes the auto-increment id
--- ---
## R12 — Browser-verified: HTTP 200, zero console errors
**Why.** A passing `Livewire::test(...)->assertOk()` only renders ONE state in PHP — it does not
catch client-side JS errors, and for a `wire:init`/lazy page it exercises the **skeleton**, not the
loaded view. Render-time Blade/PHP errors (e.g. `@disabled` on a component → "unexpected endif", a
500) only surface on the real request. A green unit test is **not** "done".
**Rule.** Before declaring any UI change done, load **every affected page in a real browser**
(headless is fine) and confirm, per page: **HTTP 200** (no 500), **zero console errors**, **zero
failed requests**. For lazy pages, verify the **loaded** state (after `wire:init`), not the skeleton.
Never use `@disabled`/`@checked`/`@required` on a Blade **component** — use the bound `:disabled="…"`
attribute (the component attribute bag drops a `false` value correctly).
**Correct** — headless probe over the routes (captures console + HTTP status):
```bash
# mount the script into the image's home dir or require('puppeteer') fails
docker run --rm --network host -v /tmp/probe.js:/home/pptruser/probe.js -w /home/pptruser \
ghcr.io/puppeteer/puppeteer:latest node probe.js
```
```js
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
const r = await page.goto(url, { waitUntil: 'networkidle2' }); // expect r.status() === 200
await new Promise(s => setTimeout(s, 4000)); // let wire:init load, then assert errors == 0
```
❌ **Forbidden**
```text
"Livewire::test rendered OK" → shipping without ever loading the page in a browser.
Testing only the skeleton state of a wire:init page and assuming the loaded state works.
@disabled($cond) on <x-btn > → injects if/endif into the component tag → 500.
```
---
## Appendix — Secret hygiene ## Appendix — Secret hygiene
The project lives in `/home/nexxo/clusev` (its own git repo). The Gitea push token lives in The project lives in `/home/nexxo/clusev` (its own git repo). The Gitea push token lives in