clusev/rules.md

16 KiB

Clusev — STRICT RULES

Non-negotiable. Generated from handoff.md §4. These are the user's explicit, mandatory rules. If any instruction (mine, yours, or a future agent's) conflicts with a rule here, STOP and ask — do not "work around" it. Keep this file open while coding.

Stack note (2026-06-11): built on Laravel 13 (handoff said 12; updated by user decision). The rules below are version-agnostic.


Quick gate — run through this before every commit

  • R1/R2 — Page is a full-page, class-based Livewire component mapped directly in routes/web.php.
  • R2 — Livewire class in app/Livewire/… and a matching separate Blade view in resources/views/livewire/…. No Volt.
  • R3 — No hard-coded hex/rgb in markup — only @theme token utilities (bg-surface, text-accent, …).
  • R4 — No inline style="…" except a progress bar's width.
  • R5 — Destructive/confirm action runs through a wire-elements/modal, never Alpine confirm().
  • R6 — Files placed exactly per the §5 folder map.
  • R7 — Verified at 375 / 768 / 1280 px; touch targets ≥ 44px.
  • R8 — Every PHP/Composer/Node/artisan command ran inside the container.
  • R9 — UI copy is German, no emoji; status via color/dots/pills.
  • 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.
  • 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).
  • R13 — route paths + names are English (/settings, not /einstellungen); German only in the visible label.
  • R14 — fonts are self-hosted locally (resources/fonts/*.woff2 bundled by Vite + @font-face in app.css) — no Google Fonts / CDN <link> or @import.
  • R15Codex reviewed the change (/codex:review) and reported no errors and no security issues — a task is NOT done until Codex passes.
  • Secretsgit status shows no .env*, token, key, or home-dir dotfile staged.

R1 — Pages are full-page Livewire components, mapped directly as routes

Why. One page = one component = one route. No controller indirection, no orphan Blade views. Keeps routing, state, and view co-located and predictable.

Correct

// routes/web.php
use App\Livewire\Dashboard;
use App\Livewire\Servers\Show;

Route::get('/', Dashboard::class)->name('dashboard');
Route::get('/servers/{server}', Show::class)->name('servers.show');

Forbidden

// Controller returning a Blade view for a PAGE
Route::get('/', [DashboardController::class, 'index']);

class DashboardController {
    public function index() {
        return view('dashboard');   // ❌ pages do not belong to controllers
    }
}

R2 — Class-based Livewire only. Volt is FORBIDDEN.

Why. Class + separate view is explicit, testable, and conventional. Volt single-file components hide logic in the view and are banned for this project.

Correctphp artisan make:livewire Servers/Show (creates class and view)

// app/Livewire/Servers/Show.php
namespace App\Livewire\Servers;

use App\Models\Server;
use Livewire\Component;

class Show extends Component
{
    public Server $server;

    public function render()
    {
        return view('livewire.servers.show');   // ← separate Blade file
    }
}
{{-- resources/views/livewire/servers/show.blade.php --}}
<div>{{ $server->hostname }}</div>

Forbidden

{{-- Volt single-file component — NEVER. Do not run `php artisan make:volt`. --}}
<?php
use function Livewire\Volt\{state};
state(['count' => 0]);
?>
<div>{{ $count }}</div>

R3 — All colors / design tokens live in resources/css/app.css under @theme

Why. Single source of truth for the visual system (Tailwind v4 CSS-first, no tailwind.config.js). Markup references tokens, never raw colors — so a token change restyles the whole app.

Correct

/* resources/css/app.css */
@import "tailwindcss";
@theme {
  --color-surface: #0F1318;
  --color-accent:  #FF6B2C;
  --color-online:  #35D07F;
  --color-line:    rgba(255,255,255,.10);
}
<div class="bg-surface border border-line">
  <span class="text-accent">CPU</span>
  <span class="text-online">online</span>
  <div class="bg-accent/10">soft accent tint</div>   {{-- opacity modifier, not a separate token --}}
</div>

Forbidden

<div class="bg-[#0F1318] border-[#ffffff1a]" style="color:#FF6B2C">  {{-- ❌ raw hex in markup --}}

R4 — Inline styles are FORBIDDEN (one exception)

Why. Inline styles bypass the token system and Tailwind. The only legitimate dynamic value that can't be a utility class is a progress bar's percentage width.

Correct — the ONE allowed inline style

<div class="h-1.5 w-full rounded-full bg-line">
  <div class="h-full rounded-full bg-accent" style="width: {{ $pct }}%"></div>
</div>

Forbidden

<div style="margin-top:12px; display:flex; color:#FF6B2C">  {{-- ❌ use utilities: mt-3 flex text-accent --}}

R5 — Destructive / confirm actions use a Modal, never an Alpine dialog

Why. Destructive actions (delete server, remove SSH key, stop a service) must be deliberate, styled, and auditable. Use wire-elements/modal components — never native confirm() or an x-data popup.

Correct

{{-- trigger: open the modal component (exact payload per installed wire-elements/modal version) --}}
<button type="button"
        wire:click="$dispatch('openModal', { component: 'modals.confirm-delete', arguments: { server: {{ $server->id }} } })"
        class="rounded-md px-3 py-2 text-offline hover:bg-offline/10">
  Löschen
</button>
// app/Livewire/Modals/ConfirmDelete.php  (a real Livewire modal class)
namespace App\Livewire\Modals;

use App\Models\Server;
use LivewireUI\Modal\ModalComponent;   // wire-elements/modal base class

class ConfirmDelete extends ModalComponent
{
    public Server $server;

    public function delete(): void
    {
        $this->server->delete();        // audit-logged in the domain layer
        $this->closeModalWithEvents([/* refresh listeners */]);
    }

    public function render() { return view('livewire.modals.confirm-delete'); }
}

Forbidden

<button x-data @click="if (confirm('Wirklich löschen?')) $wire.delete()">Löschen</button>
{{-- ❌ no native confirm(), no Alpine confirm popups for destructive actions --}}

R6 — Clean, conventional folder structure

Why. Predictable structure = fast ramp-up, no duplication, no "where does this go?". Follow the §5 map in CLAUDE.md exactly.

Correct — component, view, model, SSH wrapper each in their canonical place

app/Livewire/Servers/Show.php
resources/views/livewire/servers/show.blade.php
app/Models/Server.php
app/Support/Ssh/SshClient.php

Forbidden

app/Http/Controllers/ServerController.php   {{-- ❌ pages aren't controllers (R1) --}}
app/Livewire/show.blade.php                 {{-- ❌ view in the class namespace --}}
app/ServerSshHelperStuff.php                {{-- ❌ unstructured dumping ground --}}

R7 — Fully responsive: mobile + tablet + desktop

Why. Operators use phones and tablets in the field. Every screen must adapt — not just shrink.

Correct

{{-- KPI grid reflows 1 → 2 → 4; sidebar is drawer on small, fixed on large --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"> … </div>

<aside class="hidden lg:block lg:w-[272px]"> … </aside>          {{-- desktop sidebar --}}
<button class="lg:hidden min-h-11 min-w-11" wire:click="$toggle('navOpen')">…</button>  {{-- ≥44px touch --}}

Forbidden

<div class="grid grid-cols-4">      {{-- ❌ desktop-only; breaks on mobile/tablet --}}
<aside class="w-[272px]">           {{-- ❌ always-on sidebar, no drawer --}}

Breakpoints to test: 375 (mobile), 768 (tablet), 1280+ (desktop).


R8 — Docker-first, host stays clean

Why. The target host has only Docker + a sudo user — no PHP/Node/Composer. Everything runs in containers, in dev and prod. Run all tooling through the app (or a throwaway) container.

Correct

docker compose exec app php artisan make:livewire Dashboard
docker compose exec app composer require some/package
docker compose exec app php artisan migrate

Forbidden

php artisan migrate        # ❌ assumes PHP on the host — there is none
composer install           # ❌ host has no Composer
npm run build              # ❌ run node inside the vite/app container

R9 — German UI copy, no emoji

Why. Console/status-page register: terse, operational, factual. Status is communicated by color/dots/pills, never emoji. Technical identifiers stay native.

Correct

<x-status-pill status="online">Online</x-status-pill>
<span class="font-mono">nginx.service</span>
<button>Dienst neu starten</button>

Forbidden

<span>🟢 Online ✅</span>          {{-- ❌ emoji as status --}}
<button>Restart service</button>   {{-- ❌ English UI copy --}}

Keep native technical tokens as-is: nginx.service, systemd, chmod 600, SSH, 2FA.


R10 — Reuse the existing design system

Why. The visual system (tokens + components) is already designed ("Tactical Terminal", softened orange). Don't reinvent colors or one-off widgets — compose from the shared kit.

Correct — use the Blade component kit + tokens

<x-panel title="Auslastung">
  <x-kpi label="CPU" :value="$cpu" unit="%" />
  <x-status-dot status="warning" />
</x-panel>

Forbidden

<div class="rounded-xl bg-[#12161c] p-4 shadow">           {{-- ❌ ad-hoc panel, raw hex --}}
  <div style="color:#ffa500">CPU</div>                     {{-- ❌ new orange, inline style --}}
</div>

R11 — Public URLs use UUIDs, never the numeric primary key

Why. Auto-increment IDs in URLs are enumerable and leak record counts/ordering. Anything addressable in a route is referenced by an unguessable UUID; the integer PK stays internal (relations, performance).

Correct

// app/Models/Server.php — keep the bigint id internally, expose a uuid in URLs
use Illuminate\Support\Str;

class Server extends Model
{
    protected static function booted(): void
    {
        static::creating(fn (self $m) => $m->uuid ??= (string) Str::uuid());
    }

    public function getRouteKeyName(): string
    {
        return 'uuid';   // {server} in routes resolves by uuid, not the bigint id
    }
}
// migration: $table->uuid('uuid')->unique();
// routes/web.php — {server} binds by uuid
Route::get('/servers/{server}', \App\Livewire\Servers\Show::class)->name('servers.show');
// → /servers/9f3c0b1a-7e21-...   (never /servers/42)

Forbidden

Route::get('/servers/{id}', ...);     // integer PK in the URL — enumerable
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):

# 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
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

"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.

R13 — Routes and URL paths are English, always

Why. UI copy is German (R9), but code-level identifiers — route paths, route names, URL segments, query keys — stay English, like every other identifier in the codebase. German belongs in the visible label, never in the href.

Correct

Route::get('/settings', Settings\Index::class)->name('settings');   // path + name English
<x-nav-item href="/settings">Einstellungen</x-nav-item>   {{-- German label, English path --}}

Forbidden

Route::get('/einstellungen', ...)->name('einstellungen');   // German in the URL/name
Route::get('/dateien', ...);                                // → use /files

R14 — Fonts are self-hosted locally, never loaded from a CDN

Why. A self-hosted control plane runs in private/air-gapped networks and must not leak requests to third parties or break when the internet is unreachable. Every font ships with the app.woff2 files in public/fonts/, declared via @font-face in resources/css/app.css. No <link href="fonts.googleapis.com">, no @import url(...) from a CDN, no fonts.gstatic.com.

Correct

/* resources/css/app.css — local file in resources/fonts/, bundled + hashed by Vite
   (relative url so it resolves on the Vite dev server AND in the prod build) */
@font-face {
  font-family: 'Space Grotesk';
  font-weight: 300 700;
  font-display: swap;
  src: url('../fonts/space-grotesk.woff2') format('woff2');
}

Use a relative url('../fonts/…') (not /fonts/…): an absolute path resolves against the Vite dev-server origin in dev → 404. Relative lets Vite resolve + emit the asset for both modes.

Forbidden

<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk">  <!-- ❌ CDN -->
@import url('https://fonts.googleapis.com/css2?family=Chakra+Petch');                  /* ❌ CDN import */

R15 — Codex must review every change before it is "done"

Why. A second, independent reviewer catches what the author misses — logic bugs, regressions, and especially security holes in a control plane that holds the keys to a whole fleet. Self-review is not enough; an external pass is mandatory before any task is considered complete.

Rule. After implementing and self-verifying (R12 etc.), run /codex:review over the change. Codex must report no errors and no security vulnerabilities. If it flags anything, fix it and re-run until Codex is clean. Only then is the task done. Reviewing for security specifically — SSH/command injection, credential handling, authz, lock-out safety, CSP — is part of every pass.

Correct

implement → self-check (lint + R12 browser) → /codex:review → 0 findings → done → commit
                                                      ↑__________________│  (loop until clean)

Forbidden

"tests pass, shipping it" without an independent Codex review.
Marking a task done while Codex still reports an error or a security issue.

Appendix — Secret hygiene

The project lives in /home/nexxo/clusev (its own git repo). The Gitea push token lives in ../.env.gitea (in the parent home dir, untracked) and must later move into the app .env.

  • .env* (incl. .env.gitea), keys, and local state are in .gitignore — keep it that way.
  • Never commit or echo the GIT_ACCESS_TOKEN; read it only at push time.
  • Before any git push, eyeball git status and the staged diff for stray secrets.