diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5c181d9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,53 @@ +# CluPilot — verbindliche Regeln (Repo-Teil) + +Die **Vollfassung der Regeln R1–R17 liegt beim Nutzer**, nicht im Repo. Die +Kurzfassung steht in `docs/handoffs/2026-07-25-clupilot-state-handoff.md` §9. +Bei Konflikt: **STOP & fragen.** + +Diese Datei hält die Regeln fest, die aus konkreten Fehlern im laufenden Betrieb +entstanden sind — sie sind nicht verhandelbar und werden per Test erzwungen. + +--- + +## R18 — Icon-Größe und Zeilenumbruch + +**Ein Icon steht neben seinem Text, nie darüber, und nie größer als bestellt.** + +Verboten: + +1. **Icon zwingt den Text auf eine zweite Zeile.** Ein Navigationseintrag, ein + Button, ein Tabellen-Action ist **einzeilig**. Zwei Zeilen sind nur erlaubt, + wenn der Text selbst bewusst zweizeilig gesetzt ist (Label + Unterzeile) — + dann steht das Icon links davon, nicht darüber. +2. **Icon größer als die Zeile, in der es sitzt.** Standard ist `size-5` (20px) + in der Navigation, `size-4` (16px) in Buttons, Tabellen und Fließtext. + Größer nur, wenn es als eigenständiges Element gemeint ist (Leerzustand, + Statusplakette). + +### Warum das zweimal schiefging + +- **Zeilenumbruch.** Tailwinds Preflight setzt `svg { display: block }`. Ein + Icon in einem *inline*-Elternteil schiebt den folgenden Text damit auf die + nächste Zeile. Genau so wurde aus dem Eintrag „Zugangsdaten" ein doppelt so + hoher Kasten mit Schloss oben und Wort darunter. +- **Größe.** `.size-4` und `.size-5` haben **dieselbe Spezifität**, also + entscheidet die Reihenfolge im Stylesheet — und Tailwind gibt `.size-4` *vor* + `.size-5` aus. Eine Komponente, die `size-5` bedingungslos mitmergt, überstimmt + damit **jedes** `class="size-4"` am Aufrufort. Alle so geschriebenen Icons + liefen still auf 20px. + +### Wie es jetzt gebaut ist + +- `resources/views/components/ui/icon.blade.php` setzt seine Standardgröße + **nur**, wenn der Aufrufort keine `size-`/`w-`/`h-`-Klasse mitgibt, und + rendert `inline-block shrink-0 align-middle` statt des Preflight-`block`. +- `resources/views/components/ui/nav-item.blade.php` legt Label und Icon in eine + eigene Flex-Zeile, damit ein Icon auch im falschen Slot daneben landet. +- Icons in `` gehören in ``, nicht in den + Default-Slot. + +### Erzwungen durch + +`tests/Feature/IconLayoutTest.php` — Größe des Aufruforts gewinnt, Icon bleibt +`inline-block`, Nav-Eintrag bleibt einzeilig aus beiden Slots, und kein +Blade-File im Repo darf ein Icon am Icon-Slot vorbeischmuggeln. diff --git a/app/Livewire/Admin/Secrets.php b/app/Livewire/Admin/Secrets.php index 84ae6e3..c024c4b 100644 --- a/app/Livewire/Admin/Secrets.php +++ b/app/Livewire/Admin/Secrets.php @@ -94,8 +94,15 @@ class Secrets extends Component { $this->guard(); + // The checker named by THIS entry, not a fixed one. When the area held a + // single Stripe key a hard-coded StripeCheck was the same thing; with + // four entries it would have reported on Stripe while the operator was + // looking at the DNS token. + $checker = SecretVault::REGISTRY[$key]['check'] ?? null; + abort_if($checker === null, 404); + $candidate = trim((string) ($this->entered[self::field($key)] ?? '')) ?: null; - $this->check = app(StripeCheck::class)->run($candidate); + $this->check = app($checker)->run($candidate); } /** The dotless form key for a registry key. */ @@ -123,7 +130,10 @@ class Secrets extends Component ->map(fn (array $meta, string $key) => [ 'key' => $key, 'field' => self::field($key), - 'label' => $meta['label'], + 'label' => __($meta['label']), + // Only where a checker exists. A test button that cannot + // actually test is a promise the page does not keep. + 'testable' => isset($meta['check']), 'source' => $vault->source($key), // Only ever an outline, and only once unlocked. 'outline' => $unlocked ? $vault->outline($key) : null, diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index c419fe3..5f07807 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -49,6 +49,19 @@ final class UpdateChannel /** Tail of the last run, for the operator to read without shell access. */ private const LOG = 'deploy/update-last-run.log'; + /** + * The step deploy/update.sh is on, written by update.sh itself. + * + * Read from here rather than from the status file, and that is the whole + * point: the agent writes the status once, then blocks for the length of the + * run. Anything taken from the status file is therefore frozen at "the run + * started" and the step would never advance while it mattered. update.sh + * rewrites this file at every step, so it is the only live source. + * + * Two tab-separated fields: the step key, and when it started. + */ + private const PHASE = 'deploy/update-phase'; + /** * A request older than this is treated as abandoned. * @@ -106,7 +119,21 @@ final class UpdateChannel 'requested_at' => $request !== null ? $this->timestamp($request['requested_at'] ?? null) : null, 'requested_by' => $request['requested_by'] ?? null, + // When the agent will next look. A queued update sits until the + // timer fires, and "in a few minutes" is exactly the answer that + // leaves an operator refreshing the page wondering whether the + // button did anything at all. + 'next_check_at' => $agentAlive ? $this->nextCheckAt($checkedAt, $status) : null, + + // The step the deployment script is on — a key, translated here. + // Read live from the phase file; older agents write none, so its + // absence is normal rather than a fault. + 'phase' => $this->phase($this->currentPhase($status)), + 'started_at' => $this->timestamp($status['started_at'] ?? null), + 'last_state' => $lastRun['state'] ?? null, + 'last_phase' => $this->phase($lastRun['phase'] ?? null), + 'last_started_at' => $this->timestamp($lastRun['started_at'] ?? null), 'last_finished_at' => $this->timestamp($lastRun['finished_at'] ?? null), // The run's own failure first; a check-level problem (the // repository being unreachable) only when the last run was fine. @@ -114,6 +141,116 @@ final class UpdateChannel ]; } + /** + * The deployment step, as something a German interface can print. + * + * The script writes a key precisely so this stays translatable. An unknown + * key — a newer script against older translations — yields null rather than + * a raw identifier in the middle of a sentence. + */ + private function phase(mixed $key): ?string + { + if (! is_string($key) || $key === '') { + return null; + } + + $line = __('admin_settings.update_phase.'.$key); + + return is_string($line) && $line !== 'admin_settings.update_phase.'.$key ? $line : null; + } + + /** + * The step key update.sh last announced, or null. + * + * Its own tiny file rather than a field in the status document: update.sh + * runs as the service account on the host and rewrites this at every step, + * while the agent that owns the status document is blocked waiting for it. + * + * Only while a run is genuinely in flight, and only if the step is newer + * than that run. A failed run deliberately LEAVES its file behind so the + * failing step can be named; without these two checks, queueing the next + * update would show it as already at "Migrating the database" — a step + * nothing has started — and hide the one thing the operator needs, which is + * when it will actually begin. + * + * @param array $status + */ + private function currentPhase(array $status): ?string + { + if (($status['state'] ?? null) !== 'running') { + return null; + } + + $path = storage_path('app/'.self::PHASE); + + if (! is_file($path)) { + return null; + } + + // Deliberately forgiving: this is a progress hint, and a half-written + // line during the moment update.sh replaces it must not throw on a page + // an operator is watching a deployment from. + $parts = explode("\t", (string) @file_get_contents($path), 2); + $key = trim($parts[0] ?? ''); + + if ($key === '') { + return null; + } + + // Left over from a manual run of update.sh on the shell: newer than the + // last agent status, older than this run. + $startedAt = $this->timestamp($status['started_at'] ?? null); + $phaseAt = $this->timestamp(trim($parts[1] ?? '')); + + if ($startedAt !== null && $phaseAt !== null && $phaseAt->lt($startedAt)) { + return null; + } + + return $key; + } + + /** + * The latest moment a queued update can still be picked up. + * + * Normally one interval after the agent's last check. But a systemd timer + * that ran late leaves a last check already older than its interval, and + * `checked_at + interval` is then a time in the PAST — the page would + * promise a start that has demonstrably not happened. In that case the only + * honest upper bound is one interval from now. + * + * @param array $status + */ + private function nextCheckAt(?Carbon $checkedAt, array $status): ?Carbon + { + if ($checkedAt === null) { + return null; + } + + $interval = $this->checkInterval($status); + $due = $checkedAt->copy()->addMinutes($interval); + + return $due->isFuture() ? $due : Carbon::now()->addMinutes($interval); + } + + /** + * How often the agent looks, in minutes. + * + * Reported by the agent rather than assumed here: the interval lives in the + * systemd timer, and a server whose timer was tuned would otherwise be + * given a next-check time that quietly never arrives. + * + * @param array $status + */ + private function checkInterval(array $status): int + { + $minutes = (int) ($status['check_interval_minutes'] ?? 0); + + // Clamped, not trusted: this value ends up in a promise to the operator, + // and a corrupt file must not produce "next check in 1970" or a time so + // far out that the page looks broken. + return $minutes >= 1 && $minutes <= 60 ? $minutes : 5; + } + /** * Has the agent checked in recently enough to be considered alive? * diff --git a/app/Services/Secrets/SecretVault.php b/app/Services/Secrets/SecretVault.php index fc4df9e..1398df9 100644 --- a/app/Services/Secrets/SecretVault.php +++ b/app/Services/Secrets/SecretVault.php @@ -36,8 +36,41 @@ use RuntimeException; final class SecretVault { /** @var array */ + /** + * What the owner may change here — curated, never "everything in config". + * + * The list is the whole point of the area. With one entry it was a page + * that existed to hold a single Stripe key, which is not worth a password + * gate; these four are the credentials that actually stop the business when + * they expire, and none of them can otherwise be rotated without a shell on + * the server. + * + * `check` is optional and names a class that can verify the value before it + * is stored. Where there is none, no test button is offered — a button that + * silently checks something else is worse than no button. + * + * Deliberately NOT here: STRIPE_WEBHOOK_SECRET. It is read on every + * incoming payment event, and a database problem would turn signature + * verification into a silent failure. It stays in the server file. + */ public const REGISTRY = [ - 'stripe.secret' => ['config' => 'services.stripe.secret', 'label' => 'Stripe Secret Key'], + 'stripe.secret' => [ + 'config' => 'services.stripe.secret', + 'label' => 'secrets.item.stripe_secret', + 'check' => \App\Services\Stripe\StripeCheck::class, + ], + 'dns.token' => [ + 'config' => 'provisioning.dns.token', + 'label' => 'secrets.item.dns_token', + ], + 'monitoring.token' => [ + 'config' => 'services.monitoring.token', + 'label' => 'secrets.item.monitoring_token', + ], + 'mail.password' => [ + 'config' => 'mail.mailers.smtp.password', + 'label' => 'secrets.item.mail_password', + ], ]; public function has(string $key): bool diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh index bb1bc57..c3d3e18 100755 --- a/deploy/update-agent.sh +++ b/deploy/update-agent.sh @@ -27,6 +27,9 @@ STATE_DIR="$ROOT/storage/app/deploy" REQUEST="$STATE_DIR/update-request.json" STATUS="$STATE_DIR/update-status.json" RUNLOG="$STATE_DIR/update-last-run.log" +# The step update.sh is on, written by update.sh itself. Read back here so a run +# that failed reports WHERE it failed instead of only that it did. +PHASE_FILE="$STATE_DIR/update-phase" # The outcome of the last RUN, kept apart from the periodic check. Written into # one file, the next idle tick five minutes later would overwrite a failure with # "idle" — and an operator would usually never see that the update failed. @@ -44,6 +47,12 @@ ALLOWFILE="${CONSOLE_ALLOW_FILE:-/etc/caddy/clupilot-console-allow.conf}" # stopped fires whenever the agent next starts — which could be days later. REQUEST_EXPIRES_MINUTES=30 +# How often systemd starts this agent — see OnUnitActiveSec in install-agent.sh. +# Reported so the console can say WHEN a queued update will start rather than +# "in a few minutes", which is the whole reason an operator sits there wondering +# whether anything is happening at all. +CHECK_INTERVAL_MINUTES=5 + mkdir -p "$STATE_DIR" # One agent at a time. Two overlapping runs of update.sh fight over the @@ -118,6 +127,14 @@ json_escape() { printf '%s' "${1-}" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr -d '\n\r' } +# The step update.sh last announced, if any. Two tab-separated fields: key, time. +# +# Not copied into the status document below: this agent BLOCKS for the whole +# run, so anything it wrote there would be frozen at the first step. The panel +# reads the phase file itself, which update.sh keeps current. Here it is only +# needed once, to record which step a failed run died at. +read_phase() { cut -f1 "$PHASE_FILE" 2>/dev/null | tr -d '\r\n' || true; } + write_status() { local state="$1" error="${2-}" cat > "$STATUS.tmp" < "$LASTRUN.tmp" </dev/null || echo '')")", "error": "$(json_escape "$error")", "exit_code": ${EXIT_CODE:-null} @@ -225,6 +246,12 @@ fi # again on the next tick and update in a loop. rm -f "$REQUEST" +# The previous run's last step is not this run's first one. Left in place, the +# console would show a stale phase for the seconds before update.sh writes its +# own — and on a run that dies before writing any, for the whole run. +rm -f "$PHASE_FILE" +STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + write_status running "$FETCH_ERROR" set +e @@ -247,9 +274,15 @@ EXIT_CODE="$RESULT" if [[ $RESULT -eq 0 ]]; then BEHIND=0 TARGET_RELEASE='' + # Dropped before anything records it: a run that finished has no step it is + # on, and the last one it passed would read as "still at Rebuilding assets". + rm -f "$PHASE_FILE" write_run succeeded '' write_status idle '' else + # The phase file stays: it names the step that failed, and the console shows + # it next to the log so the operator does not have to read the log to find + # out where it stopped. write_run failed 'update_failed' write_status idle "$FETCH_ERROR" fi diff --git a/deploy/update.sh b/deploy/update.sh index 164ea0c..8a8aaf1 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -31,10 +31,27 @@ if [[ $EUID -eq 0 ]]; then exit 1 fi STATE_FILE="storage/app/deployed-commit" +# Which step is running, for the console to show. Written as a KEY, not as the +# sentence below it: the console is translated and this script is not, so an +# English line here would surface untranslated in the interface. A run that dies +# leaves the key of the step it died at, which is the one thing the operator +# needs and the log alone makes them hunt for. +PHASE_FILE="storage/app/deploy/update-phase" log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m !\033[0m %s\n' "$*"; } +# A step, announced to the terminal and recorded for the console. +phase() { + local key="$1"; shift + mkdir -p "$(dirname "$PHASE_FILE")" + # Not atomic on purpose: this is a hint for a progress line, and a torn read + # costs a single poll. Writing a temp file per step would be more moving + # parts than the thing is worth. + printf '%s\t%s\n' "$key" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$PHASE_FILE" 2>/dev/null || true + log "$*" +} + in_app() { docker compose exec -T app "$@"; } down=0 @@ -62,7 +79,7 @@ if [[ "$mode" == "release" ]]; then [[ -n "$RELEASE" ]] || { echo "Pinned to a release, but no tag recorded. Pass RELEASE=vX.Y.Z." >&2; exit 1; } fi - log "Fetching release $RELEASE" + phase fetch "Fetching release $RELEASE" git fetch --quiet --tags --force origin git rev-parse -q --verify "refs/tags/${RELEASE}^{commit}" >/dev/null \ || { echo "No such release tag: ${RELEASE}" >&2; exit 1; } @@ -70,7 +87,7 @@ if [[ "$mode" == "release" ]]; then target="$(git rev-parse "refs/tags/${RELEASE}^{commit}")" source_ref="refs/tags/${RELEASE}" else - log "Fetching $BRANCH" + phase fetch "Fetching $BRANCH" git fetch --quiet origin "$BRANCH" target="$(git rev-parse "origin/$BRANCH")" source_ref="$BRANCH" @@ -120,10 +137,31 @@ reconcile_vpn_readiness() { # inside it listens in a network namespace that was torn down underneath it # — nothing errors, the address simply refuses connections, and a # container-state check reports everything fine. + # + # Asked at the address the gateway actually serves. It binds to the hub + # address alone (see docker/caddy/vpn.Caddyfile), so nothing has ever + # listened on 127.0.0.1 — the previous probe could only ever fail, and it + # did: VPN_READY stayed false on a perfectly healthy tunnel, and because of + # that the application withheld the resolver from every client config it + # issued. The console was then unreachable over the VPN for weeks, with the + # deployment printing a warning that read like a gateway fault. + # + # Over plain HTTP on the health port, not HTTPS: the TLS site matches on the + # console's hostname, so a request to the bare address presents no SNI, gets + # no certificate and fails the handshake — which looks exactly like the + # outage this is meant to detect. Caddy refuses to start at all if the + # certificate is unreadable, so a health port that answers still proves the + # whole gateway loaded. + # The same variable compose hands the gateway, so the two cannot drift. + local hub port health + hub="$(sed -n 's/^CLUPILOT_WG_HUB_ADDRESS=//p' .env | tail -1)" + hub="${hub:-10.66.0.1}" + port="$(sed -n 's/^VPN_HEALTH_PORT=//p' .env | tail -1)" + health="http://${hub}:${port:-8081}/healthz" + for _ in $(seq 1 10); do if docker compose exec -T vpn-gateway sh -c \ - "wget -q --spider --no-check-certificate https://127.0.0.1/ 2>/dev/null \ - || wget -q -O /dev/null --no-check-certificate https://127.0.0.1/ 2>/dev/null" >/dev/null 2>&1; then + "wget -q --spider '$health' 2>/dev/null || wget -q -O /dev/null '$health' 2>/dev/null" >/dev/null 2>&1; then vpn_ready=true break fi @@ -263,7 +301,7 @@ else warn "Code is current but the last update did not finish — repeating the steps." fi -log "Enabling maintenance mode" +phase maintenance_on "Enabling maintenance mode" in_app php artisan down --retry=60 >/dev/null || warn "Could not enable maintenance mode (continuing)" down=1 @@ -271,17 +309,17 @@ if [[ "$mode" == "release" ]]; then # Detached on purpose: a release is a fixed point, not a line to follow. # `git merge --ff-only` would be meaningless here, and on a detached HEAD it # is how a pinned server silently rejoins main. - log "Checking out $source_ref" + phase checkout "Checking out $source_ref" git checkout --quiet --detach "$target" else - log "Checking out $BRANCH" + phase checkout "Checking out $BRANCH" git merge --quiet --ff-only "origin/$BRANCH" fi after="$(git rev-parse HEAD)" # The image is only rebuilt when its definition changed — minutes versus seconds. if ! git diff --quiet "$base" "$after" -- docker/ 2>/dev/null; then - log "Rebuilding the image" + phase image "Rebuilding the image" docker compose build --quiet app # Recreate now, not at the end: everything below runs INSIDE this container, # and an update that changes the PHP runtime would otherwise install and @@ -297,24 +335,24 @@ fi # image contains: rebuilding the image does NOT update them. Install explicitly # whenever a lockfile moved, or the migration below runs against stale packages. if ! git diff --quiet "$base" "$after" -- composer.json composer.lock 2>/dev/null || [[ ! -d vendor ]]; then - log "Installing PHP dependencies" + phase composer "Installing PHP dependencies" in_app composer install --no-interaction --no-dev --prefer-dist --no-progress --optimize-autoloader fi if ! git diff --quiet "$base" "$after" -- package.json package-lock.json 2>/dev/null || [[ ! -d node_modules ]]; then - log "Installing JS dependencies" + phase npm "Installing JS dependencies" in_app npm ci --no-fund --no-audit fi -log "Applying migrations" +phase migrate "Applying migrations" in_app php artisan migrate --force -log "Rebuilding assets" +phase assets "Rebuilding assets" in_app npm run build # Before the restarts, not after: a service that starts while the old cache is # still on disk loads it and keeps those values for the life of its process. -log "Rebuilding caches" +phase caches "Rebuilding caches" # optimize:clear first, then optimize: a half-warm cache from before the update # is what produces a page styled with assets that no longer exist. in_app php artisan optimize:clear >/dev/null @@ -354,7 +392,7 @@ if [[ $proxy_running -eq 1 ]]; then done fi -log "Restarting services" +phase restart "Restarting services" docker compose up -d # Workers hold their PHP classes for the life of the process; without this they # keep running the code from before the update. @@ -378,7 +416,7 @@ fi reconcile_vpn_readiness -log "Leaving maintenance mode" +phase maintenance_off "Leaving maintenance mode" in_app php artisan up >/dev/null down=0 diff --git a/docker-compose.yml b/docker-compose.yml index f4eaf6d..83f3c52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -132,6 +132,10 @@ services: # it here while the application reads a configurable one would produce a # config pointing at an address nothing listens on. VPN_HUB_ADDRESS: ${CLUPILOT_WG_HUB_ADDRESS:-10.66.0.1} + # Plain-HTTP health port on the hub address, for the deployment to ask + # whether this gateway is really listening in the tunnel's namespace. + # Never published; 10.66.0.1 exists only inside the tunnel. + VPN_HEALTH_PORT: ${VPN_HEALTH_PORT:-8081} VPN_CERT_PATH: ${VPN_CERT_PATH:-} VPN_KEY_PATH: ${VPN_KEY_PATH:-} depends_on: diff --git a/docker/caddy/vpn.Caddyfile b/docker/caddy/vpn.Caddyfile index 458ffc5..f6df4ea 100644 --- a/docker/caddy/vpn.Caddyfile +++ b/docker/caddy/vpn.Caddyfile @@ -28,3 +28,21 @@ https://{$VPN_INTERNAL_HOST}:443 { header_up Host {host} } } + +# A health port the deployment can ask, on the hub address and nowhere else. +# +# The site above matches on the console's HOSTNAME. A probe to the bare address +# sends no SNI, is offered no certificate and fails the handshake — which is +# indistinguishable from the gateway being down, and that is precisely the +# mistake that left VPN_READY false on a healthy tunnel. Plain HTTP on a +# separate port removes TLS, SNI and name resolution from the question and +# answers only what is being asked: is this gateway listening, in this network +# namespace, right now. +# +# Not a hole: 10.66.0.1 exists only inside the tunnel, and this port is never +# published. Caddy also refuses to start when the certificate above cannot be +# read, so a health port that answers proves the whole file loaded. +http://{$VPN_HUB_ADDRESS}:{$VPN_HEALTH_PORT:8081} { + respond /healthz 204 + respond 404 +} diff --git a/docs/handoffs/2026-07-25-clupilot-state-handoff.md b/docs/handoffs/2026-07-25-clupilot-state-handoff.md index 5c72ad7..5d0ac82 100644 --- a/docs/handoffs/2026-07-25-clupilot-state-handoff.md +++ b/docs/handoffs/2026-07-25-clupilot-state-handoff.md @@ -109,7 +109,7 @@ Services: `app` (php-fpm+nginx+**vite** via supervisor — EIN Container), `reve ## 9. Regeln (Kurzfassung — verbindlich) -R1/R2 full-page **klassenbasierte Livewire** als Routen (kein Volt, kein Page-Controller) · R3 nur `@theme`/Token-Utilities, kein Hex · R4 keine Inline-Styles außer Progress-`width` · R5 destruktiv → wire-elements/modal · R6 Ordner-Map · R7 responsive 375/768/1280, Touch ≥44px · R8 alles im Container · R9 UI-Copy DE, **keine Emoji** (Status via Farbe/Dots/Pills) · R10 Design-System wiederverwenden · R11 URLs per **UUID** (nicht Integer-PK) · R12 **Browser-verifiziert HTTP 200 + 0 Konsolenfehler** · R13 Routen-Pfade/Namen **englisch** · R14 Fonts **self-hosted** · R15 **Codex-Review clean** vor „fertig" · R16 alles lokalisiert **DE+EN** (identische Keys) · R17 Blade-`@php`-Block-Regeln. +R1/R2 full-page **klassenbasierte Livewire** als Routen (kein Volt, kein Page-Controller) · R3 nur `@theme`/Token-Utilities, kein Hex · R4 keine Inline-Styles außer Progress-`width` · R5 destruktiv → wire-elements/modal · R6 Ordner-Map · R7 responsive 375/768/1280, Touch ≥44px · R8 alles im Container · R9 UI-Copy DE, **keine Emoji** (Status via Farbe/Dots/Pills) · R10 Design-System wiederverwenden · R11 URLs per **UUID** (nicht Integer-PK) · R12 **Browser-verifiziert HTTP 200 + 0 Konsolenfehler** · R13 Routen-Pfade/Namen **englisch** · R14 Fonts **self-hosted** · R15 **Codex-Review clean** vor „fertig" · R16 alles lokalisiert **DE+EN** (identische Keys) · R17 Blade-`@php`-Block-Regeln · **R18 Icon-Größe und Zeilenumbruch** (siehe `CLAUDE.md`). > Volltext `rules.md`/`CLAUDE.md` hat der Nutzer (nicht im Repo). Bei Konflikt: STOP & fragen. ## 10. Session-Memory diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index c82d861..4f4e939 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -43,6 +43,7 @@ return [ 'role_support' => 'Support', 'role_billing' => 'Abrechnung', 'role_read-only' => 'Nur Lesen', + 'role_developer' => 'Entwickler', 'role_updated' => 'Rolle aktualisiert.', 'staff_revoked' => 'Zugriff entzogen.', @@ -104,6 +105,31 @@ return [ 'running' => 'läuft', ], + // Angefordert, aber der Dienst prüft im Takt — ohne Uhrzeit sitzt man davor + // und weiß nicht, ob der Knopf überhaupt etwas getan hat. + 'update_queued' => 'Angefordert. Der Update-Dienst startet sie spätestens um :time.', + 'update_step' => 'Schritt: :step', + 'update_since' => 'Läuft seit :when', + 'update_offline_hint' => 'Während der Aktualisierung ist die Seite kurz im Wartungsmodus — diese Anzeige bleibt so lange stehen und meldet sich danach von selbst zurück.', + 'update_took' => 'Dauer: :duration', + 'update_failed_at' => 'Abgebrochen beim Schritt: :step', + + // Die Schritte, die deploy/update.sh meldet. Das Skript schreibt Schlüssel, + // damit hier übersetzt und nicht englisch durchgereicht wird. + 'update_phase' => [ + 'fetch' => 'Neue Version holen', + 'maintenance_on' => 'Wartungsmodus einschalten', + 'checkout' => 'Code übernehmen', + 'image' => 'Container-Image neu bauen', + 'composer' => 'PHP-Pakete installieren', + 'npm' => 'JS-Pakete installieren', + 'migrate' => 'Datenbank migrieren', + 'assets' => 'Assets neu bauen', + 'caches' => 'Caches neu aufbauen', + 'restart' => 'Dienste neu starten', + 'maintenance_off' => 'Wartungsmodus beenden', + ], + 'password_title' => 'Passwort ändern', 'password_sub' => 'Gilt sofort. Andere Sitzungen bleiben angemeldet.', 'password_current' => 'Aktuelles Passwort', diff --git a/lang/de/secrets.php b/lang/de/secrets.php index ade0f0d..f746580 100644 --- a/lang/de/secrets.php +++ b/lang/de/secrets.php @@ -1,6 +1,14 @@ [ + 'stripe_secret' => 'Stripe Secret Key', + 'dns_token' => 'Hetzner-DNS-API-Token', + 'monitoring_token' => 'Uptime-Kuma-API-Token', + 'mail_password' => 'SMTP-Passwort (E-Mail-Versand)', + ], + 'title' => 'Zugangsdaten', 'subtitle' => 'Schlüssel für angebundene Dienste — hier änderbar, ohne Zugriff auf den Server.', 'no_key' => 'SECRETS_KEY ist auf diesem Server nicht gesetzt. Ohne eigenen Schlüssel werden hier keine Zugangsdaten gespeichert — bewusst, denn APP_KEY wird routinemäßig gewechselt.', diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index f3acd9f..6506302 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -43,6 +43,7 @@ return [ 'role_support' => 'Support', 'role_billing' => 'Billing', 'role_read-only' => 'Read-only', + 'role_developer' => 'Developer', 'role_updated' => 'Role updated.', 'staff_revoked' => 'Access revoked.', @@ -104,6 +105,31 @@ return [ 'running' => 'running', ], + // Requested, but the service checks on a timer — without a time an operator + // sits in front of it not knowing whether the button did anything. + 'update_queued' => 'Requested. The update service starts it by :time at the latest.', + 'update_step' => 'Step: :step', + 'update_since' => 'Running since :when', + 'update_offline_hint' => 'During the update the site is briefly in maintenance mode — this view stays put and comes back on its own afterwards.', + 'update_took' => 'Took :duration', + 'update_failed_at' => 'Stopped at step: :step', + + // The steps deploy/update.sh reports. The script writes keys so they are + // translated here rather than passed through in English. + 'update_phase' => [ + 'fetch' => 'Fetching the new version', + 'maintenance_on' => 'Enabling maintenance mode', + 'checkout' => 'Applying the code', + 'image' => 'Rebuilding the container image', + 'composer' => 'Installing PHP packages', + 'npm' => 'Installing JS packages', + 'migrate' => 'Migrating the database', + 'assets' => 'Rebuilding assets', + 'caches' => 'Rebuilding caches', + 'restart' => 'Restarting services', + 'maintenance_off' => 'Leaving maintenance mode', + ], + 'password_title' => 'Change password', 'password_sub' => 'Takes effect immediately. Other sessions stay signed in.', 'password_current' => 'Current password', diff --git a/lang/en/secrets.php b/lang/en/secrets.php index c757486..e5b167f 100644 --- a/lang/en/secrets.php +++ b/lang/en/secrets.php @@ -1,6 +1,14 @@ [ + 'stripe_secret' => 'Stripe secret key', + 'dns_token' => 'Hetzner DNS API token', + 'monitoring_token' => 'Uptime Kuma API token', + 'mail_password' => 'SMTP password (outgoing mail)', + ], + 'title' => 'Credentials', 'subtitle' => 'Keys for connected services — changeable here, without server access.', 'no_key' => 'SECRETS_KEY is not set on this server. Without a key of its own nothing is stored here — deliberately, because APP_KEY is rotated as routine maintenance.', diff --git a/resources/views/components/ui/icon.blade.php b/resources/views/components/ui/icon.blade.php index 073d1ed..2716400 100644 --- a/resources/views/components/ui/icon.blade.php +++ b/resources/views/components/ui/icon.blade.php @@ -39,11 +39,27 @@ 'settings' => '', ]; $body = $icons[$name] ?? ''; + + // The default size applies only when the caller has not chosen one. + // + // Merging `size-5` unconditionally does NOT lose to a caller's `size-4`: + // both utilities have the same specificity, so stylesheet order decides, + // and Tailwind emits `.size-4` before `.size-5`. Every icon written as + // size-4 was therefore rendering at 20px — visibly too large, and against + // whatever the call site asked for. + $sized = preg_match('/(^|\s)(size|[hw])-\S/', (string) $attributes->get('class', '')) === 1; @endphp +{{-- + inline-block, not the block that Tailwind's preflight gives an svg: a block + icon inside an inline parent pushes the text that follows it onto a second + line. In a flex or grid parent this changes nothing (items are blockified + there), so it is a safety net that costs nothing. shrink-0 for the matching + reason: an icon must never be squashed by a long label beside it. +--}} diff --git a/resources/views/components/ui/nav-item.blade.php b/resources/views/components/ui/nav-item.blade.php index 25484f0..1586e14 100644 --- a/resources/views/components/ui/nav-item.blade.php +++ b/resources/views/components/ui/nav-item.blade.php @@ -4,7 +4,16 @@ 'disabled' => false, ]) @php + // items-center, not baseline: the icon is a block-level svg (Tailwind's + // preflight makes it one) and must sit BESIDE the label, never above it. $base = 'flex items-center gap-3 rounded border-l-2 px-3 py-2 text-sm font-medium min-h-11'; + + // The label is a flex row of its own so that an icon handed to the default + // slot instead of the icon slot still lands next to the text. Passing it in + // the default slot put a display:block svg into an inline span and pushed + // the label onto a second line — the sidebar entry then stood twice as tall + // as every other one. Layout must not depend on which slot was used. + $label = 'flex min-w-0 items-center gap-3'; $state = $active ? 'bg-accent-subtle text-accent-text border-accent' : 'text-muted border-transparent hover:bg-surface-hover hover:text-body'; @@ -13,11 +22,11 @@ {{-- Section not built yet: render a non-interactive item, never a dead link. --}} merge(['class' => $base.' border-transparent text-faint cursor-not-allowed']) }}> @isset($icon){{ $icon }}@endisset - {{ $slot }} + {{ $slot }} @else merge(['class' => $base.' '.$state]) }}> @isset($icon){{ $icon }}@endisset - {{ $slot }} + {{ $slot }} @endif diff --git a/resources/views/errors/401.blade.php b/resources/views/errors/401.blade.php index 76ea492..7117e10 100644 --- a/resources/views/errors/401.blade.php +++ b/resources/views/errors/401.blade.php @@ -1,6 +1 @@ -@include('errors.layout', [ - 'code' => 401, - 'title' => __('errors.401.title'), - 'body' => __('errors.401.body'), - 'hint' => __('errors.401.hint'), -]) +@include('errors.layout', ['code' => 401]) diff --git a/resources/views/errors/403.blade.php b/resources/views/errors/403.blade.php index 72c0f39..ae12358 100644 --- a/resources/views/errors/403.blade.php +++ b/resources/views/errors/403.blade.php @@ -1,6 +1 @@ -@include('errors.layout', [ - 'code' => 403, - 'title' => __('errors.403.title'), - 'body' => __('errors.403.body'), - 'hint' => __('errors.403.hint'), -]) +@include('errors.layout', ['code' => 403]) diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php index 05b9a59..eefcf20 100644 --- a/resources/views/errors/404.blade.php +++ b/resources/views/errors/404.blade.php @@ -1,6 +1 @@ -@include('errors.layout', [ - 'code' => 404, - 'title' => __('errors.404.title'), - 'body' => __('errors.404.body'), - 'hint' => __('errors.404.hint'), -]) +@include('errors.layout', ['code' => 404]) diff --git a/resources/views/errors/405.blade.php b/resources/views/errors/405.blade.php index 0aa0757..8c8c9d0 100644 --- a/resources/views/errors/405.blade.php +++ b/resources/views/errors/405.blade.php @@ -1,6 +1 @@ -@include('errors.layout', [ - 'code' => 405, - 'title' => __('errors.405.title'), - 'body' => __('errors.405.body'), - 'hint' => __('errors.405.hint'), -]) +@include('errors.layout', ['code' => 405]) diff --git a/resources/views/errors/419.blade.php b/resources/views/errors/419.blade.php index 6168a92..0d29d96 100644 --- a/resources/views/errors/419.blade.php +++ b/resources/views/errors/419.blade.php @@ -1,6 +1 @@ -@include('errors.layout', [ - 'code' => 419, - 'title' => __('errors.419.title'), - 'body' => __('errors.419.body'), - 'hint' => __('errors.419.hint'), -]) +@include('errors.layout', ['code' => 419]) diff --git a/resources/views/errors/429.blade.php b/resources/views/errors/429.blade.php index 8a898f8..f08fe7a 100644 --- a/resources/views/errors/429.blade.php +++ b/resources/views/errors/429.blade.php @@ -1,6 +1 @@ -@include('errors.layout', [ - 'code' => 429, - 'title' => __('errors.429.title'), - 'body' => __('errors.429.body'), - 'hint' => __('errors.429.hint'), -]) +@include('errors.layout', ['code' => 429]) diff --git a/resources/views/errors/500.blade.php b/resources/views/errors/500.blade.php index d31e4e9..767799c 100644 --- a/resources/views/errors/500.blade.php +++ b/resources/views/errors/500.blade.php @@ -1,6 +1 @@ -@include('errors.layout', [ - 'code' => 500, - 'title' => __('errors.500.title'), - 'body' => __('errors.500.body'), - 'hint' => __('errors.500.hint'), -]) +@include('errors.layout', ['code' => 500]) diff --git a/resources/views/errors/layout.blade.php b/resources/views/errors/layout.blade.php index 3b9426a..d4491bf 100644 --- a/resources/views/errors/layout.blade.php +++ b/resources/views/errors/layout.blade.php @@ -1,4 +1,22 @@ -@props(['code', 'title', 'body', 'hint' => null]) +@props(['code']) +@php + /** + * The text is looked up from the code rather than passed in. + * + * Four of these codes have no hint, and the lang files say so with `null`. + * Laravel's translator returns the KEY when a line resolves to null, so + * `__('errors.404.hint')` printed "errors.404.hint" on the page — which is + * what it did, live, on 403/404/405/429. Lang::has() is false for a null + * line, so it is the check that actually distinguishes the two cases. + */ + $line = fn (string $part) => \Illuminate\Support\Facades\Lang::has("errors.{$code}.{$part}") + ? __("errors.{$code}.{$part}") + : null; + + $title = $line('title') ?? (string) $code; + $body = $line('body'); + $hint = $line('hint'); +@endphp @@ -76,7 +94,9 @@
{{ __('errors.heading') }}{{ $code }}

