feat: daemon health panel, brand-rail status chips, user timezone selector
Build / build (push) Successful in 10m35s

- Health API now surfaces Docker /info + /version (version, platform,
  kernel, container/image counts, storage driver, memory, latency) and
  NPM aggregates (proxy host total, managed-by-Tinyforge count, access
  lists, certificates, endpoint URL).
- Docker/NPM indicators moved out of the sidebar footer and into a
  compact mono-styled rail directly under the Tinyforge brand title,
  with pulse/fault animations and click-to-expand error hints.
- New SystemDaemonsCard on the dashboard: two terminal-styled panels
  (Docker Engine + Proxy) with a running/paused/stopped stacked bar,
  key-value diagnostics, and a total-vs-managed proportion meter on
  the proxy-hosts tile.
- Shared health store so the sidebar and dashboard share a single
  30 s poll instead of duplicating traffic.
- User-facing timezone preference with auto-detect fallback; all
  dates across projects, sites, stacks, settings, backup, event log
  and stale containers now render through \$fmt.date / \$fmt.datetime.
- en/ru translations for both features.
This commit is contained in:
2026-04-23 14:32:30 +03:00
parent a182a93950
commit 90e6e59d9e
24 changed files with 2267 additions and 177 deletions
@@ -0,0 +1,780 @@
<!--
Dashboard daemon panel. Renders detailed runtime info for the Docker engine
and the configured proxy provider (NPM or Traefik). Subscribes to the
shared health store so there is no extra network traffic beyond the
single /api/health poll already running for the sidebar chips.
Aesthetic: control-room / forge — two terminal-styled panels side by side,
with key/value rows, a container-state bar graph, and registration marks
that reveal on hover. Dimensions are generous but everything is rendered
in mono to reinforce the "instrumentation" feel.
-->
<script lang="ts">
import { health, refreshHealth } from '$lib/stores/health';
import { IconRefresh, IconContainer, IconServer, IconShield, IconWifi } from '$lib/components/icons';
import { t } from '$lib/i18n';
const docker = $derived($health.docker);
const proxy = $derived($health.proxy);
const checked = $derived($health.checked);
const dockerConnected = $derived(docker?.connected ?? false);
const proxyConnected = $derived(proxy?.connected ?? false);
const proxyProvider = $derived(proxy?.provider ?? 'none');
const totalContainers = $derived(
(docker?.running ?? 0) + (docker?.paused ?? 0) + (docker?.stopped ?? 0)
);
const runningPct = $derived(
totalContainers > 0 ? ((docker?.running ?? 0) / totalContainers) * 100 : 0
);
const pausedPct = $derived(
totalContainers > 0 ? ((docker?.paused ?? 0) / totalContainers) * 100 : 0
);
const stoppedPct = $derived(
totalContainers > 0 ? ((docker?.stopped ?? 0) / totalContainers) * 100 : 0
);
function formatBytes(n: number | undefined): string {
if (!n || n <= 0) return '—';
const gb = n / 1024 ** 3;
if (gb >= 1) return `${gb.toFixed(1)} GB`;
const mb = n / 1024 ** 2;
return `${mb.toFixed(0)} MB`;
}
function formatMs(n: number | undefined): string {
if (typeof n !== 'number') return '—';
return `${n} ms`;
}
let refreshing = $state(false);
async function onRefresh(): Promise<void> {
if (refreshing) return;
refreshing = true;
try {
await refreshHealth();
} finally {
refreshing = false;
}
}
</script>
<section class="daemons">
<header class="daemons-head">
<div>
<span class="eyebrow">SYSTEMS // DAEMONS</span>
<h2 class="daemons-title">{$t('daemons.title')}<span class="accent">.</span></h2>
</div>
<button class="refresh-btn" type="button" onclick={onRefresh} disabled={refreshing} title={$t('daemons.refresh')}>
<span class:spin={refreshing} class="refresh-icon"><IconRefresh size={14} /></span>
<span>{refreshing ? $t('daemons.refreshing') : $t('daemons.refresh')}</span>
</button>
</header>
<div class="daemons-grid">
<!-- ═══════════ DOCKER PANEL ═══════════ -->
<article class="panel" class:panel-down={checked && !dockerConnected}>
<div class="panel-head">
<div class="panel-title">
<IconContainer size={15} />
<span>{$t('daemons.docker')}</span>
</div>
<span class="status-pill" class:live={dockerConnected} class:down={!dockerConnected}>
<span class="status-dot"></span>
{dockerConnected ? $t('daemons.online') : $t('daemons.offline')}
</span>
</div>
{#if !checked}
<div class="panel-skeleton">
<div class="skel-bar"></div>
<div class="skel-bar skel-bar-narrow"></div>
<div class="skel-bar"></div>
</div>
{:else if !dockerConnected}
<div class="panel-error">
<code>{docker?.error ?? 'Docker daemon is not reachable.'}</code>
<p>{$t('daemons.dockerHint')}</p>
</div>
{:else}
<!-- Container state breakdown — stacked bar -->
<div class="state-block">
<div class="state-head">
<span class="state-caption">{$t('daemons.containers')}</span>
<span class="state-total">{totalContainers}</span>
</div>
{#if totalContainers > 0}
<div class="state-bar" aria-hidden="true">
<span class="seg seg-run" style="width: {runningPct}%"></span>
<span class="seg seg-pause" style="width: {pausedPct}%"></span>
<span class="seg seg-stop" style="width: {stoppedPct}%"></span>
</div>
{:else}
<div class="state-bar empty" aria-hidden="true"></div>
{/if}
<div class="state-legend">
<span class="leg"><i class="ld ld-run"></i>{$t('daemons.running')} <b>{docker?.running ?? 0}</b></span>
<span class="leg"><i class="ld ld-pause"></i>{$t('daemons.paused')} <b>{docker?.paused ?? 0}</b></span>
<span class="leg"><i class="ld ld-stop"></i>{$t('daemons.stopped')} <b>{docker?.stopped ?? 0}</b></span>
</div>
</div>
<!-- Key/value grid -->
<dl class="kv">
<div>
<dt>{$t('daemons.version')}</dt>
<dd>{docker?.version ?? '—'}</dd>
</div>
<div>
<dt>{$t('daemons.apiVersion')}</dt>
<dd>{docker?.api_version ?? '—'}</dd>
</div>
<div>
<dt>{$t('daemons.platform')}</dt>
<dd>{docker?.os ?? '—'}{docker?.arch ? ` · ${docker.arch}` : ''}</dd>
</div>
<div>
<dt>{$t('daemons.kernel')}</dt>
<dd class="truncate" title={docker?.kernel ?? ''}>{docker?.kernel ?? '—'}</dd>
</div>
<div>
<dt>{$t('daemons.cpu')}</dt>
<dd>{docker?.ncpu ? `${docker.ncpu} cores` : '—'}</dd>
</div>
<div>
<dt>{$t('daemons.memory')}</dt>
<dd>{formatBytes(docker?.memory_total)}</dd>
</div>
<div>
<dt>{$t('daemons.storage')}</dt>
<dd>{docker?.storage_driver ?? '—'}</dd>
</div>
<div>
<dt>{$t('daemons.images')}</dt>
<dd>{docker?.images ?? 0}</dd>
</div>
<div>
<dt>{$t('daemons.latency')}</dt>
<dd>{formatMs(docker?.latency_ms)}</dd>
</div>
<div class="kv-wide">
<dt>{$t('daemons.rootDir')}</dt>
<dd class="truncate" title={docker?.root_dir ?? ''}>{docker?.root_dir ?? '—'}</dd>
</div>
</dl>
{/if}
</article>
<!-- ═══════════ PROXY PANEL ═══════════ -->
<article class="panel" class:panel-down={checked && !proxyConnected && proxyProvider !== 'none'}>
<div class="panel-head">
<div class="panel-title">
{#if proxyProvider === 'npm'}
<IconShield size={15} />
{:else}
<IconWifi size={15} />
{/if}
<span>
{proxyProvider === 'npm'
? $t('daemons.npm')
: proxyProvider === 'traefik'
? $t('daemons.traefik')
: $t('daemons.proxy')}
</span>
</div>
{#if proxyProvider === 'none' || !proxy}
<span class="status-pill idle">
<span class="status-dot"></span>
{$t('daemons.notConfigured')}
</span>
{:else}
<span class="status-pill" class:live={proxyConnected} class:down={!proxyConnected}>
<span class="status-dot"></span>
{proxyConnected ? $t('daemons.online') : $t('daemons.offline')}
</span>
{/if}
</div>
{#if !checked}
<div class="panel-skeleton">
<div class="skel-bar"></div>
<div class="skel-bar skel-bar-narrow"></div>
<div class="skel-bar"></div>
</div>
{:else if proxyProvider === 'none' || !proxy}
<div class="panel-empty">
<IconServer size={20} />
<p>{$t('daemons.noProxyDesc')}</p>
<a href="/settings" class="inline-link">{$t('daemons.configureProxy')}</a>
</div>
{:else if !proxyConnected}
<div class="panel-error">
<code>{proxy.error ?? `${proxyProvider.toUpperCase()} is not reachable.`}</code>
<p>{$t('daemons.proxyHint')}</p>
{#if proxy.url}
<p class="url"><span class="kdim">URL</span> <code>{proxy.url}</code></p>
{/if}
</div>
{:else}
{#if proxyProvider === 'npm'}
{@const total = proxy.proxy_hosts ?? 0}
{@const managed = proxy.proxy_hosts_managed ?? 0}
{@const external = Math.max(0, total - managed)}
{@const managedPct = total > 0 ? (managed / total) * 100 : 0}
<!-- NPM stat tiles -->
<div class="proxy-stats">
<a href="/proxies" class="pstat pstat-wide">
<span class="pstat-label">{$t('daemons.proxyHosts')}</span>
<div class="pstat-split">
<span class="pstat-value">{total}</span>
<span class="pstat-sep">·</span>
<span class="pstat-sub">
<span class="pstat-chip managed">
<i class="pstat-dot managed"></i>
{managed} <em>{$t('daemons.managed')}</em>
</span>
<span class="pstat-chip external">
<i class="pstat-dot external"></i>
{external} <em>{$t('daemons.external')}</em>
</span>
</span>
</div>
{#if total > 0}
<div class="pstat-meter" aria-hidden="true">
<span class="meter-fill" style="width: {managedPct}%"></span>
</div>
{/if}
</a>
<a href="/settings/npm" class="pstat">
<span class="pstat-label">{$t('daemons.accessLists')}</span>
<span class="pstat-value">{proxy.access_lists ?? 0}</span>
</a>
<a href="/settings/npm" class="pstat">
<span class="pstat-label">{$t('daemons.certificates')}</span>
<span class="pstat-value">{proxy.certificates ?? 0}</span>
</a>
</div>
{/if}
<dl class="kv">
<div>
<dt>{$t('daemons.provider')}</dt>
<dd class="mono">{proxyProvider.toUpperCase()}</dd>
</div>
<div>
<dt>{$t('daemons.latency')}</dt>
<dd>{formatMs(proxy.latency_ms)}</dd>
</div>
<div class="kv-wide">
<dt>{$t('daemons.endpoint')}</dt>
<dd class="truncate" title={proxy.url ?? ''}>{proxy.url ?? '—'}</dd>
</div>
</dl>
{/if}
</article>
</div>
</section>
<style>
.daemons {
position: relative;
}
/* ── Header ─────────────────────────────────────────────── */
.daemons-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.85rem;
}
.eyebrow {
display: block;
font-family: var(--forge-mono);
font-size: 0.62rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--text-tertiary);
margin-bottom: 0.2rem;
}
.daemons-title {
font-family: var(--font-family-sans);
font-size: 1.35rem;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--text-primary);
margin: 0;
}
.daemons-title .accent {
color: var(--forge-accent);
font-weight: 700;
}
.refresh-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.38rem 0.7rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: 8px;
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: all 150ms ease;
}
.refresh-btn:hover:not(:disabled) {
color: var(--text-primary);
border-color: var(--color-brand-400);
background: var(--surface-card-hover);
}
.refresh-btn:disabled { opacity: 0.55; cursor: wait; }
.refresh-icon { display: inline-flex; }
.refresh-icon.spin :global(svg) { animation: daemon-spin 0.9s linear infinite; }
@keyframes daemon-spin { to { transform: rotate(360deg); } }
/* ── Grid ───────────────────────────────────────────────── */
.daemons-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
}
@media (min-width: 900px) {
.daemons-grid { grid-template-columns: 1.15fr 1fr; }
}
/* ── Panel base ─────────────────────────────────────────── */
.panel {
position: relative;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: 14px;
padding: 1rem 1.1rem 1.1rem;
overflow: hidden;
}
.panel::before {
/* Registration mark: top-left corner tick */
content: '';
position: absolute;
top: 0; left: 0;
width: 14px; height: 14px;
border-top: 2px solid var(--color-brand-500);
border-left: 2px solid var(--color-brand-500);
border-top-left-radius: 14px;
opacity: 0;
transition: opacity 200ms ease;
}
.panel:hover::before { opacity: 1; }
.panel::after {
/* Subtle orange wash top */
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 2px;
background: linear-gradient(90deg, var(--color-brand-500), transparent 70%);
opacity: 0;
transition: opacity 250ms ease;
}
.panel:hover::after { opacity: 0.55; }
.panel-down {
border-color: color-mix(in srgb, var(--color-danger) 45%, transparent);
background: color-mix(in srgb, var(--color-danger) 3%, var(--surface-card));
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.85rem;
padding-bottom: 0.75rem;
border-bottom: 1px dashed var(--border-secondary);
}
.panel-title {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-family: var(--forge-mono);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-primary);
}
.panel-title :global(svg) { color: var(--color-brand-600); }
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.22rem 0.55rem;
border-radius: 999px;
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
color: var(--text-tertiary);
}
.status-pill .status-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--text-tertiary);
}
.status-pill.live {
color: var(--color-success-dark);
background: color-mix(in srgb, var(--color-success) 9%, transparent);
border-color: color-mix(in srgb, var(--color-success) 35%, transparent);
}
.status-pill.live .status-dot {
background: var(--color-success);
animation: daemon-pulse 1.8s ease-in-out infinite;
}
.status-pill.down {
color: var(--color-danger-dark);
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border-color: color-mix(in srgb, var(--color-danger) 40%, transparent);
}
.status-pill.down .status-dot {
background: var(--color-danger);
animation: daemon-blink 0.9s steps(2) infinite;
}
.status-pill.idle { opacity: 0.75; }
:global([data-theme='dark']) .status-pill.live { color: #86efac; }
:global([data-theme='dark']) .status-pill.down { color: #fca5a5; }
@keyframes daemon-pulse {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-success) 40%, transparent); }
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-success) 0%, transparent); }
}
@keyframes daemon-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ── Container state bar ───────────────────────────────── */
.state-block {
margin-bottom: 0.95rem;
padding: 0.75rem 0.85rem;
background: color-mix(in srgb, var(--color-brand-500) 4%, transparent);
border: 1px solid color-mix(in srgb, var(--color-brand-500) 18%, transparent);
border-radius: 10px;
}
.state-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.state-caption {
font-family: var(--forge-mono);
font-size: 0.58rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.state-total {
font-family: var(--font-family-sans);
font-size: 1.6rem;
font-weight: 700;
line-height: 1;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.state-bar {
display: flex;
height: 10px;
width: 100%;
border-radius: 6px;
overflow: hidden;
background: var(--border-secondary);
}
.state-bar.empty {
background: repeating-linear-gradient(45deg, var(--border-secondary), var(--border-secondary) 4px, transparent 4px, transparent 8px);
border: 1px dashed var(--border-primary);
}
.seg { display: block; height: 100%; transition: width 600ms cubic-bezier(0.2, 0.8, 0.2, 1); }
.seg-run { background: var(--color-success); }
.seg-pause { background: var(--color-warning); }
.seg-stop { background: var(--text-tertiary); }
.state-legend {
display: flex;
flex-wrap: wrap;
gap: 0.9rem;
margin-top: 0.55rem;
font-family: var(--forge-mono);
font-size: 0.62rem;
color: var(--text-secondary);
letter-spacing: 0.04em;
}
.state-legend b { color: var(--text-primary); font-weight: 700; font-variant-numeric: tabular-nums; margin-left: 0.25rem; }
.leg { display: inline-flex; align-items: center; gap: 0.4rem; }
.ld { display: inline-block; width: 8px; height: 8px; border-radius: 2px; }
.ld-run { background: var(--color-success); }
.ld-pause { background: var(--color-warning); }
.ld-stop { background: var(--text-tertiary); }
/* ── Key/Value grid ────────────────────────────────────── */
.kv {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem 1.1rem;
margin: 0;
}
.kv > div {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.kv-wide { grid-column: 1 / -1; }
.kv dt {
font-family: var(--forge-mono);
font-size: 0.54rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-tertiary);
margin: 0;
}
.kv dd {
font-family: var(--forge-mono);
font-size: 0.78rem;
color: var(--text-primary);
margin: 0;
line-height: 1.35;
font-variant-numeric: tabular-nums;
}
.kv dd.mono { letter-spacing: 0.04em; }
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Proxy stat tiles ─────────────────────────────────── */
.proxy-stats {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-areas:
'wide wide'
'a b';
gap: 0.4rem;
margin-bottom: 0.95rem;
}
.pstat-wide { grid-area: wide; }
.pstat {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.6rem 0.7rem;
background: color-mix(in srgb, var(--color-brand-500) 4%, transparent);
border: 1px solid color-mix(in srgb, var(--color-brand-500) 16%, transparent);
border-radius: 10px;
text-decoration: none;
color: inherit;
transition: all 150ms ease;
}
.pstat:hover {
border-color: var(--color-brand-500);
background: color-mix(in srgb, var(--color-brand-500) 9%, transparent);
transform: translateY(-1px);
}
.pstat-label {
font-family: var(--forge-mono);
font-size: 0.52rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.pstat-value {
font-family: var(--font-family-sans);
font-size: 1.35rem;
font-weight: 700;
line-height: 1;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
/* Split-stat layout for proxy hosts (total / managed / external). */
.pstat-split {
display: flex;
align-items: baseline;
gap: 0.55rem;
flex-wrap: wrap;
margin-top: 0.15rem;
}
.pstat-split .pstat-value {
font-size: 1.8rem;
line-height: 1;
}
.pstat-sep {
color: var(--text-tertiary);
font-family: var(--forge-mono);
font-size: 1.2rem;
line-height: 1;
opacity: 0.55;
}
.pstat-sub {
display: inline-flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.pstat-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.12rem 0.45rem 0.12rem 0.35rem;
border-radius: 999px;
font-family: var(--forge-mono);
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text-primary);
background: var(--surface-card);
border: 1px solid var(--border-primary);
font-variant-numeric: tabular-nums;
}
.pstat-chip em {
font-style: normal;
font-weight: 500;
font-size: 0.56rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-tertiary);
margin-left: 0.1rem;
}
.pstat-chip.managed {
border-color: color-mix(in srgb, var(--color-brand-500) 40%, transparent);
color: var(--color-brand-700);
background: color-mix(in srgb, var(--color-brand-500) 10%, var(--surface-card));
}
.pstat-chip.external {
border-color: var(--border-primary);
color: var(--text-secondary);
}
:global([data-theme='dark']) .pstat-chip.managed {
color: #c7d2fe;
background: color-mix(in srgb, var(--color-brand-500) 18%, transparent);
}
.pstat-dot {
width: 7px; height: 7px; border-radius: 50%;
display: inline-block;
}
.pstat-dot.managed { background: var(--color-brand-500); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-brand-500) 20%, transparent); }
.pstat-dot.external { background: var(--text-tertiary); opacity: 0.6; }
/* Proportion meter under the total. */
.pstat-meter {
margin-top: 0.55rem;
height: 4px;
border-radius: 999px;
background: var(--border-secondary);
overflow: hidden;
}
.meter-fill {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--color-brand-500), var(--color-brand-600));
border-radius: inherit;
transition: width 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* ── Error / empty states ─────────────────────────────── */
.panel-error {
padding: 0.75rem 0.85rem;
border-radius: 10px;
background: color-mix(in srgb, var(--color-danger) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--color-danger) 35%, transparent);
}
.panel-error code {
display: block;
font-family: var(--forge-mono);
font-size: 0.7rem;
line-height: 1.55;
color: var(--color-danger-dark);
word-break: break-word;
margin-bottom: 0.45rem;
}
.panel-error p {
margin: 0;
font-size: 0.75rem;
color: var(--text-secondary);
}
.panel-error .url {
margin-top: 0.45rem;
font-family: var(--forge-mono);
font-size: 0.7rem;
}
.panel-error .url code {
display: inline;
color: var(--text-primary);
background: var(--surface-card-hover);
padding: 0.08rem 0.35rem;
border-radius: 4px;
margin: 0;
}
.kdim {
display: inline-block;
color: var(--text-tertiary);
letter-spacing: 0.2em;
text-transform: uppercase;
font-size: 0.54rem;
margin-right: 0.4rem;
}
:global([data-theme='dark']) .panel-error code { color: #fca5a5; }
.panel-empty {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 0.9rem 1rem;
background: var(--surface-card-hover);
border: 1px dashed var(--border-primary);
border-radius: 10px;
color: var(--text-secondary);
}
.panel-empty :global(svg) { color: var(--text-tertiary); }
.panel-empty p { margin: 0; font-size: 0.8rem; }
.inline-link {
font-family: var(--forge-mono);
font-size: 0.66rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-brand-600);
text-decoration: none;
}
.inline-link:hover { text-decoration: underline; }
/* ── Skeleton ─────────────────────────────────────────── */
.panel-skeleton {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding: 0.4rem 0;
}
.skel-bar {
height: 10px;
border-radius: 6px;
background: linear-gradient(90deg, var(--surface-card-hover) 0%, var(--border-secondary) 50%, var(--surface-card-hover) 100%);
background-size: 200% 100%;
animation: skel-shift 1.4s ease-in-out infinite;
}
.skel-bar-narrow { width: 55%; }
@keyframes skel-shift {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>