feat: Forge design system app-wide + Stacks i18n
Build / build (push) Successful in 10m47s

Promotes the Forge visual language from the Stacks feature into a
global design system used across the app:

- app.css: Forge utilities (dot-grid backdrop, eyebrow, ember,
  display/lede, status pills, stat grid, panels, registration
  marks, alert, terminal, buttons). CSS variables alias the forge
  display font to the app's standard sans stack (Inter, now
  properly self-hosted via @fontsource/inter).
- +layout.svelte: reskinned sidebar brand, active nav rail,
  mobile top bar, global h1/h2 typography overrides, main dot-grid
  backdrop.
- Shared components reskinned: EmptyState (breathing-ember empty
  mark), StatusBadge (mono pills with pulse), ConfirmDialog
  (registration marks + forge buttons).
- Dashboard (+page.svelte): ForgeHero header, forge-stat-grid,
  Instrument-style section titles with accent.
- New ForgeHero component for reusable hero headers.

Stacks feature fully localized (EN + RU):
- 80+ keys under stacks.* covering list, new, detail, revisions,
  logs, errors, status labels, delete/rollback dialogs.
- Russian uses forge vocabulary (куются/наковальня/куём/etc).
- $t() wired through all three Stacks pages.
This commit is contained in:
2026-04-16 04:17:42 +03:00
parent 75424a5f25
commit 0fd92fdfa3
14 changed files with 1251 additions and 343 deletions
+76 -17
View File
@@ -129,13 +129,9 @@
{sidebarOpen ? 'translate-x-0' : '-translate-x-full'}"
>
<!-- Logo -->
<div class="flex h-16 items-center gap-2.5 border-b border-[var(--border-primary)] px-5">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--color-brand-600)]">
<svg class="h-4.5 w-4.5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
</svg>
</div>
<span class="text-base font-bold text-[var(--text-primary)]">{$t('app.name')}</span>
<div class="brand flex h-16 items-center gap-2.5 border-b border-[var(--border-primary)] px-5">
<span class="forge-ember brand-ember"></span>
<span class="brand-name">{$t('app.name')}</span>
<!-- Close sidebar (mobile) -->
<button
@@ -153,10 +149,8 @@
{@const active = isActive(item.href, $page.url.pathname)}
<a
href={item.href}
class="group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
{active
? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] shadow-sm'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)]'}"
class="nav-item group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
{active ? 'nav-active' : 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)]'}"
>
{#if item.icon === 'dashboard'}
<IconDashboard size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
@@ -276,12 +270,8 @@
>
<IconMenu size={22} />
</button>
<div class="flex h-7 w-7 items-center justify-center rounded-lg bg-[var(--color-brand-600)]">
<svg class="h-3.5 w-3.5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
</svg>
</div>
<span class="text-sm font-bold text-[var(--text-primary)]">{$t('app.name')}</span>
<span class="forge-ember"></span>
<span class="brand-name">{$t('app.name')}</span>
</header>
<!-- Page content -->
@@ -295,3 +285,72 @@
{/if}
<Toast />
<style>
/* ── Forge-themed layout shell ─────────────────────────────────── */
/* Page titles — larger + tighter tracking, but using the app's sans stack */
:global(main h1) {
font-family: var(--font-family-sans) !important;
font-weight: 700 !important;
letter-spacing: -0.02em !important;
font-size: clamp(1.875rem, 4vw, 2.5rem) !important;
line-height: 1.1 !important;
color: var(--text-primary);
}
:global(main h2) {
font-family: var(--font-family-sans);
font-weight: 600;
letter-spacing: -0.01em;
}
:global(main code) {
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
}
.brand {
gap: 0.75rem;
}
.brand-ember {
width: 10px; height: 10px;
}
.brand-name {
font-family: var(--font-family-sans);
font-weight: 700;
font-size: 1.05rem;
line-height: 1;
letter-spacing: -0.02em;
color: var(--text-primary);
}
.nav-item :global(svg) { flex-shrink: 0; }
.nav-active {
background: var(--surface-card-hover);
color: var(--text-primary) !important;
position: relative;
}
.nav-active::before {
content: '';
position: absolute;
left: -12px; top: 20%; bottom: 20%;
width: 3px;
background: var(--color-brand-600);
border-radius: 0 3px 3px 0;
}
/* Apply dot-grid backdrop to main content */
:global(main) {
position: relative;
isolation: isolate;
}
:global(main)::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; height: 480px;
background-image: radial-gradient(var(--border-primary) 1px, transparent 1px);
background-size: 22px 22px;
-webkit-mask-image: radial-gradient(ellipse at 20% 0%, #000 0%, transparent 70%);
mask-image: radial-gradient(ellipse at 20% 0%, #000 0%, transparent 70%);
pointer-events: none;
z-index: -1;
opacity: 0.7;
}
</style>
+107 -71
View File
@@ -5,7 +5,8 @@
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import SystemHealthCard from '$lib/components/SystemHealthCard.svelte';
import { IconDeploy, IconBox, IconServer, IconAlert, IconClock, IconGlobe } from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { IconDeploy, IconAlert } from '$lib/components/icons';
import { t } from '$lib/i18n';
let projects = $state<Project[]>([]);
@@ -113,73 +114,53 @@
<title>{$t('dashboard.title')} - {$t('app.name')}</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('dashboard.title')}</h1>
<a
href="/deploy"
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] active:animate-press"
>
<IconDeploy size={16} />
{$t('dashboard.quickDeploy')}
<div class="space-y-6 dashboard">
<!-- Hero -->
{#snippet heroToolbar()}
<a href="/deploy" class="forge-btn">
<IconDeploy size={14} />
<span>{$t('dashboard.quickDeploy')}</span>
</a>
</div>
{/snippet}
<ForgeHero
eyebrow="THE FORGE"
eyebrowSuffix="DASHBOARD"
title={$t('dashboard.title')}
accent="."
size="lg"
toolbar={heroToolbar}
/>
<!-- Stats cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<div class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--color-brand-50)] text-[var(--color-brand-600)]">
<IconBox size={24} />
</div>
<div>
<p class="text-sm text-[var(--text-secondary)]">{$t('dashboard.totalProjects')}</p>
<p class="mt-0.5 text-2xl font-bold text-[var(--text-primary)]">{totalProjects}</p>
</div>
<!-- Stats grid -->
<div class="forge-stat-grid">
<div class="forge-stat">
<span class="forge-stat-label">{$t('dashboard.totalProjects')}</span>
<span class="forge-stat-value">{String(totalProjects).padStart(2, '0')}</span>
<span class="forge-stat-sub">active</span>
</div>
<div class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-50 dark:bg-emerald-950/30 text-emerald-600">
<IconServer size={24} />
</div>
<div>
<p class="text-sm text-[var(--text-secondary)]">{$t('dashboard.runningInstances')}</p>
<p class="mt-0.5 text-2xl font-bold text-emerald-600">{totalRunning}</p>
</div>
<div class="forge-stat">
<span class="forge-stat-label">{$t('dashboard.runningInstances')}</span>
<span class="forge-stat-value accent">{String(totalRunning).padStart(2, '0')}</span>
<span class="forge-stat-sub">instances</span>
</div>
<div class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
<div class="flex h-12 w-12 items-center justify-center rounded-xl {totalFailed > 0 ? 'bg-red-50 dark:bg-red-950/30 text-red-600' : 'bg-gray-50 dark:bg-gray-800/30 text-gray-400'}">
<IconAlert size={24} />
</div>
<div>
<p class="text-sm text-[var(--text-secondary)]">{$t('dashboard.failedInstances')}</p>
<p class="mt-0.5 text-2xl font-bold {totalFailed > 0 ? 'text-red-600' : 'text-[var(--text-primary)]'}">{totalFailed}</p>
</div>
<div class="forge-stat">
<span class="forge-stat-label">{$t('dashboard.failedInstances')}</span>
<span class="forge-stat-value" class:fail={totalFailed > 0}>{String(totalFailed).padStart(2, '0')}</span>
<span class="forge-stat-sub">need attention</span>
</div>
<a href="/containers/stale" class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]">
<div class="flex h-12 w-12 items-center justify-center rounded-xl {totalStale > 0 ? 'bg-amber-50 text-amber-600' : 'bg-gray-50 text-gray-400'}">
<IconClock size={24} />
</div>
<div>
<p class="text-sm text-[var(--text-secondary)]">{$t('dashboard.staleContainers')}</p>
<p class="mt-0.5 text-2xl font-bold {totalStale > 0 ? 'text-amber-600' : 'text-[var(--text-primary)]'}">{totalStale}</p>
</div>
<a href="/containers/stale" class="forge-stat stat-link">
<span class="forge-stat-label">{$t('dashboard.staleContainers')}</span>
<span class="forge-stat-value" class:warn={totalStale > 0}>{String(totalStale).padStart(2, '0')}</span>
<span class="forge-stat-sub">stale →</span>
</a>
<a href="/sites" class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]">
<div class="flex h-12 w-12 items-center justify-center rounded-xl {totalSites > 0 ? 'bg-[var(--color-brand-50)] text-[var(--color-brand-600)]' : 'bg-gray-50 text-gray-400'}">
<IconGlobe size={24} />
</div>
<div>
<p class="text-sm text-[var(--text-secondary)]">{$t('dashboard.totalSites')}</p>
<p class="mt-0.5 text-2xl font-bold text-[var(--text-primary)]">
{totalSites}
{#if deployedSites > 0}
<span class="text-sm font-medium text-emerald-600">{deployedSites} {$t('dashboard.deployedSites')}</span>
{/if}
{#if failedSitesCount > 0}
<span class="text-sm font-medium text-red-600">{failedSitesCount} {$t('dashboard.failedSites')}</span>
{/if}
</p>
</div>
<a href="/sites" class="forge-stat stat-link">
<span class="forge-stat-label">{$t('dashboard.totalSites')}</span>
<span class="forge-stat-value">{String(totalSites).padStart(2, '0')}</span>
<span class="forge-stat-sub">
{#if deployedSites > 0}<span class="tag ok">{deployedSites} up</span>{/if}
{#if failedSitesCount > 0}<span class="tag bad">{failedSitesCount} fail</span>{/if}
{#if deployedSites === 0 && failedSitesCount === 0}static sites →{/if}
</span>
</a>
</div>
@@ -202,12 +183,12 @@
<!-- Static sites summary -->
{#if !loading}
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('dashboard.staticSites')}</h2>
<section class="section">
<div class="section-head">
<h2 class="section-title">{$t('dashboard.staticSites')}<span class="accent">.</span></h2>
{#if sites.length > 0}
<a href="/sites" class="text-sm font-medium text-[var(--color-brand-600)] hover:text-[var(--color-brand-700)]">
{$t('dashboard.viewAllSites')} &rarr;
<a href="/sites" class="section-more">
{$t('dashboard.viewAllSites')} <span class="arrow"></span>
</a>
{/if}
</div>
@@ -244,12 +225,12 @@
{/each}
</div>
{/if}
</div>
</section>
{/if}
<!-- Project cards -->
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('dashboard.projects')}</h2>
<section class="section">
<h2 class="section-title">{$t('dashboard.projects')}<span class="accent">.</span></h2>
{#if loading}
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -285,5 +266,60 @@
{/each}
</div>
{/if}
</div>
</section>
</div>
<style>
.dashboard { position: relative; }
.stat-link {
text-decoration: none;
transition: background 150ms ease;
}
.stat-link:hover { background: var(--surface-card-hover); }
.stat-link .forge-stat-sub .tag {
display: inline-block;
padding: 0.05rem 0.4rem;
margin-right: 0.25rem;
border-radius: var(--radius-sm);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
}
.stat-link .tag.ok { background: var(--color-success-light); color: var(--color-success-dark); }
.stat-link .tag.bad { background: var(--color-danger-light); color: var(--color-danger-dark); }
:global([data-theme='dark']) .stat-link .tag.ok { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: #86efac; }
:global([data-theme='dark']) .stat-link .tag.bad { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: #fca5a5; }
.section { margin-top: 0.5rem; }
.section-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 1rem;
}
.section-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 0 1rem;
}
.section-title .accent {
color: var(--color-brand-600);
font-weight: 700;
}
.section-more {
font-family: var(--forge-mono);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-brand-600);
text-decoration: none;
}
.section-more .arrow { display: inline-block; transition: transform 150ms ease; }
.section-more:hover .arrow { transform: translateX(3px); }
</style>
+51 -56
View File
@@ -4,6 +4,7 @@
import * as api from '$lib/api';
import { IconPlus, IconRefresh, IconTrash, IconPlay, IconStop } from '$lib/components/icons';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { t } from '$lib/i18n';
let stacks = $state<Stack[]>([]);
let loading = $state(true);
@@ -38,10 +39,10 @@
function statusMeta(status: string) {
switch (status) {
case 'running': return { label: 'RUNNING', cls: 'st-running' };
case 'deploying':return { label: 'FORGING', cls: 'st-deploying' };
case 'failed': return { label: 'FAILED', cls: 'st-failed' };
default: return { label: 'COLD', cls: 'st-stopped' };
case 'running': return { label: $t('stacks.running').toUpperCase(), cls: 'st-running' };
case 'deploying':return { label: $t('stacks.deploying').toUpperCase(), cls: 'st-deploying' };
case 'failed': return { label: $t('stacks.failed').toUpperCase(), cls: 'st-failed' };
default: return { label: $t('stacks.stopped').toUpperCase(), cls: 'st-stopped' };
}
}
function fmtTime(ts: string): string {
@@ -59,34 +60,31 @@
<div class="head-top">
<span class="eyebrow">
<span class="ember"></span>
<span>THE FORGE</span>
<span>{$t('stacks.eyebrow')}</span>
<span class="sep">//</span>
<span>STACKS</span>
<span>{$t('stacks.title').toUpperCase()}</span>
</span>
<div class="toolbar">
<button class="btn-ghost" onclick={loadStacks} aria-label="Refresh">
<button class="btn-ghost" onclick={loadStacks} aria-label={$t('stacks.refresh')}>
<IconRefresh size={16} />
</button>
<a href="/stacks/new" class="btn-primary">
<IconPlus size={16} />
<span>New stack</span>
<span>{$t('stacks.newStack')}</span>
</a>
</div>
</div>
<h1 class="display">
Stacks<span class="title-accent">.</span>
{$t('stacks.title')}<span class="title-accent">.</span>
</h1>
<p class="lede">
Compose blueprints, forged as <em>atomic units</em>.
Spin up services, iterate on revisions, roll back without breaking a sweat.
</p>
<p class="lede">{@html $t('stacks.lede')}</p>
<dl class="runners">
<div><dt>TOTAL</dt><dd>{loading ? '—' : String(stacks.length).padStart(2, '0')}</dd></div>
<div><dt>RUNNING</dt><dd class="accent">{loading ? '—' : stacks.filter(s=>s.status==='running').length}</dd></div>
<div><dt>FORGING</dt><dd>{loading ? '—' : stacks.filter(s=>s.status==='deploying').length}</dd></div>
<div><dt>FAILED</dt><dd class:warn={stacks.some(s=>s.status==='failed')}>{loading ? '—' : stacks.filter(s=>s.status==='failed').length}</dd></div>
<div><dt>{$t('stacks.total').toUpperCase()}</dt><dd>{loading ? '—' : String(stacks.length).padStart(2, '0')}</dd></div>
<div><dt>{$t('stacks.running').toUpperCase()}</dt><dd class="accent">{loading ? '—' : stacks.filter(s=>s.status==='running').length}</dd></div>
<div><dt>{$t('stacks.deploying').toUpperCase()}</dt><dd>{loading ? '—' : stacks.filter(s=>s.status==='deploying').length}</dd></div>
<div><dt>{$t('stacks.failed').toUpperCase()}</dt><dd class:warn={stacks.some(s=>s.status==='failed')}>{loading ? '—' : stacks.filter(s=>s.status==='failed').length}</dd></div>
</dl>
</header>
@@ -105,10 +103,10 @@
<div class="empty-mark">
<span></span><span></span><span></span>
</div>
<h2>The anvil is cold.</h2>
<p>Upload a <code>docker-compose.yml</code> to forge your first stack.</p>
<h2>{$t('stacks.empty.title')}</h2>
<p>{$t('stacks.empty.desc')}</p>
<a href="/stacks/new" class="btn-primary">
<IconPlus size={16} /><span>New stack</span>
<IconPlus size={16} /><span>{$t('stacks.newStack')}</span>
</a>
</div>
{:else}
@@ -133,7 +131,7 @@
{#if s.description}
<p class="card-desc">{s.description}</p>
{:else}
<p class="card-desc dim">No description</p>
<p class="card-desc dim">{$t('stacks.card.noDescription')}</p>
{/if}
{#if s.error}
@@ -141,24 +139,24 @@
{/if}
<div class="card-meta">
<span class="meta-k">Updated</span>
<span class="meta-k">{$t('stacks.card.updated')}</span>
<span class="meta-v">{fmtTime(s.updated_at)}</span>
</div>
<footer class="card-foot">
{#if s.status === 'running'}
<button class="act" onclick={() => handleStop(s)} aria-label="Stop">
<IconStop size={13} /><span>Stop</span>
<button class="act" onclick={() => handleStop(s)} aria-label={$t('stacks.card.stop')}>
<IconStop size={13} /><span>{$t('stacks.card.stop')}</span>
</button>
{:else}
<button class="act" onclick={() => handleStart(s)} aria-label="Start">
<IconPlay size={13} /><span>Start</span>
<button class="act" onclick={() => handleStart(s)} aria-label={$t('stacks.card.start')}>
<IconPlay size={13} /><span>{$t('stacks.card.start')}</span>
</button>
{/if}
<button class="act danger" onclick={() => (confirmDelete = s)} aria-label="Delete">
<IconTrash size={13} /><span>Delete</span>
<button class="act danger" onclick={() => (confirmDelete = s)} aria-label={$t('stacks.card.delete')}>
<IconTrash size={13} /><span>{$t('stacks.card.delete')}</span>
</button>
<a class="act-link" href="/stacks/{s.id}">Open <span class="arrow"></span></a>
<a class="act-link" href="/stacks/{s.id}">{$t('stacks.card.open')} <span class="arrow"></span></a>
</footer>
</article>
{/each}
@@ -168,9 +166,9 @@
<ConfirmDialog
open={confirmDelete !== null}
title="Delete stack?"
message={confirmDelete ? `This runs 'docker compose down' and removes "${confirmDelete.name}".${deleteRemoveVolumes ? ' Named volumes will also be removed.' : ''}` : ''}
confirmLabel="Delete"
title={$t('stacks.detail.delete.title')}
message={confirmDelete ? $t('stacks.detail.delete.messageBase', { name: confirmDelete.name }) + (deleteRemoveVolumes ? $t('stacks.detail.delete.messageVolumes') : '') : ''}
confirmLabel={$t('stacks.detail.delete.confirm')}
confirmVariant="danger"
onconfirm={handleDelete}
oncancel={() => { confirmDelete = null; deleteRemoveVolumes = false; }}
@@ -178,7 +176,7 @@
<style>
.forge {
--serif: 'Instrument Serif', 'Iowan Old Style', Georgia, serif;
--serif: var(--font-family-sans);
--mono: var(--font-family-mono);
--accent: var(--color-brand-600);
--accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
@@ -267,27 +265,28 @@
.display {
font-family: var(--serif);
font-size: clamp(3.75rem, 9vw, 6rem);
font-weight: 400;
line-height: 1;
letter-spacing: 0;
font-size: clamp(2rem, 4vw, 2.75rem);
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.02em;
margin: 0;
}
.title-accent {
color: var(--accent);
font-style: italic;
font-weight: 700;
}
.lede {
font-family: var(--serif);
color: var(--text-secondary);
margin: 0.75rem 0 0;
max-width: 52ch;
font-size: 1.2rem;
line-height: 1.45;
max-width: 60ch;
font-size: 0.95rem;
line-height: 1.55;
}
.lede em {
.lede :global(em) {
color: var(--accent);
font-style: italic;
font-style: normal;
font-weight: 500;
}
.runners {
@@ -312,7 +311,8 @@
}
.runners dd {
margin: 0;
font-family: var(--serif); font-size: 1.75rem; line-height: 1;
font-family: var(--serif); font-size: 1.75rem; line-height: 1.1;
font-weight: 700; letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
@@ -360,18 +360,12 @@
}
.empty-mark span:nth-child(2) { background: var(--accent); animation: breathe 2.4s ease-in-out infinite; }
.empty h2 {
font-family: var(--serif); font-weight: 400;
font-size: 2.25rem; margin: 0 0 0.5rem;
letter-spacing: 0;
font-family: var(--serif); font-weight: 700;
font-size: 1.5rem; margin: 0 0 0.5rem;
letter-spacing: -0.01em;
}
.empty p { color: var(--text-secondary); margin: 0 0 1.5rem; font-size: 0.95rem; }
.empty code {
font-family: var(--mono); font-size: 0.85em;
padding: 0.1rem 0.4rem;
background: var(--surface-card-hover);
border-radius: var(--radius-sm);
}
.empty .btn-primary { display: inline-flex; }
.empty :global(.btn-primary) { display: inline-flex; }
/* ── Grid & Cards ──────────────────────────────── */
.grid {
@@ -476,10 +470,11 @@
.card-title {
font-family: var(--serif);
font-size: 1.85rem; line-height: 1.1;
font-size: 1.15rem; line-height: 1.3;
font-weight: 600;
color: var(--text-primary);
text-decoration: none;
letter-spacing: 0;
letter-spacing: -0.01em;
word-break: break-word;
margin-bottom: 0.35rem;
}
+64 -62
View File
@@ -6,6 +6,7 @@
import * as api from '$lib/api';
import { IconArrowLeft, IconRefresh, IconPlay, IconStop, IconTrash } from '$lib/components/icons';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { t } from '$lib/i18n';
const id = $derived($page.params.id ?? '');
@@ -42,7 +43,7 @@
stack = s; revisions = revs; services = svcs;
if (!editing && revs.length > 0) editYaml = revs[0].yaml;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load stack';
error = e instanceof Error ? e.message : $t('stacks.detail.errors.load');
} finally {
loading = false;
}
@@ -51,18 +52,18 @@
async function handleStop() {
if (!stack) return;
try { await api.stopStack(stack.id); setTimeout(loadAll, 1500); }
catch (e) { error = e instanceof Error ? e.message : 'Stop failed'; }
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.stop'); }
}
async function handleStart() {
if (!stack) return;
try { await api.startStack(stack.id); setTimeout(loadAll, 1500); }
catch (e) { error = e instanceof Error ? e.message : 'Start failed'; }
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.start'); }
}
async function submitNewRevision() {
if (!stack) return;
submitting = true; error = '';
try { await api.createStackRevision(stack.id, editYaml); editing = false; setTimeout(loadAll, 1500); }
catch (e) { error = e instanceof Error ? e.message : 'Update failed'; }
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.update'); }
finally { submitting = false; }
}
async function doRollback() {
@@ -70,7 +71,7 @@
const revId = confirmRollback.id;
confirmRollback = null;
try { await api.rollbackStack(stack.id, revId); setTimeout(loadAll, 1500); }
catch (e) { error = e instanceof Error ? e.message : 'Rollback failed'; }
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.rollback'); }
}
async function doDelete() {
if (!stack) return;
@@ -78,22 +79,22 @@
const rm = deleteRemoveVolumes;
confirmDelete = false; deleteRemoveVolumes = false;
try { await api.deleteStack(sid, rm); await goto('/stacks'); }
catch (e) { error = e instanceof Error ? e.message : 'Delete failed'; }
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.delete'); }
}
async function loadLogs() {
if (!stack) return;
logsLoading = true;
try { logsText = await api.getStackLogs(stack.id, logsService || undefined, 300); }
catch (e) { logsText = e instanceof Error ? e.message : 'Failed to load logs'; }
catch (e) { logsText = e instanceof Error ? e.message : $t('stacks.detail.errors.fetchLogs'); }
finally { logsLoading = false; }
}
function statusMeta(status: string) {
switch (status) {
case 'running': return { label: 'RUNNING', cls: 'st-running' };
case 'deploying':return { label: 'FORGING', cls: 'st-deploying' };
case 'failed': return { label: 'FAILED', cls: 'st-failed' };
default: return { label: 'COLD', cls: 'st-stopped' };
case 'running': return { label: $t('stacks.running').toUpperCase(), cls: 'st-running' };
case 'deploying':return { label: $t('stacks.deploying').toUpperCase(), cls: 'st-deploying' };
case 'failed': return { label: $t('stacks.failed').toUpperCase(), cls: 'st-failed' };
default: return { label: $t('stacks.stopped').toUpperCase(), cls: 'st-stopped' };
}
}
function fmtTime(ts: string): string {
@@ -117,22 +118,22 @@
<a href="/stacks" class="back">
<IconArrowLeft size={13} />
<span>STACKS</span>
<span>{$t('stacks.title').toUpperCase()}</span>
</a>
{#if loading && !stack}
<div class="loading">
<span class="spinner"></span>
<span>Loading blueprint…</span>
<span>{$t('stacks.detail.loading')}</span>
</div>
{:else if error && !stack}
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
<div class="alert"><span class="alert-tag">{$t('stacks.detail.err')}</span><span>{error}</span></div>
{:else if stack}
{@const sm = statusMeta(stack.status)}
<header class="head">
<div class="eyebrow">
<span class="ember"></span>
<span>THE FORGE</span>
<span>{$t('stacks.eyebrow')}</span>
<span class="sep">//</span>
<span class="mono-id">{stack.id.slice(0, 16)}</span>
<span class="sep">//</span>
@@ -147,36 +148,36 @@
{#if stack.description}
<p class="lede">{stack.description}</p>
{:else}
<p class="lede dim">No description</p>
<p class="lede dim">{$t('stacks.detail.noDescription')}</p>
{/if}
<span class="project-chip">
<span class="chip-k">COMPOSE PROJECT</span>
<span class="chip-k">{$t('stacks.detail.composeProject')}</span>
<code>{stack.compose_project_name}</code>
</span>
</div>
<div class="toolbar">
<button class="btn-ghost" onclick={loadAll} aria-label="Refresh">
<button class="btn-ghost" onclick={loadAll} aria-label={$t('stacks.detail.refresh')}>
<IconRefresh size={15} />
</button>
{#if stack.status === 'running'}
<button onclick={handleStop} class="chip-btn">
<IconStop size={13} /> <span>Stop</span>
<IconStop size={13} /> <span>{$t('stacks.detail.stop')}</span>
</button>
{:else}
<button onclick={handleStart} class="chip-btn primary">
<IconPlay size={13} /> <span>Start</span>
<IconPlay size={13} /> <span>{$t('stacks.detail.start')}</span>
</button>
{/if}
<button onclick={() => (confirmDelete = true)} class="chip-btn danger">
<IconTrash size={13} /> <span>Delete</span>
<IconTrash size={13} /> <span>{$t('stacks.detail.delete')}</span>
</button>
</div>
</div>
{#if stack.error}
<div class="alert">
<span class="alert-tag">FAULT</span>
<span class="alert-tag">{$t('stacks.detail.fault')}</span>
<span>{stack.error}</span>
</div>
{/if}
@@ -185,39 +186,39 @@
<!-- ── Stat tiles ─────────────────────────────── -->
<section class="stats">
<div class="stat">
<span class="stat-label">Services</span>
<span class="stat-label">{$t('stacks.detail.stats.services')}</span>
<span class="stat-value">{String(services.length).padStart(2,'0')}</span>
<span class="stat-sub">in blueprint</span>
<span class="stat-sub">{$t('stacks.detail.stats.servicesSub')}</span>
</div>
<div class="stat">
<span class="stat-label">Running</span>
<span class="stat-label">{$t('stacks.detail.stats.running')}</span>
<span class="stat-value accent">
{String(services.filter(s => serviceState(s.State) === 'running').length).padStart(2,'0')}
</span>
<span class="stat-sub">active containers</span>
<span class="stat-sub">{$t('stacks.detail.stats.runningSub')}</span>
</div>
<div class="stat">
<span class="stat-label">Revisions</span>
<span class="stat-label">{$t('stacks.detail.stats.revisions')}</span>
<span class="stat-value">{String(revisions.length).padStart(2,'0')}</span>
<span class="stat-sub">in history</span>
<span class="stat-sub">{$t('stacks.detail.stats.revisionsSub')}</span>
</div>
<div class="stat">
<span class="stat-label">Current</span>
<span class="stat-label">{$t('stacks.detail.stats.current')}</span>
<span class="stat-value">
R{(revisions.find(r => r.id === stack?.current_revision_id)?.revision ?? 0).toString().padStart(2,'0')}
</span>
<span class="stat-sub">deployed</span>
<span class="stat-sub">{$t('stacks.detail.stats.currentSub')}</span>
</div>
</section>
<!-- ── Services ───────────────────────────────── -->
<section class="panel">
<header class="panel-head">
<h2 class="panel-title">Services<span class="title-accent">.</span></h2>
<span class="panel-count">{services.length} on the floor</span>
<h2 class="panel-title">{$t('stacks.detail.services.title')}<span class="title-accent">.</span></h2>
<span class="panel-count">{$t('stacks.detail.services.count', { n: String(services.length) })}</span>
</header>
{#if services.length === 0}
<p class="panel-empty">— no containers running —</p>
<p class="panel-empty">{$t('stacks.detail.services.empty')}</p>
{:else}
<ul class="svc-list">
{#each services as svc (svc.Name)}
@@ -242,23 +243,23 @@
<section class="panel">
<div class="tabs" role="tablist">
<button role="tab" class="tab" class:active={tab==='yaml'} onclick={() => tab='yaml'}>
<span class="tab-num">I</span><span>Blueprint</span>
<span class="tab-num">I</span><span>{$t('stacks.detail.tabs.blueprint')}</span>
</button>
<button role="tab" class="tab" class:active={tab==='revisions'} onclick={() => tab='revisions'}>
<span class="tab-num">II</span><span>Revisions</span>
<span class="tab-num">II</span><span>{$t('stacks.detail.tabs.revisions')}</span>
<span class="tab-badge">{revisions.length}</span>
</button>
<button role="tab" class="tab" class:active={tab==='logs'} onclick={() => tab='logs'}>
<span class="tab-num">III</span><span>Logs</span>
<span class="tab-num">III</span><span>{$t('stacks.detail.tabs.logs')}</span>
</button>
</div>
{#if tab === 'yaml'}
<div class="panel-body">
<div class="panel-toolbar">
<span class="dim">Current revision</span>
<span class="dim">{$t('stacks.detail.yaml.currentRevision')}</span>
{#if !editing}
<button class="chip" onclick={() => (editing = true)}>Edit &amp; redeploy</button>
<button class="chip" onclick={() => (editing = true)}>{$t('stacks.detail.yaml.edit')}</button>
{/if}
</div>
{#if editing}
@@ -269,9 +270,9 @@
spellcheck="false"
></textarea>
<div class="panel-foot">
<button class="btn-ghost" onclick={() => (editing = false)}>Cancel</button>
<button class="btn-ghost" onclick={() => (editing = false)}>{$t('stacks.detail.yaml.cancel')}</button>
<button class="btn-primary" onclick={submitNewRevision} disabled={submitting}>
<span>{submitting ? 'Forging' : 'Deploy new revision'}</span>
<span>{submitting ? $t('stacks.detail.yaml.forging') : $t('stacks.detail.yaml.deployNew')}</span>
<span class="arrow"></span>
</button>
</div>
@@ -295,17 +296,17 @@
<div class="tl-head">
<span class="tl-rev">R{rev.revision.toString().padStart(2, '0')}</span>
{#if rev.id === stack.current_revision_id}
<span class="tl-badge">CURRENT</span>
<span class="tl-badge">{$t('stacks.detail.revisions.current')}</span>
{/if}
<span class="tl-status">{rev.status}</span>
<span class="tl-time">{fmtTime(rev.created_at)}</span>
</div>
<div class="tl-meta">
by <strong>{rev.author || 'operator'}</strong>
{$t('stacks.detail.revisions.by')} <strong>{rev.author || 'operator'}</strong>
</div>
{#if rev.id !== stack.current_revision_id}
<button class="tl-action" onclick={() => (confirmRollback = rev)}>
← Rollback to this revision
{$t('stacks.detail.revisions.rollback')}
</button>
{/if}
</div>
@@ -317,16 +318,16 @@
<div class="panel-body">
<div class="panel-toolbar">
<label class="log-select">
<span class="dim">Service:</span>
<span class="dim">{$t('stacks.detail.logs.service')}</span>
<select bind:value={logsService}>
<option value="">All services</option>
<option value="">{$t('stacks.detail.logs.allServices')}</option>
{#each services as svc (svc.Service)}
<option value={svc.Service}>{svc.Service}</option>
{/each}
</select>
</label>
<button onclick={loadLogs} class="chip" disabled={logsLoading}>
{logsLoading ? 'Fetching' : 'Fetch logs'}
{logsLoading ? $t('stacks.detail.logs.fetching') : $t('stacks.detail.logs.fetch')}
</button>
</div>
{#if logsText}
@@ -340,7 +341,7 @@
<pre class="terminal-body">{logsText}</pre>
</div>
{:else}
<p class="panel-empty">— no logs loaded. tap fetch. —</p>
<p class="panel-empty">{$t('stacks.detail.logs.empty')}</p>
{/if}
</div>
{/if}
@@ -350,9 +351,9 @@
<ConfirmDialog
open={confirmRollback !== null}
title="Rollback to revision?"
message={confirmRollback ? `Create a new revision from rev ${confirmRollback.revision} and redeploy the stack.` : ''}
confirmLabel="Rollback"
title={$t('stacks.detail.revisions.rollbackTitle')}
message={confirmRollback ? $t('stacks.detail.revisions.rollbackMessage', { n: String(confirmRollback.revision) }) : ''}
confirmLabel={$t('stacks.detail.revisions.rollbackConfirm')}
confirmVariant="primary"
onconfirm={doRollback}
oncancel={() => (confirmRollback = null)}
@@ -360,9 +361,9 @@
<ConfirmDialog
open={confirmDelete}
title="Delete stack?"
message={stack ? `This runs 'docker compose down' and removes "${stack.name}".${deleteRemoveVolumes ? ' Named volumes will also be removed.' : ''}` : ''}
confirmLabel="Delete"
title={$t('stacks.detail.delete.title')}
message={stack ? $t('stacks.detail.delete.messageBase', { name: stack.name }) + (deleteRemoveVolumes ? $t('stacks.detail.delete.messageVolumes') : '') : ''}
confirmLabel={$t('stacks.detail.delete.confirm')}
confirmVariant="danger"
onconfirm={doDelete}
oncancel={() => { confirmDelete = false; deleteRemoveVolumes = false; }}
@@ -370,7 +371,7 @@
<style>
.forge {
--serif: 'Instrument Serif', 'Iowan Old Style', Georgia, serif;
--serif: var(--font-family-sans);
--mono: var(--font-family-mono);
--accent: var(--color-brand-600);
--accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
@@ -477,9 +478,9 @@
.head-left { flex: 1; min-width: 280px; }
.display {
font-family: var(--serif);
font-size: clamp(2.75rem, 7vw, 4.5rem);
font-weight: 400; line-height: 1.05;
letter-spacing: 0;
font-size: clamp(1.875rem, 4vw, 2.5rem);
font-weight: 700; line-height: 1.1;
letter-spacing: -0.02em;
margin: 0;
word-break: break-word;
}
@@ -610,7 +611,8 @@
color: var(--text-tertiary);
}
.stat-value {
font-family: var(--serif); font-size: 2.5rem; line-height: 1;
font-family: var(--serif); font-size: 2rem; line-height: 1.1;
font-weight: 700; letter-spacing: -0.02em;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
@@ -634,11 +636,11 @@
border-bottom: 1px solid var(--border-secondary);
}
.panel-title {
font-family: var(--serif); font-size: 1.75rem;
margin: 0; font-weight: 400; line-height: 1;
letter-spacing: 0;
font-family: var(--serif); font-size: 1.35rem;
margin: 0; font-weight: 600; line-height: 1.2;
letter-spacing: -0.01em;
}
.title-accent { color: var(--accent); font-style: italic; }
.title-accent { color: var(--accent); font-weight: 700; }
.panel-count {
font-family: var(--mono); font-size: 0.66rem;
letter-spacing: 0.12em; color: var(--text-tertiary);
+39 -42
View File
@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import * as api from '$lib/api';
import { IconArrowLeft } from '$lib/components/icons';
import { t } from '$lib/i18n';
let name = $state('');
let description = $state('');
@@ -38,7 +39,7 @@
async function submit(e: Event) {
e.preventDefault();
if (!name.trim() || !yaml.trim()) {
error = 'Name and compose YAML are required.';
error = $t('stacks.new.errorRequired');
return;
}
submitting = true; error = '';
@@ -51,7 +52,7 @@
});
await goto(`/stacks/${stack.id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create stack';
error = e instanceof Error ? e.message : $t('stacks.new.errorCreate');
} finally {
submitting = false;
}
@@ -75,23 +76,18 @@
<a href="/stacks" class="back">
<IconArrowLeft size={13} />
<span>STACKS</span>
<span>{$t('stacks.new.back').toUpperCase()}</span>
</a>
<header class="head">
<span class="eyebrow">
<span class="ember"></span>
<span>THE FORGE</span>
<span>{$t('stacks.eyebrow')}</span>
<span class="sep">//</span>
<span>NEW BLUEPRINT</span>
<span>{$t('stacks.new.eyebrow')}</span>
</span>
<h1 class="display">
Forge a<br/>new <em>stack</em>.
</h1>
<p class="lede">
Upload or paste a <code>docker-compose.yml</code>. All services in the blueprint
deploy as a single atomic unit.
</p>
<h1 class="display">{$t('stacks.new.title')}</h1>
<p class="lede">{@html $t('stacks.new.lede')}</p>
</header>
<form onsubmit={submit} class="form">
@@ -101,37 +97,37 @@
<span class="reg reg-br" aria-hidden="true"></span>
{#if error}
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
<div class="alert"><span class="alert-tag">{$t('stacks.detail.err')}</span><span>{error}</span></div>
{/if}
<div class="field">
<label for="stack-name" class="field-label">
<span class="num">01</span>
<span class="lbl">Name</span>
<span class="req">required</span>
<span class="lbl">{$t('stacks.new.name')}</span>
<span class="req">{$t('stacks.new.required')}</span>
</label>
<input
id="stack-name"
type="text"
bind:value={name}
required
placeholder="my-app-stack"
placeholder={$t('stacks.new.namePlaceholder')}
class="input"
/>
<p class="hint">Lowercase, hyphenated. Used as the compose project name.</p>
<p class="hint">{$t('stacks.new.nameHint')}</p>
</div>
<div class="field">
<label for="stack-desc" class="field-label">
<span class="num">02</span>
<span class="lbl">Description</span>
<span class="opt">optional</span>
<span class="lbl">{$t('stacks.new.description')}</span>
<span class="opt">{$t('stacks.new.optional')}</span>
</label>
<input
id="stack-desc"
type="text"
bind:value={description}
placeholder="What does this stack do?"
placeholder={$t('stacks.new.descriptionPlaceholder')}
class="input"
/>
</div>
@@ -139,11 +135,11 @@
<div class="field">
<div class="field-label">
<span class="num">03</span>
<span class="lbl">Compose YAML</span>
<span class="req">required</span>
<span class="lbl">{$t('stacks.new.composeYaml')}</span>
<span class="req">{$t('stacks.new.required')}</span>
<span class="spacer"></span>
<button type="button" class="chip" onclick={loadSample}>Load sample</button>
<button type="button" class="chip" onclick={() => fileInput?.click()}>Upload file</button>
<button type="button" class="chip" onclick={loadSample}>{$t('stacks.new.loadSample')}</button>
<button type="button" class="chip" onclick={() => fileInput?.click()}>{$t('stacks.new.uploadFile')}</button>
<input
bind:this={fileInput}
type="file"
@@ -164,8 +160,8 @@
onclick={() => fileInput?.click()}
>
<div class="dz-icon"></div>
<div class="dz-title">Drop a <em>docker-compose.yml</em> here</div>
<div class="dz-sub">or click to browse · or use <strong>Load sample</strong> above</div>
<div class="dz-title">{$t('stacks.new.dropHere')}</div>
<div class="dz-sub">{@html $t('stacks.new.dropSub')}</div>
</button>
{/if}
@@ -186,12 +182,12 @@
></textarea>
</div>
<div class="editor-foot">
<span>{lineCount} lines</span>
<span>{$t('stacks.new.lines', { n: String(lineCount) })}</span>
<span class="sep">·</span>
<span>{byteCount} bytes</span>
<span>{$t('stacks.new.bytes', { n: String(byteCount) })}</span>
<span class="sep">·</span>
<span>YAML</span>
<button type="button" class="clear-btn" onclick={() => (yaml = '')}>Clear</button>
<button type="button" class="clear-btn" onclick={() => (yaml = '')}>{$t('stacks.new.clear')}</button>
</div>
</div>
</div>
@@ -200,19 +196,19 @@
<input type="checkbox" bind:checked={deployNow} />
<span class="toggle-box"></span>
<span class="toggle-text">
<strong>Deploy immediately</strong>
<span class="dim">Strike while the iron's hot. If unchecked, the stack is saved cold.</span>
<strong>{$t('stacks.new.deployImmediate')}</strong>
<span class="dim">{$t('stacks.new.deployHint')}</span>
</span>
</label>
<div class="actions">
<a href="/stacks" class="btn-ghost">Cancel</a>
<a href="/stacks" class="btn-ghost">{$t('stacks.new.cancel')}</a>
<button
type="submit"
disabled={submitting}
class="btn-primary"
>
<span>{submitting ? 'Forging' : deployNow ? 'Forge & deploy' : 'Save blueprint'}</span>
<span>{submitting ? $t('stacks.new.forging') : deployNow ? $t('stacks.new.forgeAndDeploy') : $t('stacks.new.saveBlueprint')}</span>
<span class="arrow"></span>
</button>
</div>
@@ -221,7 +217,7 @@
<style>
.forge {
--serif: 'Instrument Serif', 'Iowan Old Style', Georgia, serif;
--serif: var(--font-family-sans);
--mono: var(--font-family-mono);
--accent: var(--color-brand-600);
--accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
@@ -281,14 +277,15 @@
.display {
font-family: var(--serif);
font-size: clamp(3rem, 7vw, 4.75rem);
font-weight: 400; line-height: 1.05;
letter-spacing: 0;
font-size: clamp(1.875rem, 4vw, 2.5rem);
font-weight: 700; line-height: 1.1;
letter-spacing: -0.02em;
margin: 0;
}
.display em {
.display :global(em) {
color: var(--accent);
font-style: italic;
font-style: normal;
font-weight: 700;
}
.lede {
font-family: var(--serif);
@@ -298,7 +295,7 @@
font-size: 1.15rem;
line-height: 1.45;
}
.lede code {
.lede :global(code) {
font-family: var(--mono);
font-size: 0.85em;
padding: 0.1rem 0.4rem;
@@ -443,13 +440,13 @@
font-family: var(--serif); font-size: 1.5rem;
color: var(--text-primary);
}
.dz-title em { color: var(--accent); font-style: italic; }
.dz-title :global(em) { color: var(--accent); font-style: italic; }
.dz-sub {
font-family: var(--mono);
font-size: 0.72rem; letter-spacing: 0.06em;
color: var(--text-tertiary);
}
.dz-sub strong { color: var(--text-secondary); font-weight: 600; }
.dz-sub :global(strong) { color: var(--text-secondary); font-weight: 600; }
/* ── Editor ────────────────────────────────────── */
.editor {