528 lines
20 KiB
Markdown
528 lines
20 KiB
Markdown
# 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`**.
|
||
- [ ] **R15** — **Codex reviewed the change** (`/codex:review`) and reported **no errors and no security issues** — a task is NOT done until Codex passes.
|
||
- [ ] **Secrets** — `git 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**
|
||
```php
|
||
// 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**
|
||
```php
|
||
// 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.
|
||
|
||
✅ **Correct** — `php artisan make:livewire Servers/Show` (creates class **and** view)
|
||
```php
|
||
// 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
|
||
}
|
||
}
|
||
```
|
||
```blade
|
||
{{-- resources/views/livewire/servers/show.blade.php --}}
|
||
<div>{{ $server->hostname }}</div>
|
||
```
|
||
|
||
❌ **Forbidden**
|
||
```blade
|
||
{{-- 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**
|
||
```css
|
||
/* resources/css/app.css */
|
||
@import "tailwindcss";
|
||
@theme {
|
||
--color-surface: #0F1318;
|
||
--color-accent: #FF6B2C;
|
||
--color-online: #35D07F;
|
||
--color-line: rgba(255,255,255,.10);
|
||
}
|
||
```
|
||
```blade
|
||
<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**
|
||
```blade
|
||
<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
|
||
```blade
|
||
<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**
|
||
```blade
|
||
<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**
|
||
```blade
|
||
{{-- 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>
|
||
```
|
||
```php
|
||
// 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**
|
||
```blade
|
||
<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**
|
||
```blade
|
||
{{-- 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**
|
||
```blade
|
||
<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**
|
||
```bash
|
||
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**
|
||
```bash
|
||
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 — UI copy register: terse, operational, no emoji
|
||
|
||
**Why.** Console/status-page register: terse, operational, factual. Status is communicated
|
||
by **color/dots/pills**, never emoji. Technical identifiers stay native. German is the
|
||
default language, but every visible string is **localized** via lang files — see **R16**
|
||
(no hard-coded copy in views/components/services).
|
||
|
||
✅ **Correct**
|
||
```blade
|
||
<x-status-pill status="online">{{ __('common.online') }}</x-status-pill>
|
||
<span class="font-mono">nginx.service</span>
|
||
<button>{{ __('services.restart') }}</button>
|
||
```
|
||
|
||
❌ **Forbidden**
|
||
```blade
|
||
<span>🟢 Online ✅</span> {{-- ❌ emoji as status --}}
|
||
<button>Dienst neu starten</button> {{-- ❌ hard-coded copy (must be __('…'), R16) --}}
|
||
```
|
||
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
|
||
```blade
|
||
<x-panel title="Auslastung">
|
||
<x-kpi label="CPU" :value="$cpu" unit="%" />
|
||
<x-status-dot status="warning" />
|
||
</x-panel>
|
||
```
|
||
|
||
❌ **Forbidden**
|
||
```blade
|
||
<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**
|
||
```php
|
||
// 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();
|
||
```
|
||
```php
|
||
// 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**
|
||
```php
|
||
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):
|
||
```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.
|
||
```
|
||
|
||
---
|
||
|
||
## 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**
|
||
```php
|
||
Route::get('/settings', Settings\Index::class)->name('settings'); // path + name English
|
||
```
|
||
```blade
|
||
<x-nav-item href="/settings">Einstellungen</x-nav-item> {{-- German label, English path --}}
|
||
```
|
||
|
||
❌ **Forbidden**
|
||
```php
|
||
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**
|
||
```css
|
||
/* 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**
|
||
```html
|
||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk"> <!-- ❌ CDN -->
|
||
```
|
||
```css
|
||
@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**
|
||
```text
|
||
implement → self-check (lint + R12 browser) → /codex:review → 0 findings → done → commit
|
||
↑__________________│ (loop until clean)
|
||
```
|
||
|
||
❌ **Forbidden**
|
||
```text
|
||
"tests pass, shipping it" without an independent Codex review.
|
||
Marking a task done while Codex still reports an error or a security issue.
|
||
```
|
||
|
||
---
|
||
|
||
## R16 — Every UI string is localized (German + English)
|
||
|
||
**Why.** Clusev ships in **two languages, user-selectable** (DE default, EN). There must be
|
||
**no hard-coded user-facing copy** anywhere — every visible string (views, Livewire components,
|
||
service messages, notifications, validation, modal copy) goes through Laravel localization so a
|
||
new string is never English-only or German-only.
|
||
|
||
**Structure (mandatory).** Translations live in `lang/<code>/<group>.php` returning a nested
|
||
array, one **group per feature area** mirroring the view tree, plus a shared `common` group:
|
||
|
||
```
|
||
lang/
|
||
de/ common.php auth.php dashboard.php servers.php services.php files.php
|
||
audit.php settings.php system.php versions.php modals.php shell.php
|
||
en/ (same files, same keys)
|
||
```
|
||
|
||
- Access with `__('group.key')` / `@lang`; interpolate with `:placeholders`
|
||
(`__('servers.banned', ['ip' => $ip])`).
|
||
- **Keys are identical across `de` and `en`** — every key MUST exist in BOTH. Reuse `common.*`
|
||
for shared actions/status (`save`, `cancel`, `online`, …) instead of duplicating.
|
||
- Supported locales are declared once in `App\Http\Middleware\SetLocale::SUPPORTED`; the active
|
||
locale comes from the user's saved preference → session → `config('app.locale')`.
|
||
|
||
✅ **Correct**
|
||
```blade
|
||
<x-btn>{{ __('common.save') }}</x-btn>
|
||
<h2>{{ __('servers.detail_title') }}</h2>
|
||
```
|
||
```php
|
||
$this->dispatch('notify', message: __('servers.credential_saved'));
|
||
```
|
||
|
||
❌ **Forbidden**
|
||
```blade
|
||
<x-btn>Speichern</x-btn> {{-- ❌ hard-coded — add a key to lang/{de,en} --}}
|
||
```
|
||
```php
|
||
$this->error = 'Server nicht gefunden.'; // ❌ use __('common.server_not_found')
|
||
```
|
||
|
||
Adding a string in only one language, or omitting a key from `en`/`de`, is a rule violation.
|
||
|
||
---
|
||
|
||
## R17 — Blade: block `@php`; never the literal directive tokens in a comment
|
||
|
||
Blade's raw-block precompiler matches `@php … @endphp` **non-greedily on the FIRST `@endphp`**.
|
||
|
||
- **Never** write the literal tokens `@php`/`@endphp` (or `@verbatim`/`@endverbatim`) inside a
|
||
Blade **comment or text** in a file that also has a real `@php` block — the matcher takes the
|
||
token in your comment as the block end and dumps the remainder as raw text. (This shipped a
|
||
garbled `<header>` on every signed-in page.) To mention a directive, break it (`@php`) or word
|
||
it ("the php directive").
|
||
- Use the **block** `@php … @endphp`, not **inline** `@php(...)`: inline silently mis-compiles
|
||
when the expression nests parens/calls (e.g. `@php($x ??= __('a.b'))`) and collides with any
|
||
later block in the same file.
|
||
- **Verify the rendered DOM, not just HTTP 200** (extends R12): leaked template text and missing
|
||
translations both return 200 with no console error. R12 must inspect the actual visible markup
|
||
(`<header>`/body) for stray `@`, `{{ }}`, `$var`, or `group.key` literals.
|
||
|
||
---
|
||
|
||
## 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.
|