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'])
@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 = [
'primary' => 'bg-accent text-void hover:bg-accent-bright',
'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',
'danger' => 'bg-offline text-void hover:bg-offline/90',
'ghost' => 'text-ink-2 hover:bg-raised hover:text-ink',
'ghost-danger' => 'text-ink-3 hover:bg-offline/10 hover:text-offline',
'danger-soft' => 'border border-offline/25 bg-offline/10 text-offline hover:bg-offline/15',
];
// sm = compact toolbar/row buttons; lg = full-height primary CTA (≥44px touch target, R7).
$sizes = [

View File

@ -119,10 +119,10 @@
{{-- Row actions --}}
<div class="flex shrink-0 items-center gap-1 lg:justify-end">
@unless ($isDir)
<x-btn variant="ghost" 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="download({{ $loop->index }})">{{ __('files.download') }}</x-btn>
<x-btn variant="secondary" wire:click="edit({{ $loop->index }})">{{ __('common.edit') }}</x-btn>
@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>
@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>
{{ $cred->disabled ? __('common.unlock') : __('common.lock') }}
</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-btn>
</div>
@ -207,7 +207,7 @@
</div>
@if ($supported)
@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 }} } })">
<x-icon name="settings" class="h-3.5 w-3.5" />
</x-btn>
@ -320,7 +320,7 @@
'text-offline' => $ruleTone === 'offline',
])>{{ strtolower($rule['action'] ?? 'allow') }}</span>
@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 }})">
<x-icon name="trash" class="h-3.5 w-3.5" />
</x-btn>
@ -351,7 +351,7 @@
:padded="false">
@if ($f2bSupported && $f2bInstalled && $f2bActive)
<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 }} } })">
<x-icon name="settings" class="h-3.5 w-3.5" />
</x-btn>
@ -395,7 +395,7 @@
@foreach ($jail['bannedIps'] as $ip)
<div class="flex items-center justify-between gap-2">
<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>
@endforeach
</div>
@ -504,7 +504,7 @@
</div>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $key['fingerprint'] }}</p>
</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-btn>
</div>

View File

@ -106,7 +106,7 @@
</div>
</div>
@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
<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') }}

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);
}
}