feat(ui): uniform bordered button kit; retire ghost variants

Every x-btn variant now carries a persistent border + background. Replace the
borderless `ghost`/`ghost-danger` (the latter only reddened on hover) with the
bordered `secondary` and a new `danger-soft` (red-tinted border+bg, always).
Migrate all 10 usages across servers/settings/files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 10:18:47 +02:00
parent 50b18aa45f
commit e26921f33a
5 changed files with 40 additions and 13 deletions

View File

@ -1,13 +1,15 @@
@props(['variant' => 'secondary', 'icon' => false, 'href' => null, 'size' => 'sm']) @props(['variant' => 'secondary', 'icon' => false, 'href' => null, 'size' => 'sm'])
@php @php
// One button style for the whole app — compact, consistent (R10). // One button style for the whole app — EVERY variant carries a persistent
// border + background (R10). No borderless variants: low-emphasis actions use
// `secondary`, destructive ones use `danger-soft` (red-tinted, always — not
// only on hover). Unknown variants fall back to the bordered `secondary`.
$variants = [ $variants = [
'primary' => 'bg-accent text-void hover:bg-accent-bright', 'primary' => 'bg-accent text-void hover:bg-accent-bright',
'accent' => 'border border-accent/25 bg-accent/10 text-accent-text hover:bg-accent/15', 'accent' => 'border border-accent/25 bg-accent/10 text-accent-text hover:bg-accent/15',
'secondary' => 'border border-line bg-inset text-ink-2 hover:bg-raised hover:text-ink', 'secondary' => 'border border-line bg-inset text-ink-2 hover:bg-raised hover:text-ink',
'danger' => 'bg-offline text-void hover:bg-offline/90', 'danger' => 'bg-offline text-void hover:bg-offline/90',
'ghost' => 'text-ink-2 hover:bg-raised hover:text-ink', 'danger-soft' => 'border border-offline/25 bg-offline/10 text-offline hover:bg-offline/15',
'ghost-danger' => 'text-ink-3 hover:bg-offline/10 hover:text-offline',
]; ];
// sm = compact toolbar/row buttons; lg = full-height primary CTA (≥44px touch target, R7). // sm = compact toolbar/row buttons; lg = full-height primary CTA (≥44px touch target, R7).
$sizes = [ $sizes = [

View File

@ -119,10 +119,10 @@
{{-- Row actions --}} {{-- Row actions --}}
<div class="flex shrink-0 items-center gap-1 lg:justify-end"> <div class="flex shrink-0 items-center gap-1 lg:justify-end">
@unless ($isDir) @unless ($isDir)
<x-btn variant="ghost" wire:click="download({{ $loop->index }})">{{ __('files.download') }}</x-btn> <x-btn variant="secondary" wire:click="download({{ $loop->index }})">{{ __('files.download') }}</x-btn>
<x-btn variant="ghost" wire:click="edit({{ $loop->index }})">{{ __('common.edit') }}</x-btn> <x-btn variant="secondary" wire:click="edit({{ $loop->index }})">{{ __('common.edit') }}</x-btn>
@endunless @endunless
<x-btn variant="ghost-danger" wire:click="confirmDelete({{ $loop->index }})">{{ __('common.delete') }}</x-btn> <x-btn variant="danger-soft" wire:click="confirmDelete({{ $loop->index }})">{{ __('common.delete') }}</x-btn>
</div> </div>
</div> </div>
@endforeach @endforeach

View File

@ -125,7 +125,7 @@
<svg wire:loading wire:target="toggleCredential" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg> <svg wire:loading wire:target="toggleCredential" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
{{ $cred->disabled ? __('common.unlock') : __('common.lock') }} {{ $cred->disabled ? __('common.unlock') : __('common.lock') }}
</x-btn> </x-btn>
<x-btn variant="ghost-danger" wire:click="confirmDeleteCredential"> <x-btn variant="danger-soft" wire:click="confirmDeleteCredential">
<x-icon name="trash" class="h-3.5 w-3.5" /> {{ __('common.delete') }} <x-icon name="trash" class="h-3.5 w-3.5" /> {{ __('common.delete') }}
</x-btn> </x-btn>
</div> </div>
@ -207,7 +207,7 @@
</div> </div>
@if ($supported) @if ($supported)
@if ($check['key'] === 'fail2ban') @if ($check['key'] === 'fail2ban')
<x-btn variant="ghost" size="sm" icon class="shrink-0" title="{{ __('servers.fail2ban_configure') }}" <x-btn variant="secondary" size="sm" icon class="shrink-0" title="{{ __('servers.fail2ban_configure') }}"
wire:click="$dispatch('openModal', { component: 'modals.fail2ban-config', arguments: { serverId: {{ $server->id }} } })"> wire:click="$dispatch('openModal', { component: 'modals.fail2ban-config', arguments: { serverId: {{ $server->id }} } })">
<x-icon name="settings" class="h-3.5 w-3.5" /> <x-icon name="settings" class="h-3.5 w-3.5" />
</x-btn> </x-btn>
@ -320,7 +320,7 @@
'text-offline' => $ruleTone === 'offline', 'text-offline' => $ruleTone === 'offline',
])>{{ strtolower($rule['action'] ?? 'allow') }}</span> ])>{{ strtolower($rule['action'] ?? 'allow') }}</span>
@unless ($fwReadOnly) @unless ($fwReadOnly)
<x-btn variant="ghost-danger" size="sm" icon class="shrink-0" title="{{ __('servers.firewall_remove_rule') }}" <x-btn variant="danger-soft" size="sm" icon class="shrink-0" title="{{ __('servers.firewall_remove_rule') }}"
wire:click="confirmDeleteRule({{ $loop->index }})"> wire:click="confirmDeleteRule({{ $loop->index }})">
<x-icon name="trash" class="h-3.5 w-3.5" /> <x-icon name="trash" class="h-3.5 w-3.5" />
</x-btn> </x-btn>
@ -351,7 +351,7 @@
:padded="false"> :padded="false">
@if ($f2bSupported && $f2bInstalled && $f2bActive) @if ($f2bSupported && $f2bInstalled && $f2bActive)
<x-slot:actions> <x-slot:actions>
<x-btn variant="ghost" size="sm" icon title="{{ __('servers.fail2ban_configure') }}" <x-btn variant="secondary" size="sm" icon title="{{ __('servers.fail2ban_configure') }}"
wire:click="$dispatch('openModal', { component: 'modals.fail2ban-config', arguments: { serverId: {{ $server->id }} } })"> wire:click="$dispatch('openModal', { component: 'modals.fail2ban-config', arguments: { serverId: {{ $server->id }} } })">
<x-icon name="settings" class="h-3.5 w-3.5" /> <x-icon name="settings" class="h-3.5 w-3.5" />
</x-btn> </x-btn>
@ -395,7 +395,7 @@
@foreach ($jail['bannedIps'] as $ip) @foreach ($jail['bannedIps'] as $ip)
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span class="font-mono text-[11px] text-ink-2">{{ $ip }}</span> <span class="font-mono text-[11px] text-ink-2">{{ $ip }}</span>
<x-btn variant="ghost" size="sm" wire:click="unbanIp({{ \Illuminate\Support\Js::from($jail['name']) }}, {{ \Illuminate\Support\Js::from($ip) }})">{{ __('common.unlock') }}</x-btn> <x-btn variant="secondary" size="sm" wire:click="unbanIp({{ \Illuminate\Support\Js::from($jail['name']) }}, {{ \Illuminate\Support\Js::from($ip) }})">{{ __('common.unlock') }}</x-btn>
</div> </div>
@endforeach @endforeach
</div> </div>
@ -504,7 +504,7 @@
</div> </div>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $key['fingerprint'] }}</p> <p class="truncate font-mono text-[11px] text-ink-3">{{ $key['fingerprint'] }}</p>
</div> </div>
<x-btn variant="ghost-danger" icon wire:click="confirmKeyRemoval({{ $loop->index }})" title="{{ __('servers.ssh_keys_remove') }}"> <x-btn variant="danger-soft" icon wire:click="confirmKeyRemoval({{ $loop->index }})" title="{{ __('servers.ssh_keys_remove') }}">
<x-icon name="trash" class="h-3.5 w-3.5" /> <x-icon name="trash" class="h-3.5 w-3.5" />
</x-btn> </x-btn>
</div> </div>

