Give the update screen the product's colours, and only one window
tests / pest (push) Failing after 8m16s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details

Two complaints, both correct, both mine.

Dark blue. The panel carried a `prefers-color-scheme: dark` block — the only
one in a product that has no dark mode. On a phone set to dark the update
screen, and nothing else, turned dark blue. A dark mode for one page is not a
dark mode; it is one page that does not match. It is gone.

And even in daylight the panel was not this product's colours: it used
Tailwind's slate ramp (#0f172a, #64748b, #e2e8f0), which is blue-tinted,
against neutrals that are warm. Every value is now a copy of a token from
portal-tokens.css, with the comment saying so, because this file cannot
import them — it renders when the asset build may not exist.

The small window then the big one. The settings card grew its own little
"läuft gerade" block the instant a run started; up to three seconds later the
full-page overlay covered it and said the same thing in a different size. The
card no longer says it at all, and the overlay now opens on the button's own
click — requestUpdate dispatches an event, but only once a run is actually
pending, so a refused second press does not black out the console.

Two more things found while looking:

The panel had `min-height: 100%`, which resolves against the parent's height.
On the 503 page the parent is <body>, whose height is auto — so it computed
to zero, the panel was only as tall as its text, and the bottom two thirds of
the screen stayed the browser's default white. Viewport units now, dvh so a
phone's collapsing address bar leaves no strip either.

And the mark was a plain orange tile again, the same empty-square that was
just fixed on the placeholder. The real logo is inlined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/plan-marketing v1.3.8
nexxo 2026-07-29 14:11:01 +02:00
parent 4755a4f362
commit b843c10ec3
6 changed files with 182 additions and 58 deletions

View File

@ -1 +1 @@
1.3.7
1.3.8

View File

