diff --git a/VERSION b/VERSION index 3336003..e05cb33 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.7 +1.3.8 diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 29029cc..158df31 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -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'); + } } /** diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index f647cb6..f8f0012 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -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" diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index 08a60ab..12ee76d 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -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']) -
- @if ($update['phase']) -

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

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

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

- @endif - @else -

- {{ $update['instant'] ? __('admin_settings.update_starting') : __('admin_settings.update_starting_queued') }} -

- @endif -

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

-
- @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'])

diff --git a/resources/views/partials/updating-panel.blade.php b/resources/views/partials/updating-panel.blade.php index 6dcf8df..9394ec4 100644 --- a/resources/views/partials/updating-panel.blade.php +++ b/resources/views/partials/updating-panel.blade.php @@ -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. --}}

-
+ {{-- 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. --}} + +
{{ __('updating.badge') }}

{{ __('updating.title') }}

{{ $body ?? __('updating.body') }}

diff --git a/tests/Feature/Admin/UpdateButtonTest.php b/tests/Feature/Admin/UpdateButtonTest.php index 30de3f0..7d21105 100644 --- a/tests/Feature/Admin/UpdateButtonTest.php +++ b/tests/Feature/Admin/UpdateButtonTest.php @@ -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 operator’s language', function () { @@ -161,9 +162,13 @@ it('names the step the deployment is on, in the operator’s 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 failure’s step as this update’s 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'); +});