{{ $title }}

-

{{ $body }}

+ @if ($body) +

{{ $body }}

+ @endif @if ($hint)

{{ $hint }}

@endif diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 9284f7f..f924f3d 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -52,7 +52,8 @@ {{-- Only where the capability is held: "can open the console" must not mean "can read the payment key". --}} - {{ __('admin.nav.secrets') }} + + {{ __('admin.nav.secrets') }} @endcan diff --git a/resources/views/livewire/admin/secrets.blade.php b/resources/views/livewire/admin/secrets.blade.php index 9d491d9..b24a08d 100644 --- a/resources/views/livewire/admin/secrets.blade.php +++ b/resources/views/livewire/admin/secrets.blade.php @@ -72,11 +72,14 @@
{{-- Test first: a key that is saved and wrong fails later, - somewhere else, usually in front of a customer. --}} - - {{ __('secrets.test') }} - + somewhere else, usually in front of a customer. Only + where this entry actually has a checker. --}} + @if ($entry['testable']) + + {{ __('secrets.test') }} + + @endif {{ $update['last_error'] }} @endif + {{-- Where it is. "Läuft gerade" alone is what sends an operator to + the shell: the service checks on a timer, so a queued update + does nothing at all for up to five minutes, and while it runs + the site is in maintenance mode and this page is unreachable. + Both of those look identical to "wedged" without a time. --}} + @if ($update['running']) +
+ @if ($update['phase']) +