View File

@ -106,7 +106,7 @@
</div> </div>
</div> </div>
@if ($twoFactorEnabled) @if ($twoFactorEnabled)
<x-btn variant="ghost-danger" wire:click="confirmDisableTwoFactor">{{ __('common.disable') }}</x-btn> <x-btn variant="danger-soft" wire:click="confirmDisableTwoFactor">{{ __('common.disable') }}</x-btn>
@else @else
<x-btn variant="accent" :href="route('two-factor.setup')" wire:navigate> <x-btn variant="accent" :href="route('two-factor.setup')" wire:navigate>
<x-icon name="shield" class="h-3.5 w-3.5" /> {{ __('settings.twofa_setup') }} <x-icon name="shield" class="h-3.5 w-3.5" /> {{ __('settings.twofa_setup') }}

View File

@ -0,0 +1,25 @@
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\Blade;
use Tests\TestCase;
class ButtonComponentTest extends TestCase
{
public function test_danger_soft_has_persistent_border_and_bg(): void
{
$html = Blade::render('<x-btn variant="danger-soft">x</x-btn>');
$this->assertStringContainsString('border border-offline/25', $html);
$this->assertStringContainsString('bg-offline/10', $html);
$this->assertStringContainsString('text-offline', $html);
}
public function test_no_borderless_ghost_variants_remain(): void
{
// Unknown variants fall back to secondary (bordered) — never borderless.
$html = Blade::render('<x-btn variant="ghost">x</x-btn>');
$this->assertStringContainsString('border border-line', $html);
$this->assertStringContainsString('bg-inset', $html);
}
}