Match the panel to the approved template, measured rather than assumed
tests / pest (push) Successful in 8m49s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details

The panel had been built by reading the template and believing the result.
Every round of review found the same class of defect, so this round the
template was rendered in a browser and measured with getComputedStyle, and the
implementation measured the same way — two number columns instead of an
opinion.

That immediately settled a disagreement: a review of the template's SOURCE said
the metric grid used a 20px gap. Rendered, it is 14px. The measurement wins.

Brought to the template's figures: label 11.5px (it was 11px in three separate
places, which is how it drifted — it is now one .lbl rule), value tracking
-0.02em, unit weight 450, metric row 6px above and 14px between, page head
centred with a 14px gap, grid gap 14px, columns switching at 1101/561px and the
h1 at 901px to match the template's own breakpoints, ring 62px, bar 5px.

The shared button now takes its height from min-height (40px) rather than
vertical padding, at 14px/600 with 0 18px padding and a 10px radius — a button
keeps its height whatever sits inside it, icon, spinner or bare text.

Also here, from the same round of review:

- The chart's blue border was Tailwind's own `ring` utility colliding with a
  component class of the same name. Renamed to `.metric-ring`. This was
  dismissed once as a screenshot artefact; it was real.
- Page titles lost the `sm:text-3xl` (40px) an earlier bulk edit had appended
  to 23 of them. The template's h1 is 30px.
- The users table has its actions back — edit, suspend, lock and delete — for
  every seat that is not the owner, with the owner refused in the action itself
  and not merely hidden in the markup.

DemoCustomerSeeder writes one complete customer as real rows: instance,
subscription, six seats across four roles, a backup, current-period traffic and
thirty days of samples with a deliberate wobble and one day at 286/288 checks.
Nothing in the panel is drawn from a fixture any more, so anything missing
shows up as missing. Removing the demo is one deletion.

Verified: 607 tests, and a Codex comparison of the two measurement sets —
"a person would call them the same design", the only difference a 1px gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-27 17:08:05 +02:00
parent 85fcc154b8
commit 435a202fdd
33 changed files with 331 additions and 64 deletions

View File