{{ __('admin_settings.update_step', ['step' => $update['phase']]) }}

+ @if ($update['started_at']) +

{{ __('admin_settings.update_since', ['when' => $update['started_at']->diffForHumans()]) }}

+ @endif + @elseif ($update['next_check_at']) +

{{ __('admin_settings.update_queued', ['time' => $update['next_check_at']->timezone(config('app.timezone'))->format('H:i')]) }}

+ @endif +

{{ __('admin_settings.update_offline_hint') }}

+
+ @endif + @if ($update['last_state'] !== null && ! $update['running'])

{{ __('admin_settings.update_last_run', [ 'state' => __('admin_settings.update_state.'.$update['last_state']), 'when' => $update['last_finished_at']?->diffForHumans() ?? '—', ]) }} + @if ($update['last_started_at'] && $update['last_finished_at']) + · {{ __('admin_settings.update_took', ['duration' => $update['last_started_at']->diffForHumans($update['last_finished_at'], true)]) }} + @endif + {{-- Only on a failure: on a run that worked the last step is + "left maintenance mode", which says nothing. --}} + @if ($update['last_state'] === 'failed' && $update['last_phase']) + · {{ __('admin_settings.update_failed_at', ['step' => $update['last_phase']]) }} + @endif

@endif diff --git a/tests/Feature/Admin/UpdateButtonTest.php b/tests/Feature/Admin/UpdateButtonTest.php index 5a26d30..3f73b51 100644 --- a/tests/Feature/Admin/UpdateButtonTest.php +++ b/tests/Feature/Admin/UpdateButtonTest.php @@ -120,12 +120,183 @@ it('survives a status file that is corrupt', function () { ->assertOk(); }); +it('says when a queued update will actually start', function () { + // The agent checks on a timer, so pressing the button visibly does nothing + // for minutes. Without a time that is indistinguishable from a broken + // button, which is precisely how it was read. + // Frozen: the assertion is about the arithmetic, and a clock that moves + // between writing the file and reading it turns 5 into 4.998. + Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00')); + + writeStatus([ + 'state' => 'idle', + 'checked_at' => now()->toIso8601String(), + 'check_interval_minutes' => 5, + 'behind' => 1, + ]); + + app(UpdateChannel::class)->request('owner@example.com'); + + $state = app(UpdateChannel::class)->state(); + + expect($state['running'])->toBeTrue() + ->and($state['next_check_at']?->toIso8601String())->toBe(now()->addMinutes(5)->toIso8601String()); + + Livewire::actingAs(operator('Owner')) + ->test(AdminSettings::class) + ->assertSee(__('admin_settings.update_queued', [ + 'time' => $state['next_check_at']->timezone(config('app.timezone'))->format('H:i'), + ])); +}); + +it('names the step the deployment is on, in the operator’s language', function () { + // The step comes from the file update.sh rewrites as it goes, NOT from the + // status document: the agent writes that once and then blocks for the whole + // run, so a phase taken from there is frozen at the first step forever. + writeStatus([ + 'state' => 'running', + 'checked_at' => now()->toIso8601String(), + 'started_at' => now()->subMinutes(2)->toIso8601String(), + 'behind' => 1, + ]); + writePhase('migrate'); + + Livewire::actingAs(operator('Owner')) + ->test(AdminSettings::class) + ->assertSee(__('admin_settings.update_step', [ + 'step' => __('admin_settings.update_phase.migrate'), + ])); +}); + +it('follows the step file as the deployment moves on', function () { + writeStatus(['state' => 'running', 'checked_at' => now()->toIso8601String(), 'behind' => 1]); + + writePhase('composer'); + expect(app(UpdateChannel::class)->state()['phase'])->toBe(__('admin_settings.update_phase.composer')); + + writePhase('restart'); + expect(app(UpdateChannel::class)->state()['phase'])->toBe(__('admin_settings.update_phase.restart')); +}); + +it('does not print a raw phase key it has no translation for', function () { + // A newer deployment script against older translations. Better to say + // nothing than to put `update_phase.something` in front of an operator. + writeStatus([ + 'state' => 'running', + 'checked_at' => now()->toIso8601String(), + 'behind' => 1, + ]); + writePhase('something_new'); + + expect(app(UpdateChannel::class)->state()['phase'])->toBeNull(); +}); + +it('does not present the last failure’s step as this update’s progress', function () { + // A failed run leaves its phase file in place on purpose — that is how the + // failing step gets named. Queueing the next update must not then show it + // as already mid-migration, hiding the only thing that is actually known: + // when it will start. + Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00')); + + writeStatus([ + 'state' => 'idle', + 'checked_at' => now()->toIso8601String(), + 'check_interval_minutes' => 5, + 'behind' => 1, + ]); + writePhase('migrate'); + + app(UpdateChannel::class)->request('owner@example.com'); + + $state = app(UpdateChannel::class)->state(); + + expect($state['running'])->toBeTrue() + ->and($state['phase'])->toBeNull(); + + Livewire::actingAs(operator('Owner')) + ->test(AdminSettings::class) + ->assertSee(__('admin_settings.update_queued', [ + 'time' => $state['next_check_at']->timezone(config('app.timezone'))->format('H:i'), + ])) + ->assertDontSee(__('admin_settings.update_phase.migrate')); +}); + +it('ignores a step written before the run that is going on now', function () { + // A manual `bash deploy/update.sh` on the shell leaves a phase file the + // agent never cleared. + Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00')); + + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put( + storage_path('app/deploy/update-phase'), + "assets\t".now()->subHour()->toIso8601String()."\n", + ); + + writeStatus([ + 'state' => 'running', + 'checked_at' => now()->toIso8601String(), + 'started_at' => now()->subMinute()->toIso8601String(), + 'behind' => 1, + ]); + + expect(app(UpdateChannel::class)->state()['phase'])->toBeNull(); +}); + +it('never promises a start time that has already gone by', function () { + // A systemd timer that ran late leaves the last check older than its own + // interval. checked_at + interval is then in the past, and the page would + // promise a start that demonstrably did not happen. + Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00')); + + writeStatus([ + 'state' => 'idle', + 'checked_at' => now()->subMinutes(12)->toIso8601String(), + 'check_interval_minutes' => 5, + 'behind' => 1, + ]); + + app(UpdateChannel::class)->request('owner@example.com'); + + $next = app(UpdateChannel::class)->state()['next_check_at']; + + expect($next)->not->toBeNull() + ->and($next->isFuture())->toBeTrue() + // One interval from now is the only honest upper bound once the timer + // is already overdue. + ->and($next->toIso8601String())->toBe(now()->addMinutes(5)->toIso8601String()); +}); + +it('names the step a failed update stopped at', function () { + writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]); + writeLastRun([ + 'state' => 'failed', + 'started_at' => now()->subMinutes(3)->toIso8601String(), + 'finished_at' => now()->toIso8601String(), + 'phase' => 'migrate', + 'error' => 'update_failed', + 'exit_code' => 1, + ]); + + Livewire::actingAs(operator('Owner')) + ->test(AdminSettings::class) + ->assertSee(__('admin_settings.update_failed_at', [ + 'step' => __('admin_settings.update_phase.migrate'), + ])); +}); + function writeStatus(array $status): void { File::ensureDirectoryExists(storage_path('app/deploy')); File::put(storage_path('app/deploy/update-status.json'), json_encode($status)); } +/** What deploy/update.sh writes as it moves from step to step. */ +function writePhase(string $key): void +{ + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/update-phase'), $key."\t".now()->toIso8601String()."\n"); +} + function writeLastRun(array $run): void { File::ensureDirectoryExists(storage_path('app/deploy')); diff --git a/tests/Feature/ErrorPagesTest.php b/tests/Feature/ErrorPagesTest.php new file mode 100644 index 0000000..78aa565 --- /dev/null +++ b/tests/Feature/ErrorPagesTest.php @@ -0,0 +1,52 @@ +setLocale($locale); + + $html = View::make("errors.{$code}")->render(); + + expect($html) + ->toContain((string) $code) + ->not->toContain("errors.{$code}.") + // The generic catch: any unresolved key of ours has this shape. + ->not->toMatch('/errors\.\d{3}\./'); + } +})->with($codes); + +it('shows a hint where there is one and nothing where there is not', function () { + app()->setLocale('de'); + + // 419 has a hint in the lang file; 404 deliberately does not. Asserted on + // the element, not on the word: "hint" is also the CSS class that styles it. + expect(View::make('errors.419')->render()) + ->toContain(__('errors.419.hint')) + ->toContain('

'); + + $html = View::make('errors.404')->render(); + + expect($html)->toContain(__('errors.404.title')) + ->and($html)->not->toContain('

'); +}); + +it('serves a real 404 through the stack', function () { + // The view rendering above cannot catch a broken error handler. + $this->get('/definitely-not-a-route') + ->assertNotFound() + ->assertSee(__('errors.404.title')) + ->assertDontSee('errors.404.'); +}); diff --git a/tests/Feature/IconLayoutTest.php b/tests/Feature/IconLayoutTest.php new file mode 100644 index 0000000..80725d9 --- /dev/null +++ b/tests/Feature/IconLayoutTest.php @@ -0,0 +1,103 @@ +'); + + expect($html)->toContain('size-4')->not->toContain('size-5'); +}); + +it('falls back to its own size when the call site names none', function () { + expect(Blade::render(''))->toContain('size-5'); +}); + +it('keeps an icon on the line it was written on', function () { + // Tailwind's preflight makes every svg display:block. Inside an inline + // parent that pushes the following text onto a second line, which is how a + // one-line navigation entry became two lines tall. + expect(Blade::render('')) + ->toContain('inline-block') + ->toContain('shrink-0'); +}); + +it('keeps a navigation entry on one line whichever slot the icon uses', function () { + // Blade::render leaves a slot's output buffer open when a component is + // rendered outside a request, which PHPUnit reports as a risky test. The + // markup it returns is complete; only the buffer needs tidying. + $render = function (string $template): string { + $level = ob_get_level(); + + try { + return Blade::render($template); + } finally { + while (ob_get_level() > $level) { + ob_end_clean(); + } + } + }; + + $viaSlot = $render( + 'Zugangsdaten' + ); + + // The mistake that caused the report: the icon handed to the DEFAULT slot + // rather than the icon slot. The layout must not depend on which was used. + $viaDefaultSlot = $render( + 'Zugangsdaten' + ); + + foreach ([$viaSlot, $viaDefaultSlot] as $html) { + expect($html) + ->toContain('items-center') + ->toContain('Zugangsdaten') + // The label wrapper is a flex row of its own, so a stray icon lands + // next to the text rather than above it. + ->toContain('flex min-w-0 items-center gap-3'); + } +}); + +it('puts navigation icons in the icon slot everywhere', function () { + // Belt to the component's braces. The component now survives an icon in the + // wrong slot, but the wrong slot also skips the shrink-0 wrapper and reads + // as an accident to the next person editing the file. + $offenders = []; + + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator(resource_path('views'), FilesystemIterator::SKIP_DOTS) + ); + + foreach ($files as $file) { + if (! str_ends_with($file->getFilename(), '.blade.php')) { + continue; + } + + $source = (string) file_get_contents($file->getPathname()); + + // Each nav-item element, from its opening tag to its closing one. + preg_match_all('//s', $source, $matches); + + foreach ($matches[0] as $element) { + $withoutIconSlot = preg_replace('/.*?<\/x-slot:icon>/s', '', $element); + + if (str_contains((string) $withoutIconSlot, 'getPathname()); + } + } + } + + expect($offenders)->toBe([]); +}); diff --git a/tests/Feature/TranslationParityTest.php b/tests/Feature/TranslationParityTest.php new file mode 100644 index 0000000..59573d5 --- /dev/null +++ b/tests/Feature/TranslationParityTest.php @@ -0,0 +1,58 @@ + every key in a translation array, dotted. */ +function flattenKeys(array $lines, string $prefix = ''): array +{ + $keys = []; + + foreach ($lines as $key => $value) { + $path = $prefix === '' ? (string) $key : $prefix.'.'.$key; + + // Only nested arrays recurse. A list of strings — a bullet list in the + // interface — is one key whose shape is the translator's business. + if (is_array($value) && ! array_is_list($value)) { + $keys = [...$keys, ...flattenKeys($value, $path)]; + + continue; + } + + $keys[] = $path; + } + + sort($keys); + + return $keys; +} + +it('has the same keys in German and English', function () { + $files = array_map( + fn (string $path) => basename($path), + glob(lang_path('de/*.php')) ?: [], + ); + + expect($files)->not->toBeEmpty(); + + foreach ($files as $file) { + $de = require lang_path('de/'.$file); + $enPath = lang_path('en/'.$file); + + expect(file_exists($enPath))->toBeTrue("lang/en/{$file} is missing"); + + $en = require $enPath; + + $missingInEn = array_diff(flattenKeys($de), flattenKeys($en)); + $missingInDe = array_diff(flattenKeys($en), flattenKeys($de)); + + expect($missingInEn)->toBe([], "missing in lang/en/{$file}: ".implode(', ', $missingInEn)); + expect($missingInDe)->toBe([], "missing in lang/de/{$file}: ".implode(', ', $missingInDe)); + } +});