@ -419,6 +419,20 @@ class Settings extends Component
$this->dispatch('notify', message: __($accepted
? 'admin_settings.update_requested'
: 'admin_settings.update_already_requested'));
// Opens the maintenance overlay on THIS click rather than on the
// watcher's next poll. Without it there were visibly two screens: this
// component re-rendered at once and grew a little "läuft gerade" panel
// inside the settings card, and up to three seconds later the overlay
// covered the page and said the same thing again. Reported as "first a
// small window, then the big one".
//
// Dispatched only once a run is actually pending — an overlay that
// covers the console because a button was pressed, over nothing, is
// worse than a slow one.
if ($accepted) {
$this->dispatch('update-started');
}
}
/**

View File

@ -114,6 +114,10 @@
runningSince: @js($updateRunningSince),
log: @js($updateLog),
})"
{{-- The button's own click, not the next poll three seconds later.
Those three seconds were long enough to show a second, smaller
"läuft gerade" panel inside the settings card first. --}}
@update-started.window="wasRunning = true"
x-show="wasRunning"
x-cloak
x-transition:enter="transition ease-out duration-200"

View File

@ -152,21 +152,15 @@
jumped, forever. And it was wrong in kind: a check starts
nothing, and an update now starts at once. A queued run says so
in a sentence; a queued check says nothing beyond its badge. --}}
@if ($update['running'])
<div class="mt-4 space-y-1 rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm">
@if ($update['phase'])
<p class="font-medium text-body">{{ __('admin_settings.update_step', ['step' => $update['phase']]) }}</p>
@if ($update['started_at'])
<p class="text-xs text-muted">{{ __('admin_settings.update_since', ['when' => $update['started_at']->diffForHumans()]) }}</p>
@endif
@else
<p class="font-medium text-body">
{{ $update['instant'] ? __('admin_settings.update_starting') : __('admin_settings.update_starting_queued') }}
</p>
@endif
<p class="text-xs text-muted">{{ __('admin_settings.update_offline_hint') }}</p>
</div>
@endif
{{-- And the step is NOT shown here.
It used to be, in a small bordered block inside this card, and
the moment a run began an operator saw it appear and then, up
to three seconds later, saw the full-page overlay cover it and
say the same thing in a different size. Two windows for one
event. The overlay in layouts/admin carries the step, the
running time and the log tail it is the one that survives the
restart, so it is the one that keeps them. --}}
@if ($update['last_state'] !== null && ! $update['running'])
<p class="mt-3 text-xs text-muted">

View File

@ -4,8 +4,7 @@
It showed up twice with two different faces: the console's own overlay in the
app's light tokens while the application was still up, and then this, the
503 page, once the containers went down. Two designs for one event, one
replacing the other mid-update. Reported as "the first screen must not be
there", and rightly.
replacing the other mid-update.
The styles are inline and self-contained rather than Tailwind classes,
because the other place this renders is the 503 page shown while the
@ -16,38 +15,81 @@
Prefixed class names: this is injected into the console's own document,
which has a stylesheet of its own, and `.badge` is not a name to claim
globally.
── On the colours, because they were wrong twice ────────────────────────
Every value below is a copy of a token from resources/css/portal-tokens.css.
They were not: the first version used Tailwind's slate ramp (#0f172a,
#64748b, #e2e8f0), which is blue-tinted, against a product whose neutrals
are warm. Even in daylight the panel read cooler than everything around it.
And it carried a `prefers-color-scheme: dark` block, which nothing else in
this product has. On a phone set to dark mode the update screen and only
the update screen turned dark blue. Reported exactly that way. A dark mode
for one page is not a dark mode; it is one page that does not match.
--}}
<style>
.cpu-wrap { --cpu-bg: #f6f7f9; --cpu-fg: #0f172a; --cpu-muted: #64748b; --cpu-accent: #f97316; --cpu-card: #ffffff; --cpu-line: #e2e8f0;
display: grid; place-items: center; text-align: center; padding: 4rem 1.5rem; min-height: 100%;
background: var(--cpu-bg); color: var(--cpu-fg);
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif; }
@media (prefers-color-scheme: dark) {
.cpu-wrap { --cpu-bg: #0b0f19; --cpu-fg: #e2e8f0; --cpu-muted: #94a3b8; --cpu-card: #111827; --cpu-line: #1f2937; }
.cpu-wrap {
/* portal-tokens.css: --bg, --text-strong, --text-muted, --accent,
--accent-text, --surface, --border, --surface-2 */
--cpu-bg: #f6f6f8; --cpu-fg: #17171c; --cpu-muted: #6e6e7a;
--cpu-accent: #f97316; --cpu-accent-ink: #b8500a;
--cpu-card: #ffffff; --cpu-card-2: #fafafb; --cpu-line: #e9e9ee;
display: grid; place-items: center; text-align: center;
padding: 4rem 1.5rem;
/* Viewport units, not `100%`. A percentage resolves against the
parent's HEIGHT, and on the 503 page the parent is <body>, whose
height is auto so it resolved to zero, the panel was only as tall
as its text, and the bottom two thirds of the screen stayed the
browser's default white. dvh so a phone's collapsing address bar does
not leave a strip either; vh first for anything that lacks it. */
min-height: 100vh; min-height: 100dvh;
background: var(--cpu-bg); color: var(--cpu-fg);
font-family: "IBM Plex Sans", "Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
}
.cpu-main { max-width: 34rem; width: 100%; }
.cpu-mark { width: 2.5rem; height: 2.5rem; margin: 0 auto; border-radius: 0.75rem; background: var(--cpu-accent); }
.cpu-badge { display: inline-flex; align-items: center; gap: 0.5rem; margin-top: 1.25rem; padding: 0.25rem 0.75rem;
border: 1px solid var(--cpu-line); border-radius: 9999px; background: var(--cpu-card);
font-size: 0.72rem; font-weight: 600; color: var(--cpu-accent); }
.cpu-dot { width: 0.5rem; height: 0.5rem; border-radius: 9999px; background: var(--cpu-accent); animation: cpu-pulse 1.6s ease-in-out infinite; }
@keyframes cpu-pulse { 50% { opacity: 0.35; } }
.cpu-mark { width: 2.75rem; height: 2.75rem; margin: 0 auto; display: block; }
.cpu-badge { display: inline-flex; align-items: center; gap: 0.5rem; margin-top: 1.5rem;
padding: 0.4rem 0.85rem; border: 1px solid #e8cdb2; border-radius: 9999px;
background: #fff3e9; color: var(--cpu-accent-ink);
font-family: "IBM Plex Mono", "Plex Mono", ui-monospace, monospace;
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.07em; }
.cpu-dot { width: 0.375rem; height: 0.375rem; border-radius: 9999px; background: var(--cpu-accent);
animation: cpu-pulse 2s ease-in-out infinite; }
@keyframes cpu-pulse { 50% { opacity: 0.3; } }
@media (prefers-reduced-motion: reduce) { .cpu-dot { animation: none; } }
.cpu-title { margin: 1.75rem 0 0; font-size: 1.5rem; line-height: 1.25; letter-spacing: -0.01em; }
.cpu-body { margin: 0.75rem 0 0; font-size: 0.9rem; line-height: 1.65; color: var(--cpu-muted); }
.cpu-foot { margin-top: 2rem; font-size: 0.75rem; color: var(--cpu-muted); }
.cpu-detail { margin-top: 1.5rem; text-align: left; }
.cpu-step { margin: 0; padding: 0.7rem 0.9rem; border: 1px solid var(--cpu-line); border-radius: 0.5rem;
background: var(--cpu-card); font-size: 0.85rem; font-weight: 500; }
.cpu-since { margin: 0.5rem 0 0; font-size: 0.75rem; color: var(--cpu-muted); }
.cpu-log { margin: 0.5rem 0 0; max-height: 10rem; overflow: auto; padding: 0.7rem 0.9rem;
border: 1px solid var(--cpu-line); border-radius: 0.5rem; background: var(--cpu-card);
font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 0.72rem; line-height: 1.6; }
.cpu-title { margin: 1.5rem 0 0; font-size: clamp(1.6rem, 4vw, 2.1rem); font-weight: 700;
line-height: 1.1; letter-spacing: -0.035em; }
.cpu-body { margin: 0.9rem auto 0; max-width: 42ch; font-size: 0.95rem; line-height: 1.6;
color: var(--cpu-muted); }
.cpu-foot { margin-top: 2.25rem; font-family: "IBM Plex Mono", "Plex Mono", ui-monospace, monospace;
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.07em; color: var(--cpu-muted); }
.cpu-detail { margin-top: 2rem; text-align: left; }
.cpu-step { margin: 0; padding: 0.8rem 1rem; border: 1px solid var(--cpu-line); border-radius: 0.7rem;
background: var(--cpu-card); font-size: 0.9rem; font-weight: 500; }
.cpu-since { margin: 0.55rem 0 0; font-size: 0.78rem; color: var(--cpu-muted); }
.cpu-log { margin: 0.55rem 0 0; max-height: 11rem; overflow: auto; padding: 0.8rem 1rem;
border: 1px solid var(--cpu-line); border-radius: 0.7rem; background: var(--cpu-card-2);
font-family: "IBM Plex Mono", "Plex Mono", ui-monospace, monospace;
font-size: 0.72rem; line-height: 1.6; color: var(--cpu-muted); }
</style>
<div class="cpu-wrap">
<main class="cpu-main">
<div class="cpu-mark"></div>
{{-- The real mark, not a rounded orange tile. An empty brand square
reads as an image that failed to load, and this panel is shown at
the one moment somebody is already wondering whether something is
broken. --}}
<svg class="cpu-mark" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<linearGradient id="cpu-mark-grad" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop stop-color="#FB923C"/><stop offset="0.55" stop-color="#F97316"/><stop offset="1" stop-color="#C2560A"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="15" fill="url(#cpu-mark-grad)"/>
<path d="M22 41h20a8 8 0 0 0 .7-15.97A11 11 0 0 0 21.4 27.2 8.5 8.5 0 0 0 22 44z" fill="#ffffff" fill-opacity="0.22"/>
<path d="M32 17 44 44l-12-6-12 6z" fill="#ffffff"/>
</svg>
<div class="cpu-badge"><span class="cpu-dot"></span>{{ __('updating.badge') }}</div>
<h1 class="cpu-title">{{ __('updating.title') }}</h1>
<p class="cpu-body">{{ $body ?? __('updating.body') }}</p>

