Give the update screen the product's colours, and only one window
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
parent
4755a4f362
commit
b843c10ec3
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue