Files
web-app-launcher/src/lib/components/widget/StatusWidget.svelte
T
alexei.dolgolyov 5dcadd1c20 feat(ui): migrate entire UI to "Cozy Home" design
Warm, friendly redesign replacing the generic cold-shadcn look. Built as a
swappable token bundle so other presets can be added later; dark mode and the
user-tunable accent hue are retained.

Foundation
- app.css: warm cream (light) + "dusk" (dark) token system; terracotta accent
  (default hue 16); pastel --room-* palette; vivid --status-* (dots/bars) plus
  AA-legible --status-*-ink (text); soft warm shadows; --radius 1rem; font tokens
- Fonts: Fraunces (display) + Figtree (body), self-hosted in static/fonts
  (no Google CDN) so offline/LAN installs work; system-ui fallbacks kept
- h1/h2/h3 render in Fraunces via base layer

Chrome and surfaces
- Sidebar, Header, home, AppCard/BoardCard, BoardHeader, sections, favorites
- 29 widgets + integration renderers: cozy card shells, room-palette charts
- Default background is a static warm "cozy" glow (mesh demoted, rAF gated on
  prefers-reduced-motion)

System-wide
- Status colors tokenized (no raw bg/text-*-500 or status hex); success/warning
  to status tokens, categorical to room palette, errors to destructive
- Inputs rounded-xl; buttons rounded-xl; cards/dialogs rounded-[1.4rem];
  soft-shadow vocabulary only; focus-visible:ring-primary/30
- Forms, admin tables (now cozy cards), dialogs, popovers, auth screens

a11y: reduced-motion guards; darker status "ink" text for AA on cream.
Known tradeoff: terracotta primary + white button text ~2.96:1 (signature color,
user-tunable).

Verified: svelte-check 0/0, build ok, 274 tests pass, eslint 0 errors.
Design refs + system sheet in design-mockups/.
2026-05-27 23:04:47 +03:00

146 lines
3.9 KiB
Svelte

<script lang="ts">
interface AppData {
id: string;
name: string;
url: string;
icon: string | null;
iconType: string;
description: string | null;
statuses: Array<{ status: string; responseTime: number | null }>;
}
interface StatusConfig {
appIds: string[];
label?: string;
}
interface Props {
config: StatusConfig;
apps: AppData[];
}
let { config, apps }: Props = $props();
// Filter apps that match the configured appIds
const matchedApps = $derived(
config.appIds
.map((id) => apps.find((a) => a.id === id))
.filter((a): a is AppData => a !== undefined)
);
const statusCounts = $derived.by(() => {
const counts = { online: 0, offline: 0, degraded: 0, unknown: 0 };
for (const app of matchedApps) {
const status = app.statuses[0]?.status ?? 'unknown';
if (status in counts) {
counts[status as keyof typeof counts] += 1;
} else {
counts.unknown += 1;
}
}
return counts;
});
const total = $derived(matchedApps.length);
let expanded = $state(false);
</script>
<div class="flex flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
<!-- Header -->
<button
type="button"
onclick={() => (expanded = !expanded)}
class="flex w-full items-center justify-between text-left"
>
<span class="text-sm font-medium text-foreground">
{config.label || 'Service Status'}
</span>
<span class="text-xs text-muted-foreground">{total} services</span>
</button>
<!-- Status bar -->
<div class="mt-3 flex gap-1">
{#if statusCounts.online > 0}
<div
class="h-2 rounded-full bg-status-online"
style="flex: {statusCounts.online}"
title="{statusCounts.online} online"
></div>
{/if}
{#if statusCounts.degraded > 0}
<div
class="h-2 rounded-full bg-status-degraded"
style="flex: {statusCounts.degraded}"
title="{statusCounts.degraded} degraded"
></div>
{/if}
{#if statusCounts.offline > 0}
<div
class="h-2 rounded-full bg-status-offline"
style="flex: {statusCounts.offline}"
title="{statusCounts.offline} offline"
></div>
{/if}
{#if statusCounts.unknown > 0}
<div
class="h-2 rounded-full bg-status-unknown"
style="flex: {statusCounts.unknown}"
title="{statusCounts.unknown} unknown"
></div>
{/if}
</div>
<!-- Summary counts -->
<div class="mt-2 flex flex-wrap gap-3 text-xs">
{#if statusCounts.online > 0}
<span class="flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-status-online"></span>
{statusCounts.online} online
</span>
{/if}
{#if statusCounts.degraded > 0}
<span class="flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-status-degraded"></span>
{statusCounts.degraded} degraded
</span>
{/if}
{#if statusCounts.offline > 0}
<span class="flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-status-offline"></span>
{statusCounts.offline} offline
</span>
{/if}
{#if statusCounts.unknown > 0}
<span class="flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-status-unknown"></span>
{statusCounts.unknown} unknown
</span>
{/if}
</div>
<!-- Expanded: individual app statuses -->
{#if expanded}
<div class="mt-3 space-y-1 border-t border-border pt-3">
{#each matchedApps as app (app.id)}
{@const status = app.statuses[0]?.status ?? 'unknown'}
{@const statusColor =
status === 'online'
? 'bg-status-online'
: status === 'offline'
? 'bg-status-offline'
: status === 'degraded'
? 'bg-status-degraded'
: 'bg-status-unknown'}
<div class="flex items-center justify-between text-xs">
<span class="text-foreground">{app.name}</span>
<span class="flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full {statusColor}"></span>
<span class="text-muted-foreground">{status}</span>
</span>
</div>
{/each}
</div>
{/if}
</div>