feat(ui): polish domains UX and dark-mode loading accents

Group service domain tables with inline redirect controls, live-save
redirects without full refreshes, and only auto-pair www hosts when a
www redirect is active. Align helper tooltips, server-timing HUD, and
loading spinners with dark-theme accents; default theme-color to dark.
This commit is contained in:
Andras Bacsai committed 2026-08-06 13:18:46 +02:00
1 parent 232b999cc9
commit bb83c1e3be
19 files changed
+404 -176

No files matched your search

@@ -131,6 +131,20 @@ test('Server-Timing HUD docks into navbar slots and floats only as fallback', fu
->toContain('compactSummary');
});
test('Server-Timing HUD follows the application color mode', function () {
$hud = file_get_contents(resource_path('views/components/server-timing-hud.blade.php'));
expect($hud)
->toContain('#server-timing-hud {')
->toContain('html.dark #server-timing-hud {')
->toContain('--sth-background: rgba(255, 255, 255, .96)')
->toContain('--sth-background: rgba(16, 16, 16, .96)')
->toContain('var(--sth-text)')
->toContain('var(--sth-border)')
->toContain('[data-sth-log]::-webkit-scrollbar-thumb')
->toContain('scrollbar-color: var(--sth-scrollbar-thumb) var(--sth-scrollbar-track)');
});
test('does not inject HUD into non-HTML or fragment responses', function () {
Config::set('app.server_timing', true);
@@ -12,3 +12,11 @@ test('breadcrumb switchers indicate the current item with background only', func
->and($views->every(fn (string $view): bool => str_contains($view, 'bg-neutral-100')))
->toBeTrue();
});
test('teleported resource breadcrumbs use the same spacing as application breadcrumbs', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($layout)
->toContain('class="flex items-center gap-0.5 min-w-0 flex-1 pl-3 pr-4"')
->not->toContain('class="flex items-center gap-1.5 min-w-0 flex-1 pl-3 pr-4"');
});
+11
View File
@@ -0,0 +1,11 @@
<?php
test('new users default to dark mode', function () {
$layout = file_get_contents(resource_path('views/layouts/base.blade.php'));
expect($layout)
->toContain('<html data-theme="dark"')
->toContain("const t = localStorage.theme || 'dark';")
->toContain("localStorage.theme = 'dark';")
->toContain('<meta name="theme-color" content="#101010" id="theme-color-meta" />');
});
@@ -0,0 +1,19 @@
<?php
test('docker cleanup execution output uses light and dark theme surfaces', function () {
$view = file_get_contents(resource_path('views/livewire/server/docker-cleanup-executions.blade.php'));
expect($view)
->toContain('bg-white text-neutral-700')
->toContain('dark:bg-neutral-950 dark:text-neutral-300');
});
test('docker cleanup executions show a loading state while opening an execution', function () {
$view = file_get_contents(resource_path('views/livewire/server/docker-cleanup-executions.blade.php'));
expect($view)
->toContain('wire:loading.attr="disabled"')
->toContain('wire:target="selectExecution({{ data_get($execution, \'id\') }})"')
->toContain('wire:loading.remove')
->toContain('<x-loading wire:loading');
});
+16 -1
View File
@@ -44,12 +44,27 @@ test('helper popup remains open while moving from the trigger into interactive c
->toContain('@mouseleave="hide()"');
});
test('clicking a helper does not pin its popup open', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
expect($helper)
->toContain('@click.prevent.stop="show()"')
->not->toContain('pinned');
});
test('helper popup stays in its alpine scope during livewire morphs', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
expect($helper)
->not->toContain('x-teleport');
});
test('helper popup supports focus dismissal and tooltip aria relationships', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
expect($helper)
->toContain('closeWhenFocusLeaves()')
->toContain('@focus="show(false)"')
->toContain('@focus="show()"')
->toContain('@blur="closeWhenFocusLeaves()"')
->toContain('role="tooltip"')
->toContain(':aria-describedby="open ? $id(\'helper-popup\') : null"')
+8 -3
View File
@@ -1,7 +1,8 @@
<?php
test('livewire loading indicators use the shared coollabs purple', function () {
test('loading indicators use yellow throughout dark mode', function () {
$utilities = file_get_contents(resource_path('css/utilities.css'));
$appCss = file_get_contents(resource_path('css/app.css'));
$loading = file_get_contents(resource_path('views/components/loading.blade.php'));
$pageLoading = file_get_contents(resource_path('views/components/page-loading.blade.php'));
$views = collect(new RecursiveIteratorIterator(new RecursiveDirectoryIterator(resource_path('views'))))
@@ -11,10 +12,14 @@ test('livewire loading indicators use the shared coollabs purple', function () {
expect($utilities)
->toContain('@utility loading-indicator')
->toContain('@apply text-coollabs dark:text-coollabs-100;')
->toContain('@apply text-coollabs dark:text-warning;')
->and($loading)->toContain('loading-indicator')
->and($pageLoading)->toContain('loading-indicator')
->and($views)->not->toMatch('/animate-spin[^"\n]*dark:text-warning|dark:text-warning[^"\n]*animate-spin/');
->and($appCss)->toContain('.dark .animate-spin')
->toContain('color: var(--color-warning) !important;')
->toContain('.dark #nprogress .bar')
->toContain('background: var(--color-warning) !important;')
->and($views)->not->toMatch('/animate-spin[^"\n]*dark:text-coollabs|dark:text-coollabs[^"\n]*animate-spin/');
});
test('livewire navigation progress bar uses coollabs purple', function () {
+50 -19
View File
@@ -90,12 +90,26 @@ beforeEach(function () {
]);
});
it('lists domains grouped by service application on the stack domains page', function () {
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
it('groups configured domains with their service redirect and excludes services without domains', function () {
$this->apiApp->update([
'fqdn' => 'https://api.example.com,https://admin.example.com',
]);
$html = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSuccessful()
->assertSee('API')
->assertSee('https://api.example.com')
->assertSee('Web');
->assertSee('https://admin.example.com')
->html();
expect($html)
->toContain("service-domain-group-{$this->apiApp->id}")
->toContain("service-domain-redirect-{$this->apiApp->id}")
->toContain("wire:model.change=\"serviceRedirects.{$this->apiApp->id}\"")
->toContain("wire:target=\"serviceRedirects.{$this->apiApp->id}\"")
->not->toContain("service-domain-redirect-toggle-{$this->apiApp->id}")
->not->toContain("service-domain-group-{$this->webApp->id}")
->and(substr_count($html, "id=\"service-domain-group-{$this->apiApp->id}\""))->toBe(1);
});
it('shows dns entries control next to Add', function () {
@@ -139,14 +153,18 @@ it('lists dns entries for service hosts that still need dns', function () {
->not->toContain('web.example.com');
});
it('does not persist service redirect until Set Direction is called', function () {
it('persists a service redirect when its dropdown changes', function () {
$this->webApp->update(['fqdn' => 'https://web.example.com', 'redirect' => 'both']);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set("serviceRedirects.{$this->webApp->id}", 'www');
->set("serviceRedirects.{$this->webApp->id}", 'www')
->assertDispatched('success')
->assertSee('https://www.web.example.com');
expect($this->webApp->fresh()->redirect)->toBe('both')
->and($this->webApp->fresh()->fqdn)->toBe('https://web.example.com');
expect($this->webApp->fresh()->redirect)->toBe('www')
->and(explode(',', (string) $this->webApp->fresh()->fqdn))
->toContain('https://web.example.com')
->toContain('https://www.web.example.com');
});
it('sets redirect direction per service application without changing other apps', function () {
@@ -166,6 +184,18 @@ it('sets redirect direction per service application without changing other apps'
->and($this->apiApp->fresh()->fqdn)->toBe('https://api.example.com');
});
it('saves the explicitly selected service redirect value', function () {
$this->webApp->update(['fqdn' => 'https://web.example.com', 'redirect' => 'both']);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('updateServiceRedirect', $this->webApp->id, 'www')
->assertDispatched('success')
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->contains('https://www.web.example.com'))
->assertSee('https://www.web.example.com');
expect($this->webApp->fresh()->redirect)->toBe('www');
});
it('auto-adds missing non-www pair for a service application redirect', function () {
$this->apiApp->update([
'fqdn' => 'https://www.api.example.com',
@@ -185,7 +215,7 @@ it('auto-adds missing non-www pair for a service application redirect', function
->toContain('https://api.example.com');
});
it('auto-adds the suggested www pair when adding a service domain with both directions', function () {
it('adds only the entered domain when redirects allow both directions', function () {
$this->webApp->update(['redirect' => 'both']);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
@@ -196,9 +226,7 @@ it('auto-adds the suggested www pair when adding a service domain with both dire
->assertDispatched('success')
->assertSee('DNS skipped');
expect(explode(',', (string) $this->webApp->fresh()->fqdn))
->toContain('https://web.example.com')
->toContain('https://www.web.example.com');
expect($this->webApp->fresh()->fqdn)->toBe('https://web.example.com');
});
it('adds a domain to a selected service application', function () {
@@ -212,24 +240,26 @@ it('adds a domain to a selected service application', function () {
->assertSee('https://web.example.com');
$this->webApp->refresh();
expect(explode(',', (string) $this->webApp->fqdn))
->toBe(['https://web.example.com', 'https://www.web.example.com']);
expect($this->webApp->fqdn)->toBe('https://web.example.com');
$dnsStatuses = $this->webApp->domain_dns_statuses;
expect($dnsStatuses)
->toHaveKey('https://web.example.com')
->toHaveKey('https://www.web.example.com')
->not->toHaveKey('https://www.web.example.com')
->and($dnsStatuses['https://web.example.com']['status'])
->toBe('skipped')
->and($dnsStatuses['https://web.example.com']['checked_at'])
->not->toBeNull();
});
it('replaces the rendered domain list when its rows change', function () {
it('keeps a stable key for the rendered domain list', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
expect($view)->toContain('wire:key="service-domains-list-{{ md5(serialize($domainRows)) }}"');
expect($view)
->toContain('wire:key="service-domains-list"')
->toContain('wire:key="service-domain-rows-{{ $appId }}-{{ md5(serialize($rows->all())) }}"')
->not->toContain('md5(serialize($domainRows))');
});
it('does not duplicate the service name as a badge in the domain cell', function () {
@@ -279,7 +309,8 @@ it('prunes dns status when a service domain is removed', function () {
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('removeDomain', 0)
->assertDispatched('success');
->assertDispatched('success')
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->doesntContain('https://api.example.com'));
expect($this->apiApp->fresh()->domain_dns_statuses)->toBeNull();
});
@@ -334,7 +365,7 @@ it('does not restore stale dns status when a removed service domain is re-added'
$this->apiApp->refresh();
expect(explode(',', (string) $this->apiApp->fqdn))
->toBe(['https://api.example.com', 'https://www.api.example.com'])
->toBe(['https://api.example.com'])
->and($this->apiApp->domain_dns_statuses['https://api.example.com']['status'] ?? null)->toBe('skipped')
->and($this->apiApp->domain_dns_statuses['https://api.example.com']['message'] ?? null)->not->toBe('Stale DNS result.');
});
@@ -371,7 +402,7 @@ YAML,
->assertDispatched('success');
expect(explode(',', (string) $this->webApp->fresh()->fqdn))
->toBe(['https://api.example.com', 'https://www.api.example.com']);
->toBe(['https://api.example.com']);
});
it('loads persisted dns status for service applications', function () {
+3
View File
@@ -60,6 +60,9 @@ it('defaults to a system console theme that follows the page color mode', functi
->toContain("attributeFilter: ['class', 'data-theme']")
->and($appCss)
->toContain('[data-console-theme="system"]')
->toContain('html:not(.dark) .application-console-shell[data-console-theme="system"]')
->toContain('--console-theme-border: #d4d4d8')
->toContain('background: transparent')
->toContain('html.dark .application-console-shell[data-console-theme="system"]');
});
@@ -94,6 +94,60 @@ it('opens service environment variables domains and storages pages', function ()
->screenshot(filename: 'service-storages');
});
it('renders an automatically added redirect counterpart without reloading', function () {
$this->serviceApplication->update([
'fqdn' => 'https://web.example.com',
'redirect' => 'both',
]);
loginAndSkipBoarding();
$base = serviceConfigurationUrl(
$this->stack['project'],
$this->stack['environment'],
$this->service
);
$page = visit("{$base}/domains")
->assertSee('https://web.example.com');
$selector = '#service-domain-redirect-'.$this->serviceApplication->id;
$page->select($selector, 'www')
->wait(3);
$this->serviceApplication->refresh();
expect($this->serviceApplication->redirect)->toBe('www');
$page->assertSee('https://www.web.example.com')
->screenshot(filename: 'service-domain-redirect-counterpart');
$page->script(<<<'JS'
(() => {
const domain = document.querySelector('a[title="https://www.web.example.com"]');
domain.closest('.env-table-item').querySelector('[aria-label="Remove domain"]').click();
})()
JS);
$page->wait(0.5)
->script(<<<'JS'
(() => {
const button = [...document.querySelectorAll('button')]
.find((candidate) => candidate.offsetParent && candidate.textContent.trim() === 'Remove domain');
button.click();
})()
JS);
$page->wait(3);
$configuredCounterparts = $page->script(
'document.querySelectorAll(\'a[title="https://www.web.example.com"]\').length'
);
expect($configuredCounterparts)->toBe(0);
$page->screenshot(filename: 'service-domain-remove-without-reload');
});
it('opens compose stack application general settings', function () {
loginAndSkipBoarding();