@ -121,6 +121,38 @@ class Users extends Component
}
}
/**
* Pause a seat without destroying it.
*
* The action an owner actually needs when someone leaves: access stops now,
* and the record of who held it survives which is the half a deletion
* throws away, on a product sold on being able to show who had access to
* what.
*/
public function suspend(string $uuid): void
{
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
$seat = $customer->seats()->where('uuid', $uuid)->first();
if ($seat === null || $seat->role === 'owner') {
// The owner cannot lock themselves out of their own cloud.
$this->dispatch('notify', message: __('users.owner_locked'));
return;
}
$seat->update(['status' => $seat->status === 'suspended' ? 'active' : 'suspended']);
$this->dispatch('notify', message: __(
$seat->status === 'suspended' ? 'users.suspended' : 'users.reactivated',
));
}
public function revoke(string $uuid): void
{
$customer = $this->requireCustomer();
@ -196,7 +228,7 @@ class Users extends Component
// The owner can be neither re-invited nor revoked, so a table holding
// only the owner has nothing to act on and should not carry a column
// of empty cells.
$hasActions = $seats->contains(fn ($seat) => $seat->role !== 'owner' || $seat->status === 'invited');
$hasActions = $seats->contains(fn ($seat) => $seat->role !== 'owner');
return view('livewire.users', [
'hasActions' => $hasActions,

View File

@ -0,0 +1,197 @@
<?php
namespace Database\Seeders;
use App\Models\Backup;
use App\Models\Customer;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\InstanceTraffic;
use App\Models\Order;
use App\Models\Seat;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
/**
* One complete customer, written as real rows.
*
* The panel used to be judged against fixtures baked into the views, and every
* round of that hid a different gap: a card with no data source, a figure that
* came from a translation string, a chart drawn from an array in a Blade file.
* A real customer with real rows makes the panel tell the truth about itself
* whatever is missing shows up as missing.
*
* Everything hangs off one User, so removing the demo is one deletion:
*
* php artisan db:seed --class=DemoCustomerSeeder --force
* php artisan tinker --execute="App\Models\User::where('email','demo@clupilot.com')->first()?->delete()"
*
* Safe to run twice: every row is matched on a natural key first.
*/
class DemoCustomerSeeder extends Seeder
{
private const EMAIL = 'demo@clupilot.com';
public function run(): void
{
$user = User::firstOrCreate(
['email' => self::EMAIL],
['name' => 'Kanzlei Muster', 'password' => Hash::make(config('app.demo_password', 'Muster!2026'))],
);
$customer = Customer::firstOrCreate(
['user_id' => $user->id],
['name' => 'Kanzlei Muster', 'email' => self::EMAIL, 'contact_name' => 'Dr. M. Muster', 'status' => 'active'],
);
$datacenter = Datacenter::firstOrCreate(
['code' => 'fsn'],
['name' => 'Falkenstein', 'location' => 'DE', 'active' => true],
);
$host = Host::query()->where('datacenter', $datacenter->code)->first()
?? Host::factory()->create(['datacenter' => $datacenter->code, 'name' => 'pve-fsn-01', 'status' => 'active']);
$order = Order::firstOrCreate(
['customer_id' => $customer->id, 'type' => 'plan'],
[
'plan' => 'team',
'amount_cents' => 17900,
'currency' => 'EUR',
'datacenter' => $datacenter->code,
'status' => 'paid',
],
);
$instance = Instance::firstOrCreate(
['customer_id' => $customer->id],
[
'order_id' => $order->id,
'host_id' => $host->id,
'vmid' => 9001,
'plan' => 'team',
'quota_gb' => 500,
'disk_gb' => 500,
'ram_mb' => 8192,
'cores' => 4,
'traffic_addons' => 0,
'guest_ip' => '10.20.0.31',
'subdomain' => 'kanzlei-muster',
'status' => 'active',
'cert_ok' => true,
'route_written' => true,
],
);
Subscription::firstOrCreate(
['customer_id' => $customer->id],
[
'order_id' => $order->id,
'instance_id' => $instance->id,
'plan' => 'team',
'tier' => 2,
'term' => 'monthly',
'price_cents' => 17900,
'currency' => 'EUR',
'quota_gb' => 500,
'traffic_gb' => 3072,
'seats' => 25,
'ram_mb' => 8192,
'cores' => 4,
'disk_gb' => 500,
'performance' => 'enhanced',
'status' => 'active',
'started_at' => now()->subMonths(4),
'current_period_start' => now()->startOfMonth(),
'current_period_end' => now()->addMonth()->startOfMonth(),
],
);
$this->seats($customer);
$this->backups($instance);
$this->traffic($instance);
$this->metrics($instance);
$this->command?->info('Demo customer ready: '.self::EMAIL);
}
private function seats(Customer $customer): void
{
$people = [
['Dr. M. Muster', 'demo@clupilot.com', 'owner', 'active'],
['S. Berger', 'berger@kanzlei-muster.test', 'admin', 'active'],
['A. Wimmer', 'wimmer@kanzlei-muster.test', 'member', 'active'],
['T. Klein', 'klein@kanzlei-muster.test', 'member', 'active'],
['P. Hofer', 'hofer@kanzlei-muster.test', 'member', 'invited'],
['Steuerberatung Ost', 'extern@partner.test', 'readonly', 'active'],
];
foreach ($people as [$name, $email, $role, $status]) {
Seat::firstOrCreate(
['customer_id' => $customer->id, 'email' => $email],
['name' => $name, 'role' => $role, 'status' => $status, 'invited_at' => now()->subDays(30)],
);
}
}
private function backups(Instance $instance): void
{
Backup::firstOrCreate(
['instance_id' => $instance->id, 'schedule' => 'täglich 03:00'],
['external_job_id' => 'demo-backup', 'status' => 'ok', 'last_ok_at' => now()->setTime(3, 12)],
);
}
private function traffic(Instance $instance): void
{
InstanceTraffic::firstOrCreate(
['instance_id' => $instance->id, 'period' => InstanceTraffic::currentPeriod()],
[
'rx_bytes' => (int) (340 * 1024 ** 3),
'tx_bytes' => (int) (72 * 1024 ** 3),
'last_netin' => 0,
'last_netout' => 0,
'sampled_at' => now(),
],
);
}
/**
* Thirty days of samples: a disk that fills slowly, transfer that varies,
* and one day where two checks out of 288 failed.
*
* Deliberately not a smooth curve a straight line is the first thing that
* gives a demo away, and a chart that never wobbles teaches nobody what a
* real one looks like.
*/
private function metrics(Instance $instance): void
{
$total = (int) (500 * 1024 ** 3);
$used = (int) (198 * 1024 ** 3);
$wobble = [1.0, 0.6, 1.4, 0.9, 1.7, 0.4, 0.2, 1.1, 1.5, 0.8];
foreach (range(29, 0) as $offset) {
$day = Carbon::today()->subDays($offset);
$i = 29 - $offset;
$used += (int) ($wobble[$i % 10] * 0.42 * 1024 ** 3);
InstanceMetric::updateOrCreate(
['instance_id' => $instance->id, 'day' => $day->toDateString()],
[
'disk_used_bytes' => $used,
'disk_total_bytes' => $total,
'rx_bytes' => (int) ((9 + $wobble[$i % 10] * 4) * 1024 ** 3),
'tx_bytes' => (int) ((2 + $wobble[($i + 3) % 10]) * 1024 ** 3),
// 288 checks a day is one every five minutes.
'checks_total' => 288,
'checks_ok' => $offset === 12 ? 286 : 288,
],
);
}
}
}

View File

@ -31,6 +31,12 @@ return [
'owner_no_actions' => 'Der Inhaber kann nicht entfernt werden.',
'col_actions' => 'Aktionen',
'suspend' => 'Sperren',
'reactivate' => 'Entsperren',
'suspended' => 'Zugang gesperrt.',
'reactivated' => 'Zugang wieder freigegeben.',
'owner_locked' => 'Der Inhaber kann sich nicht selbst sperren.',
'status_suspended' => 'Gesperrt',
'status_active' => 'Aktiv',
'status_invited' => 'Eingeladen',
'status_revoked' => 'Entfernt',

View File

@ -31,6 +31,12 @@ return [
'owner_no_actions' => 'The owner cannot be removed.',
'col_actions' => 'Actions',
'suspend' => 'Suspend',
'reactivate' => 'Reactivate',
'suspended' => 'Access suspended.',
'reactivated' => 'Access restored.',
'owner_locked' => 'The owner cannot lock themselves out.',
'status_suspended' => 'Suspended',
'status_active' => 'Active',
'status_invited' => 'Invited',
'status_revoked' => 'Removed',

View File

@ -91,9 +91,9 @@
* a browser skip the declaration, it computes to `unset` which for a
* stroke-dashoffset silently draws a full ring at every value.
*/
.ring { transform: rotate(-90deg); }
.ring .track { fill: none; stroke: var(--surface-hover); stroke-width: 7; }
.ring .value {
.metric-ring { transform: rotate(-90deg); }
.metric-ring .track { fill: none; stroke: var(--surface-hover); stroke-width: 7; }
.metric-ring .value {
fill: none;
stroke: var(--accent);
stroke-width: 7;
@ -117,7 +117,7 @@
@keyframes spark-fade { to { opacity: .09; } }
@media (prefers-reduced-motion: reduce) {
.ring .value { stroke-dashoffset: var(--o, 0) !important; }
.metric-ring .value { stroke-dashoffset: var(--o, 0) !important; }
.spark .line { stroke-dashoffset: 0 !important; }
.spark .area { opacity: .09 !important; }
}

View File

@ -103,3 +103,17 @@
--focus-ring: 0 0 0 3px var(--accent-ring);
}
/*
* The small-caps label: the template calls it "the typographic signature,
* carried across all three surfaces". It is written once here so the size
* cannot drift apart between the panel, the console and the site which is
* exactly how it ended up at 11px in one place and 11.5px in another.
*/
.lbl {
font-family: var(--font-mono);
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--muted);
}

View File

@ -38,7 +38,7 @@
@foreach ($groups as $group)
@if (! empty($group['label']))
<p class="px-3 pb-2 pt-5 font-mono text-[11px] uppercase tracking-[0.07em] text-muted">{{ $group['label'] }}</p>
<p class="px-3 pb-2 pt-5 lbl">{{ $group['label'] }}</p>
@endif
<nav class="space-y-0.5">
@foreach ($group['items'] as [$route, $icon, $key, $capability])

View File

@ -9,11 +9,12 @@
'href' => null,
])
@php
$base = 'inline-flex items-center justify-center gap-2 rounded font-semibold transition select-none disabled:opacity-50 disabled:pointer-events-none';
$base = 'inline-flex items-center justify-center gap-2 rounded-[10px] font-semibold leading-none transition select-none disabled:opacity-50 disabled:pointer-events-none';
$sizes = [
'sm' => 'px-3 py-1.5 text-sm',
'md' => 'px-4 py-2.5 text-sm',
'sm' => 'min-h-8 px-3 text-[13px]',
'md' => 'min-h-10 px-[18px] text-[14px]',
'lg' => 'min-h-[46px] px-[22px] text-[15px]',
];
// Accent buttons use the AA-safe darker fill (#c2560a) so white text passes

View File

@ -18,9 +18,9 @@
ring, a bar or a sparkline and so a metric with nothing to draw simply
passes none instead of getting a decorative one.
--}}
<article {{ $attributes->merge(['class' => 'overflow-hidden rounded-lg border border-line bg-surface p-5 shadow-xs']) }}>
<div class="flex items-center justify-between gap-3">
<span class="font-mono text-[11px] uppercase tracking-[0.07em] text-muted">{{ $label }}</span>
<article {{ $attributes->merge(['class' => 'overflow-hidden rounded-lg border border-line bg-surface px-5 py-[18px] shadow-xs']) }}>
<div class="flex items-center justify-between gap-2.5">
<span class="lbl">{{ $label }}</span>
@if ($href)
<a href="{{ $href }}" wire:navigate class="text-muted transition hover:text-ink" aria-label="{{ $label }}">
<x-ui.icon name="chevron-down" class="size-3.5 -rotate-90" />
@ -28,14 +28,14 @@
@endif
</div>
<div class="mt-3 flex items-end justify-between gap-4">
<div class="mt-1.5 flex items-end justify-between gap-3.5">
<div class="min-w-0">
<p class="flex flex-wrap items-baseline gap-x-1.5 font-mono text-2xl font-semibold leading-tight tracking-tight text-ink">
<p class="mt-[9px] flex flex-wrap items-baseline gap-x-1.5 font-mono text-[25px] font-semibold leading-tight tracking-[-0.02em] text-ink">
{{ $value }}
@if ($unit)<span class="text-sm font-normal text-muted">{{ $unit }}</span>@endif
@if ($unit)<span class="text-[13px] font-[450] text-muted">{{ $unit }}</span>@endif
</p>
@if ($foot)
<p class="mt-1.5 text-xs leading-snug text-muted">{{ $foot }}</p>
<p class="mt-1.5 text-[12px] leading-snug text-muted">{{ $foot }}</p>
@endif
</div>

View File

@ -6,9 +6,9 @@
$o = (int) round($c * (1 - min(100, max(0, (float) $percent)) / 100));
@endphp
<div class="relative grid place-items-center">
<svg class="ring" viewBox="0 0 62 62" style="--c:{{ $c }};--o:{{ $o }};width:{{ $size }}px;height:{{ $size }}px" aria-hidden="true">
<svg class="metric-ring" viewBox="0 0 62 62" style="--c:{{ $c }};--o:{{ $o }};width:{{ $size }}px;height:{{ $size }}px" aria-hidden="true">
<circle class="track" cx="31" cy="31" r="25" />
<circle class="value" cx="31" cy="31" r="25" />
</svg>
<b class="absolute font-mono text-xs font-semibold text-ink">{{ (int) round($percent) }}%</b>
<b class="absolute font-mono text-[13px] font-medium text-ink">{{ (int) round($percent) }}%</b>
</div>

View File

@ -1,6 +1,6 @@
<div class="space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('admin.nav.customers') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin.nav.customers') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin.customers_sub') }}</p>
</div>

View File

@ -1,6 +1,6 @@
<div class="space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('datacenters.title') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('datacenters.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('datacenters.subtitle') }}</p>
</div>

View File

@ -3,7 +3,7 @@
<a href="{{ route('admin.hosts') }}" wire:navigate class="inline-flex items-center gap-1.5 text-xs font-medium text-muted hover:text-body">
<x-ui.icon name="arrow-left" class="size-4" />{{ __('hosts.back') }}
</a>
<h1 class="mt-2 text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('hosts.create_title') }}</h1>
<h1 class="mt-2 text-2xl font-bold tracking-tight text-ink">{{ __('hosts.create_title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('hosts.create_sub') }}</p>
</div>

View File

@ -9,7 +9,7 @@
<x-ui.icon name="arrow-left" class="size-4" />{{ __('hosts.back') }}
</a>
<div class="mt-2 flex items-center gap-3">
<h1 class="font-mono text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ $host->name }}</h1>
<h1 class="font-mono text-2xl font-bold tracking-tight text-ink">{{ $host->name }}</h1>
<x-ui.badge :status="$badge">{{ __('hosts.status.'.$host->status) }}</x-ui.badge>
</div>
<p class="mt-1 font-mono text-xs text-muted">{{ $host->public_ip }} · {{ $host->datacenter }}</p>

View File

@ -1,7 +1,7 @@
<div class="space-y-5">
<div class="flex items-start justify-between gap-4 animate-rise">
<div>
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('hosts.title') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('hosts.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('hosts.subtitle') }}</p>
</div>
<a href="{{ route('admin.hosts.create') }}" wire:navigate>

View File

@ -1,6 +1,6 @@
<div class="space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('admin.nav.instances') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin.nav.instances') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin.instances_sub') }}</p>
</div>

View File

@ -1,6 +1,6 @@
<div class="space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('maintenance.title') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('maintenance.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('maintenance.subtitle') }}</p>
</div>

View File

@ -1,6 +1,6 @@
<div class="space-y-5">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('admin.overview_title') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin.overview_title') }}</h1>
{{-- Derived from the notice list below, never asserted: a green badge
that is not computed is the most expensive kind of decoration. --}}
@if ($noticeCount === 0)

View File

@ -7,7 +7,7 @@
<x-ui.icon name="arrow-left" class="size-3.5" />
{{ __('plans.back') }}
</a>
<h1 class="mt-2 text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ $family->name }}</h1>
<h1 class="mt-2 text-2xl font-bold tracking-tight text-ink">{{ $family->name }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('plans.versions_subtitle') }}</p>
</div>

View File

@ -1,6 +1,6 @@
<div class="space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('plans.title') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('plans.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('plans.subtitle') }}</p>
</div>

View File

@ -1,6 +1,6 @@
<div class="space-y-5" @if ($hasActive) wire:poll.4s @endif>
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('admin.nav.provisioning') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin.nav.provisioning') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin.provisioning_sub') }}</p>
</div>

View File

@ -1,6 +1,6 @@
<div class="space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('admin.nav.revenue') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin.nav.revenue') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin.revenue_sub') }}</p>
</div>

View File

@ -1,6 +1,6 @@
<div class="mx-auto max-w-3xl space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('secrets.title') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('secrets.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('secrets.subtitle') }}</p>
</div>

View File

@ -1,6 +1,6 @@
<div class="mx-auto max-w-3xl space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('admin_settings.title') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin_settings.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.subtitle') }}</p>
</div>

View File

@ -3,7 +3,7 @@
seconds. Traffic figures can wait the minute someone needs to copy a key. --}}
<div class="space-y-5" @if (! $newConfig) wire:poll.5s="refreshPeers" @endif>
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('vpn.title') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('vpn.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('vpn.subtitle') }}</p>
</div>

View File

@ -1,7 +1,7 @@
<div class="space-y-6" x-data="{ msgs: @js(['restore' => __('backups.restore_toast')]) }">
<div class="flex flex-wrap items-center gap-3 animate-rise">
<div>
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('backups.title') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('backups.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('backups.subtitle') }}</p>
</div>
<span class="ml-auto rounded-pill bg-success-bg px-3 py-1.5 text-xs font-semibold text-success">{{ __('backups.schedule') }}</span>

View File

@ -3,7 +3,7 @@
@php $loc = app()->getLocale(); $eur = fn ($c) => Number::currency($c / 100, in: 'EUR', locale: $loc); @endphp
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('billing.title') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('billing.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('billing.subtitle') }}</p>
</div>

View File

@ -1,6 +1,6 @@
<div class="space-y-6" x-data="{ msgs: @js(['soon' => __('cloud.action_toast')]) }">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('cloud.title') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('cloud.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('cloud.subtitle') }}</p>
</div>

View File

@ -5,35 +5,34 @@
$locale = app()->getLocale();
$money = fn (int $cents, string $currency) => Number::currency($cents / 100, in: $currency, locale: $locale);
@endphp
<div class="space-y-5">
<div class="space-y-3.5">
{{-- Page head. The same pill the public site uses to label a section, so a
customer meets one vocabulary on both sides of the login. --}}
<header class="flex flex-wrap items-end gap-4 animate-rise">
<header class="mb-2 flex flex-wrap items-center gap-3.5 animate-rise">
<div class="min-w-0 flex-1">
<p class="font-mono text-[11px] uppercase tracking-[0.07em] text-muted">
<p class="lbl">
{{ __('dashboard.sheet', ['when' => $asOf->locale($locale)->isoFormat('LL, LT')]) }}
</p>
<h1 class="mt-2.5 text-2xl font-bold tracking-tight text-ink sm:text-3xl">
<h1 class="mt-[7px] text-[23px] font-bold leading-[1.12] tracking-[-0.03em] text-ink min-[901px]:text-[30px]">
{{ $instance !== null ? __('dashboard.title_running') : __('dashboard.title_pending') }}
</h1>
</div>
@if ($instance !== null)
<div class="flex flex-wrap items-center gap-2.5">
<span @class([
'inline-flex items-center gap-2 rounded-pill border px-3.5 py-1.5 text-xs font-semibold',
'border-success-border bg-success-bg text-success' => $instance->status === 'active',
'border-warning-border bg-warning-bg text-warning' => $instance->status !== 'active',
])>
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('dashboard.instance_status.'.$instance->status) }}
</span>
@if ($domain)
<x-ui.button variant="ink" :href="'https://'.$domain" target="_blank" rel="noopener">
<x-ui.icon name="external-link" class="size-4" />{{ __('dashboard.cloud.open') }}
</x-ui.button>
@endif
</div>
<span @class([
'inline-flex shrink-0 items-center gap-[7px] rounded-pill border px-[11px] py-1 text-xs font-medium',
'border-success-border bg-success-bg text-success' => $instance->status === 'active',
'border-warning-border bg-warning-bg text-warning' => $instance->status !== 'active',
])>
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('dashboard.instance_status.'.$instance->status) }}
</span>
@if ($domain)
<x-ui.button variant="ink" class="shrink-0" :href="'https://'.$domain" target="_blank" rel="noopener">
<x-ui.icon name="external-link" class="size-4" />{{ __('dashboard.cloud.open') }}
</x-ui.button>
@endif
@endif
</header>
@ -58,7 +57,7 @@
beside it, a line of context, the visual ring, bar, sparkline.
Every number below is measured; nothing is drawn at a level nobody
read. --}}
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<section class="grid gap-3.5 min-[561px]:grid-cols-2 min-[1101px]:grid-cols-4">
@if ($disk)
<x-ui.metric
:label="__('dashboard.master.storage')"
@ -69,7 +68,7 @@
:href="route('cloud')"
class="animate-rise [animation-delay:60ms]"
>
<x-slot:visual><x-ui.ring :percent="$disk['percent']" :size="58" /></x-slot:visual>
<x-slot:visual><x-ui.ring :percent="$disk['percent']" :size="62" /></x-slot:visual>
</x-ui.metric>
@elseif ($instance->quota_gb)
<x-ui.metric
@ -90,7 +89,7 @@
class="animate-rise [animation-delay:100ms]"
>
@if ($seats['total'])
<div class="mt-4 h-1.5 overflow-hidden rounded-pill bg-surface-hover">
<div class="mt-3 h-[5px] overflow-hidden rounded-pill bg-surface-hover">
{{-- R4: width is data, not decoration. --}}
<div class="h-full rounded-pill bg-muted"
style="width: {{ min(100, (int) round($seats['used'] / max(1, $seats['total']) * 100)) }}%"></div>

