Files
tiny-forge/web/src/lib/components/SystemDaemonsCard.svelte
T
alexei.dolgolyov 410a131cec feat(apps): stepped creation wizard, branch previews, and app-creation fixes
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
  WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
  ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
  + {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
  /apps/[id] edit form onto the same components (removes the duplication). Add
  vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
  environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
  state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
  conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
  label hints; dashboard + /apps "Total workloads" count only source_kind workloads
  (drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
  empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.

Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
2026-05-29 02:09:54 +03:00

780 lines
23 KiB
Svelte

<!--
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
);
import { formatBytes as formatBytesShared } from '$lib/format/bytes';
function formatBytes(n: number | undefined): string {
if (!n || n <= 0) return '—';
return formatBytesShared(n);
}
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 ?? $t('daemons.dockerNotReachable')}</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 ?? $t('daemons.notReachable', { provider: proxyProvider.toUpperCase() })}</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>