View File

@ -142,11 +142,12 @@ it('says a queued update is starting, and never counts down to it', function ()
$state = app(UpdateChannel::class)->state();
expect($state['running'])->toBeTrue()
->and($state)->not->toHaveKey('next_check_at');
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_starting'));
->and($state)->not->toHaveKey('next_check_at')
// `instant` is what selects the sentence. Asserted here rather than on
// the settings component: the sentence moved into the full-page overlay
// in layouts/admin, because it was being shown in BOTH and an operator
// saw one panel appear and a second cover it seconds later.
->and($state['instant'])->toBeTrue();
});
it('names the step the deployment is on, in the operators language', function () {
@ -161,9 +162,13 @@ it('names the step the deployment is on, in the operators language', function
]);
writePhase('migrate');
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_step', [
// Through the endpoint the overlay actually reads. That is the whole path
// now: no Livewire, no component state, no assets — because the thing being
// reported on restarts the containers serving the page.
$this->actingAs(operator('Owner'), 'operator')
->getJson(route('admin.update.state'))
->assertOk()
->assertJsonPath('step', __('admin_settings.update_step', [
'step' => __('admin_settings.update_phase.migrate'),
]));
});
@ -211,12 +216,14 @@ it('does not present the last failures step as this updates progress', fun
$state = app(UpdateChannel::class)->state();
expect($state['running'])->toBeTrue()
->and($state['phase'])->toBeNull();
->and($state['phase'])->toBeNull()
// Queued, not instant — which is the sentence the overlay picks.
->and($state['instant'])->toBeFalse();
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_starting_queued'))
->assertDontSee(__('admin_settings.update_phase.migrate'));
// And the endpoint the overlay reads says nothing about a step either.
$this->actingAs(operator('Owner'), 'operator')
->getJson(route('admin.update.state'))
->assertJsonPath('step', null);
});
it('ignores a step written before the run that is going on now', function () {
@ -492,10 +499,11 @@ it('tells a queued update apart from an immediate one, without either naming a t
app(UpdateChannel::class)->request('owner@example.com');
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_starting_queued'))
->assertDontSee(__('admin_settings.update_starting'));
// On the state, which is what the overlay in layouts/admin picks its
// sentence from. The settings card no longer says any of this — it said it
// at the same time as the overlay, in a smaller box, and an operator saw
// the page arrange itself twice.
expect(app(UpdateChannel::class)->state()['instant'])->toBeFalse();
});
it('does not believe a stale agent about being woken on demand', function () {
@ -790,3 +798,65 @@ it('keeps the deployment step in the overlay, where an operator needs it', funct
->toContain('x-text="runningSince"')
->toContain('x-text="log"');
});
it('has no dark mode of its own, in a product that has none', function () {
// Reported from a phone: the update screen — and only the update screen —
// came up dark blue. It carried a prefers-color-scheme block nothing else
// in this product has, so it was the one surface that flipped, and it
// flipped into Tailwind's slate ramp rather than the product's warm
// neutrals.
// Comments stripped first. The panel documents at length what it used to
// get wrong, naming the exact values — and a scan over the raw file reads
// that explanation as the offence it warns about. Twice now this file has
// had to learn the same thing: measure the thing, not the note about it.
$panel = preg_replace(
'/\{\{--.*?--\}\}/s',
'',
Illuminate\Support\Facades\File::get(resource_path('views/partials/updating-panel.blade.php')),
);
expect($panel)->not->toContain('prefers-color-scheme')
// The slate values it used. Named, so a paste of the same ramp is
// caught rather than merely discouraged by a comment.
->not->toContain('#0f172a')
->not->toContain('#64748b')
->not->toContain('#e2e8f0')
->not->toContain('#0b0f19')
// And the tokens it should be using instead.
->toContain('#f6f6f8')
->toContain('#17171c')
->toContain('#6e6e7a');
});
it('shows the step in one place, not in two of different sizes', function () {
// The settings card grew its own little "läuft gerade" block the instant a
// run started, and up to three seconds later the full-page overlay covered
// it saying the same thing. Reported as "first a small window, then the big
// one".
$card = Illuminate\Support\Facades\File::get(resource_path('views/livewire/admin/settings.blade.php'));
$layout = Illuminate\Support\Facades\File::get(resource_path('views/layouts/admin.blade.php'));
expect($card)->not->toContain("admin_settings.update_offline_hint")
->and($layout)->toContain('admin_settings.update_offline_hint');
});
it('opens the overlay on the click, not on the next poll', function () {
// Three seconds is long enough to see the page arrange itself twice.
$settings = Illuminate\Support\Facades\File::get(app_path('Livewire/Admin/Settings.php'));
$layout = Illuminate\Support\Facades\File::get(resource_path('views/layouts/admin.blade.php'));
expect($settings)->toContain("dispatch('update-started')")
->and($layout)->toContain('@update-started.window="wasRunning = true"');
});
it('does not cover the console because a button was pressed over nothing', function () {
// The overlay opens on the event, so the event must only fire once a run is
// actually pending. A second press, which the channel refuses, must not
// black out the page again.
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
$component = Livewire::actingAs(operator('Owner'), 'operator')->test(AdminSettings::class);
$component->call('requestUpdate')->assertDispatched('update-started');
$component->call('requestUpdate')->assertNotDispatched('update-started');
});