View File

@ -1,6 +1,6 @@
<div class="space-y-6" x-data="{ msgs: @js(['download' => __('invoices.download_toast')]) }">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('invoices.title') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('invoices.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('invoices.subtitle') }}</p>
</div>

View File

@ -1,6 +1,6 @@
<div class="mx-auto max-w-3xl space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('settings.title') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('settings.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('settings.subtitle') }}</p>
</div>

View File

@ -1,7 +1,7 @@
<div class="space-y-6" x-data="{ open: null, msgs: @js(['sent' => __('support.new_toast')]) }">
<div class="flex flex-wrap items-center gap-3 animate-rise">
<div>
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('support.title') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('support.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('support.subtitle') }}</p>
</div>
<x-ui.button variant="primary" class="ml-auto" @click="$dispatch('notify', { message: msgs.sent })">

View File

@ -1,7 +1,7 @@
<div class="space-y-6">
<div class="flex flex-wrap items-end justify-between gap-4 animate-rise">
<div>
<h1 class="text-2xl font-bold tracking-tight text-ink sm:text-3xl">{{ __('users.title') }}</h1>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('users.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('users.subtitle') }}</p>
</div>
<div class="text-right">
@ -81,8 +81,20 @@
<button type="button" wire:click="resend('{{ $seat->uuid }}')" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:bg-surface-hover">{{ __('users.resend') }}</button>
@endif
@if ($seat->role !== 'owner')
<button type="button" wire:click="revoke('{{ $seat->uuid }}')" aria-label="{{ __('users.revoke') }}"
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-danger hover:text-danger">
{{-- Pause first, remove second. Somebody
leaving needs their access stopped
today; deleting the seat also throws
away who held it, which is the half
an audit asks about. --}}
<button type="button" wire:click="suspend('{{ $seat->uuid }}')"
aria-label="{{ $seat->status === 'suspended' ? __('users.reactivate') : __('users.suspend') }}"
title="{{ $seat->status === 'suspended' ? __('users.reactivate') : __('users.suspend') }}"
class="grid size-9 place-items-center rounded-md border border-line text-muted hover:border-warning hover:text-warning">
<x-ui.icon :name="$seat->status === 'suspended' ? 'unlock' : 'lock'" class="size-4" />
</button>
<button type="button" wire:click="revoke('{{ $seat->uuid }}')"
aria-label="{{ __('users.revoke') }}" title="{{ __('users.revoke') }}"
class="grid size-9 place-items-center rounded-md border border-line text-muted hover:border-danger hover:text-danger">
<x-ui.icon name="trash-2" class="size-4" />
</button>
@endif