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
+14
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@fontsource/instrument-serif": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8"
},
"devDependencies": {
@@ -447,6 +448,14 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
@@ -2300,6 +2309,11 @@
"resolved": "https://registry.npmjs.org/@fontsource/instrument-serif/-/instrument-serif-5.2.8.tgz",
"integrity": "sha512-s+bkz+syj2rO00Rmq9g0P+PwuLig33DR1xDR8pTWmovH1pUjwnncrFk++q9mmOex8fUQ7oW80gPpPDaw7V1MMw=="
},
"@fontsource/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="
},
"@fontsource/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
+1
View File
@@ -22,6 +22,7 @@
"type": "module",
"dependencies": {
"@fontsource/instrument-serif": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8"
}
}
+417 -2
View File
@@ -1,7 +1,9 @@
@import 'tailwindcss';
@import '$lib/styles/tokens.css';
@import '@fontsource/instrument-serif/400.css';
@import '@fontsource/instrument-serif/400-italic.css';
@import '@fontsource/inter/400.css';
@import '@fontsource/inter/500.css';
@import '@fontsource/inter/600.css';
@import '@fontsource/inter/700.css';
@import '@fontsource/jetbrains-mono/400.css';
@import '@fontsource/jetbrains-mono/500.css';
@import '@fontsource/jetbrains-mono/700.css';
@@ -69,3 +71,416 @@ input[type="number"] {
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
/* ═══════════════════════════════════════════════════════════════════
FORGE DESIGN SYSTEM — global utilities
═══════════════════════════════════════════════════════════════════ */
:root {
/* Display font now uses the app's standard sans stack.
--forge-serif is kept as an alias so all existing usages switch with one edit. */
--forge-serif: var(--font-family-sans);
--forge-display: var(--font-family-sans);
--forge-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', monospace;
--forge-accent: var(--color-brand-600);
--forge-accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
--forge-glow: color-mix(in srgb, var(--color-brand-500) 32%, transparent);
}
/* ── Dot-grid backdrop ─────────────────────────────────────────── */
.forge-backdrop {
position: relative;
isolation: isolate;
}
.forge-backdrop::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.8;
}
/* ── Eyebrow label (mono caps) ─────────────────────────────────── */
.forge-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.55rem;
font-family: var(--forge-mono);
font-size: 0.7rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.forge-eyebrow .sep { opacity: 0.5; }
/* ── Ember (breathing accent dot) ──────────────────────────────── */
.forge-ember {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--forge-accent);
box-shadow: 0 0 0 3px var(--forge-accent-soft);
animation: forge-breathe 2.4s ease-in-out infinite;
}
@keyframes forge-breathe {
0%, 100% { box-shadow: 0 0 0 3px var(--forge-accent-soft); }
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 20%, transparent); }
}
@keyframes forge-blink {
0%, 60%, 100% { opacity: 1; }
70%, 90% { opacity: 0.3; }
}
/* ── Display heading ──────────────────────────────────────────── */
.forge-display {
font-family: var(--forge-display);
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.02em;
margin: 0;
color: var(--text-primary);
}
.forge-display .accent { color: var(--forge-accent); font-weight: 700; }
.forge-display em { color: var(--forge-accent); font-style: normal; font-weight: 700; }
/* ── Lede paragraph ───────────────────────────────────────────── */
.forge-lede {
font-family: var(--font-family-sans);
color: var(--text-secondary);
font-size: 1rem;
line-height: 1.55;
max-width: 62ch;
margin: 0.75rem 0 0;
}
.forge-lede em { color: var(--forge-accent); font-style: normal; font-weight: 500; }
.forge-lede code {
font-family: var(--forge-mono);
font-size: 0.8em;
padding: 0.1rem 0.4rem;
background: var(--surface-card-hover);
border-radius: var(--radius-sm);
color: var(--text-primary);
}
/* ── Status pill with pulse ────────────────────────────────────── */
.forge-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0.55rem;
border-radius: var(--radius-full);
background: var(--surface-card-hover);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
}
.forge-pill .pulse {
width: 6px; height: 6px; border-radius: 50%;
background: var(--text-tertiary);
}
.forge-pill.is-running { background: var(--color-success-light); color: var(--color-success-dark); }
.forge-pill.is-running .pulse { background: var(--color-success); animation: forge-blink 1.8s infinite; }
.forge-pill.is-info { background: var(--color-info-light); color: var(--color-info-dark); }
.forge-pill.is-info .pulse { background: var(--color-info); animation: forge-blink 0.8s infinite; }
.forge-pill.is-warn { background: var(--color-warning-light); color: var(--color-warning-dark); }
.forge-pill.is-warn .pulse { background: var(--color-warning); animation: forge-blink 0.9s infinite; }
.forge-pill.is-fail { background: var(--color-danger-light); color: var(--color-danger-dark); }
.forge-pill.is-fail .pulse { background: var(--color-danger); animation: forge-blink 0.5s infinite; }
[data-theme='dark'] .forge-pill.is-running { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: #86efac; }
[data-theme='dark'] .forge-pill.is-info { background: color-mix(in srgb, var(--color-info) 16%, transparent); color: #93c5fd; }
[data-theme='dark'] .forge-pill.is-warn { background: color-mix(in srgb, var(--color-warning) 16%, transparent); color: #fcd34d; }
[data-theme='dark'] .forge-pill.is-fail { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: #fca5a5; }
/* ── Buttons ───────────────────────────────────────────────────── */
.forge-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
background: var(--text-primary);
color: var(--surface-card);
border: 0;
border-radius: var(--radius-lg);
font-family: var(--forge-mono);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
text-decoration: none;
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease;
box-shadow: 0 0 0 0 var(--forge-glow);
}
.forge-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 0 0 4px var(--forge-glow);
}
.forge-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.forge-btn-ghost {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.5rem 0.85rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
color: var(--text-secondary);
font-family: var(--forge-mono);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
text-decoration: none;
cursor: pointer;
transition: all 150ms ease;
}
.forge-btn-ghost:hover:not(:disabled) {
background: var(--surface-card-hover);
color: var(--text-primary);
border-color: var(--color-brand-300);
}
.forge-btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
.forge-btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
color: var(--text-secondary);
cursor: pointer;
transition: all 150ms ease;
}
.forge-btn-icon:hover:not(:disabled) {
background: var(--surface-card-hover);
color: var(--text-primary);
border-color: var(--color-brand-300);
}
.forge-btn-icon:disabled { opacity: 0.5; cursor: not-allowed; }
.forge-btn-danger { color: var(--color-danger); border-color: var(--border-primary); }
.forge-btn-danger:hover:not(:disabled) {
background: var(--color-danger-light);
border-color: var(--color-danger);
color: var(--color-danger-dark);
}
[data-theme='dark'] .forge-btn-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: #fca5a5;
}
/* ── Panel / Card with registration marks ──────────────────────── */
.forge-panel {
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
}
.forge-panel-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
padding: 1rem 1.35rem 0.85rem;
border-bottom: 1px solid var(--border-secondary);
}
.forge-panel-title {
font-family: var(--forge-display);
font-size: 1.35rem;
margin: 0;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.forge-panel-title .accent { color: var(--forge-accent); font-weight: 600; }
.forge-panel-body { padding: 1.15rem 1.35rem 1.35rem; }
/* ── Stat tile ─────────────────────────────────────────────────── */
.forge-stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
overflow: hidden;
background: var(--surface-card);
}
.forge-stat {
padding: 1rem 1.15rem;
border-right: 1px solid var(--border-secondary);
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.forge-stat:last-child { border-right: 0; }
.forge-stat-label {
font-family: var(--forge-mono);
font-size: 0.62rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.forge-stat-value {
font-family: var(--forge-display);
font-size: 2.25rem;
line-height: 1.1;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.forge-stat-value.accent { color: var(--forge-accent); }
.forge-stat-value.warn { color: var(--color-warning-dark); }
.forge-stat-value.fail { color: var(--color-danger); }
.forge-stat-sub {
font-family: var(--forge-mono);
font-size: 0.66rem;
color: var(--text-tertiary);
}
/* ── Registration corner marks (reveal on hover) ───────────────── */
.forge-regs {
position: relative;
}
.forge-regs::before,
.forge-regs::after,
.forge-regs > .reg-bl,
.forge-regs > .reg-br {
content: '';
position: absolute;
width: 8px; height: 8px;
border-color: var(--color-brand-500);
border-style: solid;
border-width: 0;
opacity: 0;
transition: opacity 180ms ease;
pointer-events: none;
}
.forge-regs::before { top: -1px; left: -1px; border-top-width: 2px; border-left-width: 2px; }
.forge-regs::after { top: -1px; right: -1px; border-top-width: 2px; border-right-width: 2px; }
.forge-regs:hover::before,
.forge-regs:hover::after { opacity: 1; }
/* ── Alert ─────────────────────────────────────────────────────── */
.forge-alert {
display: flex;
gap: 0.7rem;
align-items: center;
padding: 0.7rem 0.9rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.forge-alert-tag {
font-family: var(--forge-mono);
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 0.16em;
padding: 0.15rem 0.4rem;
background: var(--color-danger);
color: #fff;
border-radius: var(--radius-sm);
}
[data-theme='dark'] .forge-alert {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: #fca5a5;
}
/* ── Terminal frame ────────────────────────────────────────────── */
.forge-terminal {
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: #0b1020;
overflow: hidden;
}
[data-theme='dark'] .forge-terminal { background: #05070f; }
.forge-terminal-head {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.9rem;
background: #141a2e;
border-bottom: 1px solid #0a0e1c;
}
[data-theme='dark'] .forge-terminal-head { background: #0a0e1c; }
.forge-terminal-head .dot {
width: 9px; height: 9px; border-radius: 50%;
background: rgba(255,255,255,0.12);
}
.forge-terminal-head .dot:nth-child(1) { background: color-mix(in srgb, var(--color-danger) 70%, black); }
.forge-terminal-head .dot:nth-child(2) { background: color-mix(in srgb, var(--color-warning) 70%, black); }
.forge-terminal-head .dot:nth-child(3) { background: color-mix(in srgb, var(--color-success) 70%, black); }
.forge-terminal-head .title {
margin-left: 0.6rem;
font-family: var(--forge-mono);
font-size: 0.7rem;
color: rgba(255,255,255,0.45);
}
.forge-terminal-body {
margin: 0;
padding: 1rem 1.1rem;
font-family: var(--forge-mono);
font-size: 0.76rem;
line-height: 1.55;
color: #c7d0e0;
white-space: pre-wrap;
word-break: break-all;
max-height: 480px;
overflow: auto;
}
/* ── Native heading tune-up ────────────────────────────────────── */
h1.forge, h2.forge, h3.forge {
font-family: var(--forge-display);
font-weight: 700;
letter-spacing: -0.01em;
}
/* ── Table refresh ─────────────────────────────────────────────── */
.forge-table {
width: 100%;
border-collapse: collapse;
}
.forge-table thead th {
font-family: var(--forge-mono);
font-size: 0.62rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-tertiary);
font-weight: 600;
text-align: left;
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--border-primary);
background: var(--surface-card-hover);
}
.forge-table tbody td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-secondary);
font-size: 0.88rem;
color: var(--text-primary);
}
.forge-table tbody tr:last-child td { border-bottom: 0; }
.forge-table tbody tr:hover { background: var(--surface-card-hover); }
/* ── Spin & fade ───────────────────────────────────────────────── */
@keyframes forge-spin { to { transform: rotate(360deg); } }
.forge-spinner {
width: 12px; height: 12px;
border: 2px solid var(--text-tertiary);
border-right-color: transparent;
border-radius: 50%;
animation: forge-spin 0.9s linear infinite;
}
+85 -16
View File
@@ -25,12 +25,6 @@
oncancel
}: Props = $props();
const confirmClass = $derived(
confirmVariant === 'danger'
? 'bg-[var(--color-danger)] hover:bg-[var(--color-danger-dark)] focus-visible:outline-[var(--color-danger)]'
: 'bg-[var(--color-brand-600)] hover:bg-[var(--color-brand-700)] focus-visible:outline-[var(--color-brand-600)]'
);
const iconBgClass = $derived(
confirmVariant === 'danger'
? 'bg-[var(--color-danger-light)]'
@@ -50,28 +44,29 @@
<!-- Dialog -->
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="w-full max-w-md rounded-2xl bg-[var(--surface-card)] p-6 shadow-xl animate-scale-in">
<div class="dlg w-full max-w-md animate-scale-in">
<span class="dlg-reg dlg-reg-tl"></span>
<span class="dlg-reg dlg-reg-tr"></span>
<span class="dlg-reg dlg-reg-bl"></span>
<span class="dlg-reg dlg-reg-br"></span>
<div class="flex items-start gap-4">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full {iconBgClass}">
<IconAlert size={20} class={iconColorClass} />
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-[var(--text-primary)]">{title}</h3>
<p class="mt-2 text-sm text-[var(--text-secondary)] leading-relaxed">{message}</p>
<h3 class="dlg-title">{title}</h3>
<p class="dlg-msg">{message}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="rounded-lg px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors active:animate-press"
onclick={oncancel}
>
<div class="mt-6 flex justify-end gap-2">
<button type="button" class="forge-btn-ghost" onclick={oncancel}>
{$t('common.cancel')}
</button>
<button
type="button"
class="rounded-lg px-4 py-2 text-sm font-medium text-white {confirmClass} shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 active:animate-press"
class="dlg-confirm {confirmVariant}"
onclick={onconfirm}
>
{confirmLabel}
@@ -80,3 +75,77 @@
</div>
</div>
{/if}
<style>
.dlg {
position: relative;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-2xl);
padding: 1.5rem;
box-shadow: var(--shadow-md);
}
.dlg-reg {
position: absolute;
width: 10px; height: 10px;
border-color: var(--color-brand-500);
border-style: solid;
border-width: 0;
pointer-events: none;
}
.dlg-reg-tl { top: -1px; left: -1px; border-top-width: 2px; border-left-width: 2px; border-top-left-radius: var(--radius-2xl); }
.dlg-reg-tr { top: -1px; right: -1px; border-top-width: 2px; border-right-width: 2px; border-top-right-radius: var(--radius-2xl); }
.dlg-reg-bl { bottom: -1px; left: -1px; border-bottom-width: 2px; border-left-width: 2px; border-bottom-left-radius: var(--radius-2xl); }
.dlg-reg-br { bottom: -1px; right: -1px; border-bottom-width: 2px; border-right-width: 2px; border-bottom-right-radius: var(--radius-2xl); }
.dlg-title {
font-family: var(--font-family-sans);
font-weight: 700;
font-size: 1.15rem;
letter-spacing: -0.01em;
line-height: 1.25;
color: var(--text-primary);
margin: 0;
}
.dlg-msg {
font-size: 0.9rem;
color: var(--text-secondary);
line-height: 1.5;
margin: 0.5rem 0 0;
}
.dlg-confirm {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 1rem;
border: 0;
border-radius: var(--radius-lg);
font-family: var(--forge-mono);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #fff;
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease;
}
.dlg-confirm.primary {
background: var(--color-brand-600);
box-shadow: 0 0 0 0 var(--forge-glow);
}
.dlg-confirm.primary:hover {
background: var(--color-brand-700);
transform: translateY(-1px);
box-shadow: 0 0 0 4px var(--forge-glow);
}
.dlg-confirm.danger {
background: var(--color-danger);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-danger) 30%, transparent);
}
.dlg-confirm.danger:hover {
background: var(--color-danger-dark);
transform: translateY(-1px);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-danger) 30%, transparent);
}
</style>
+56 -48
View File
@@ -1,6 +1,3 @@
<!--
Task 9: Empty state component with SVG illustration and call-to-action.
-->
<script lang="ts">
interface Props {
title: string;
@@ -17,69 +14,80 @@
actionLabel = '',
actionHref = '',
onaction,
icon = 'projects'
}: Props = $props();
</script>
<div class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[var(--border-primary)] px-6 py-16 text-center animate-fade-in">
<!-- SVG Illustration -->
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-[var(--color-brand-50)]">
{#if icon === 'projects'}
<svg class="h-8 w-8 text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
<path d="M12 10v6" /><path d="M9 13h6" />
</svg>
{:else if icon === 'instances'}
<svg class="h-8 w-8 text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" /><rect width="20" height="8" x="2" y="14" rx="2" ry="2" /><line x1="6" x2="6.01" y1="6" y2="6" /><line x1="6" x2="6.01" y1="18" y2="18" />
</svg>
{:else if icon === 'deploys'}
<svg class="h-8 w-8 text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M15.59 14.37a6 6 0 0 1-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 0 0 6.16-12.12A14.98 14.98 0 0 0 9.631 8.41m5.96 5.96a14.926 14.926 0 0 1-5.841 2.58M16.5 9a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z" />
</svg>
{:else if icon === 'registries'}
<svg class="h-8 w-8 text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v14a9 3 0 0 0 18 0V5" /><path d="M3 12a9 3 0 0 0 18 0" />
</svg>
{:else if icon === 'volumes'}
<svg class="h-8 w-8 text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" x2="2" y1="12" y2="12" /><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</svg>
{:else if icon === 'users'}
<svg class="h-8 w-8 text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
{/if}
<div class="empty">
<div class="empty-mark">
<span></span><span></span><span></span>
</div>
<h3 class="text-base font-semibold text-[var(--text-primary)]">{title}</h3>
<h3 class="empty-title">{title}</h3>
{#if description}
<p class="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">{description}</p>
<p class="empty-desc">{description}</p>
{/if}
{#if actionLabel}
{#if actionHref}
<a
href={actionHref}
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] active:animate-press"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<a href={actionHref} class="forge-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="M12 5v14" />
</svg>
{actionLabel}
<span>{actionLabel}</span>
</a>
{:else if onaction}
<button
type="button"
onclick={onaction}
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] active:animate-press"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<button type="button" onclick={onaction} class="forge-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="M12 5v14" />
</svg>
{actionLabel}
<span>{actionLabel}</span>
</button>
{/if}
{/if}
</div>
<style>
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
text-align: center;
padding: 4rem 2rem;
border: 1px dashed var(--border-primary);
border-radius: var(--radius-2xl);
background: var(--surface-card);
}
.empty-mark {
display: inline-flex; gap: 4px;
margin-bottom: 1.5rem;
}
.empty-mark span {
width: 10px; height: 10px; border-radius: 50%;
background: var(--border-input);
}
.empty-mark span:nth-child(2) {
background: var(--color-brand-600);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand-500) 18%, transparent);
animation: forge-breathe 2.4s ease-in-out infinite;
}
.empty-title {
font-family: var(--font-family-sans);
font-weight: 700;
font-size: 1.5rem;
letter-spacing: -0.01em;
line-height: 1.2;
margin: 0 0 0.4rem;
color: var(--text-primary);
}
.empty-desc {
color: var(--text-secondary);
max-width: 42ch;
font-size: 0.95rem;
line-height: 1.5;
margin: 0 0 1.5rem;
}
.forge-btn { margin-top: 0.5rem; }
</style>
+72
View File
@@ -0,0 +1,72 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
eyebrow?: string;
eyebrowSuffix?: string;
title: string;
accent?: string;
lede?: string;
lede_html?: Snippet;
toolbar?: Snippet;
size?: 'md' | 'lg' | 'xl';
}
const {
eyebrow = 'THE FORGE',
eyebrowSuffix,
title,
accent = '.',
lede,
lede_html,
toolbar,
size = 'lg'
}: Props = $props();
</script>
<header class="hero">
<div class="top">
<span class="forge-eyebrow">
<span class="forge-ember"></span>
<span>{eyebrow}</span>
{#if eyebrowSuffix}
<span class="sep">//</span>
<span>{eyebrowSuffix}</span>
{/if}
</span>
{#if toolbar}
<div class="toolbar">{@render toolbar()}</div>
{/if}
</div>
<h1 class="forge-display" class:s-md={size === 'md'} class:s-lg={size === 'lg'} class:s-xl={size === 'xl'}>
{title}{#if accent}<span class="accent">{accent}</span>{/if}
</h1>
{#if lede_html}
<p class="forge-lede">{@render lede_html()}</p>
{:else if lede}
<p class="forge-lede">{lede}</p>
{/if}
</header>
<style>
.hero { margin-bottom: 2rem; }
.top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1.25rem;
flex-wrap: wrap;
}
.toolbar { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.forge-display.s-md { font-size: clamp(1.75rem, 3.5vw, 2.25rem); }
.forge-display.s-lg { font-size: clamp(2rem, 4vw, 2.75rem); }
.forge-display.s-xl { font-size: clamp(2.5rem, 5vw, 3.5rem); }
@media (max-width: 640px) {
.top { align-items: flex-start; }
}
</style>
+25 -29
View File
@@ -1,6 +1,3 @@
<!--
Task 5, 11: Status badge with pulse animation for "running" status.
-->
<script lang="ts">
import type { InstanceStatus, DeployStatus } from '$lib/types';
@@ -13,35 +10,34 @@
const { status, size = 'md' }: Props = $props();
const colorMap: Record<string, { bg: string; text: string; dot: string }> = {
running: { bg: 'bg-emerald-50 dark:bg-emerald-950', text: 'text-emerald-700 dark:text-emerald-300', dot: 'bg-emerald-500' },
success: { bg: 'bg-emerald-50 dark:bg-emerald-950', text: 'text-emerald-700 dark:text-emerald-300', dot: 'bg-emerald-500' },
stopped: { bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-700 dark:text-gray-300', dot: 'bg-gray-400' },
failed: { bg: 'bg-red-50 dark:bg-red-950', text: 'text-red-700 dark:text-red-300', dot: 'bg-red-500' },
rolled_back: { bg: 'bg-red-50 dark:bg-red-950', text: 'text-red-700 dark:text-red-300', dot: 'bg-red-500' },
removing: { bg: 'bg-amber-50 dark:bg-amber-950', text: 'text-amber-700 dark:text-amber-300', dot: 'bg-amber-500' },
pending: { bg: 'bg-blue-50 dark:bg-blue-950', text: 'text-blue-700 dark:text-blue-300', dot: 'bg-blue-500' },
pulling: { bg: 'bg-blue-50 dark:bg-blue-950', text: 'text-blue-700 dark:text-blue-300', dot: 'bg-blue-500' },
starting: { bg: 'bg-amber-50 dark:bg-amber-950', text: 'text-amber-700 dark:text-amber-300', dot: 'bg-amber-500' },
configuring_proxy: { bg: 'bg-amber-50 dark:bg-amber-950', text: 'text-amber-700 dark:text-amber-300', dot: 'bg-amber-500' },
health_checking: { bg: 'bg-violet-50 dark:bg-violet-950', text: 'text-violet-700 dark:text-violet-300', dot: 'bg-violet-500' }
const variantMap: Record<string, string> = {
running: 'is-running',
success: 'is-running',
stopped: '',
failed: 'is-fail',
rolled_back: 'is-fail',
removing: 'is-warn',
pending: 'is-info',
pulling: 'is-info',
starting: 'is-warn',
configuring_proxy: 'is-warn',
health_checking: 'is-info',
deploying: 'is-info'
};
const fallback = { bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-700 dark:text-gray-300', dot: 'bg-gray-400' };
const colors = $derived(colorMap[status] ?? fallback);
const sizeClass = $derived(size === 'sm' ? 'text-xs px-2 py-0.5' : 'text-sm px-2.5 py-0.5');
const dotSize = $derived(size === 'sm' ? 'h-1.5 w-1.5' : 'h-2 w-2');
const label = $derived(status.replace(/_/g, ' '));
const isAnimated = $derived(status === 'running' || status === 'pulling' || status === 'starting' || status === 'health_checking');
const variant = $derived(variantMap[status] ?? '');
const label = $derived(status.replace(/_/g, ' ').toUpperCase());
</script>
<span class="inline-flex items-center gap-1.5 rounded-full font-medium {colors.bg} {colors.text} {sizeClass}">
<span class="relative flex {dotSize}">
{#if isAnimated}
<span class="absolute inline-flex h-full w-full animate-ping rounded-full {colors.dot} opacity-50"></span>
{/if}
<span class="relative inline-flex rounded-full {dotSize} {colors.dot}"></span>
</span>
<span class="forge-pill {variant}" class:sm={size === 'sm'}>
<span class="pulse"></span>
{label}
</span>
<style>
.forge-pill.sm {
padding: 0.15rem 0.45rem;
font-size: 0.58rem;
}
.forge-pill.sm .pulse { width: 5px; height: 5px; }
</style>
+122
View File
@@ -890,5 +890,127 @@
"language": {
"en": "English",
"ru": "Russian"
},
"stacks": {
"eyebrow": "THE FORGE",
"title": "Stacks",
"lede": "Compose blueprints, forged as <em>atomic units</em>. Spin up services, iterate on revisions, roll back without breaking a sweat.",
"newStack": "New stack",
"refresh": "Refresh",
"total": "Total",
"running": "Running",
"deploying": "Forging",
"failed": "Failed",
"stopped": "Cold",
"empty": {
"title": "The anvil is cold.",
"desc": "Upload a docker-compose.yml to forge your first stack."
},
"card": {
"noDescription": "No description",
"updated": "Updated",
"start": "Start",
"stop": "Stop",
"delete": "Delete",
"open": "Open"
},
"new": {
"eyebrow": "NEW BLUEPRINT",
"title": "Forge a new stack.",
"lede": "Upload or paste a <code>docker-compose.yml</code>. All services in the blueprint deploy as a single atomic unit.",
"back": "Stacks",
"name": "Name",
"namePlaceholder": "my-app-stack",
"nameHint": "Lowercase, hyphenated. Used as the compose project name.",
"description": "Description",
"descriptionPlaceholder": "What does this stack do?",
"composeYaml": "Compose YAML",
"required": "required",
"optional": "optional",
"loadSample": "Load sample",
"uploadFile": "Upload file",
"dropHere": "Drop a docker-compose.yml here",
"dropSub": "or click to browse · or use <strong>Load sample</strong> above",
"lines": "{n} lines",
"bytes": "{n} bytes",
"clear": "Clear",
"deployImmediate": "Deploy immediately",
"deployHint": "Strike while the iron's hot. If unchecked, the stack is saved cold.",
"cancel": "Cancel",
"forging": "Forging…",
"forgeAndDeploy": "Forge & deploy",
"saveBlueprint": "Save blueprint",
"errorRequired": "Name and compose YAML are required.",
"errorCreate": "Failed to create stack"
},
"detail": {
"manifest": "MANIFEST",
"loading": "Loading blueprint…",
"composeProject": "COMPOSE PROJECT",
"noDescription": "No description",
"refresh": "Refresh",
"start": "Start",
"stop": "Stop",
"delete": "Delete",
"fault": "FAULT",
"err": "ERR",
"stats": {
"services": "Services",
"servicesSub": "in blueprint",
"running": "Running",
"runningSub": "active containers",
"revisions": "Revisions",
"revisionsSub": "in history",
"current": "Current",
"currentSub": "deployed"
},
"services": {
"title": "Services",
"count": "{n} on the floor",
"empty": "— no containers running —"
},
"tabs": {
"blueprint": "Blueprint",
"revisions": "Revisions",
"logs": "Logs"
},
"yaml": {
"currentRevision": "Current revision",
"edit": "Edit & redeploy",
"cancel": "Cancel",
"forging": "Forging…",
"deployNew": "Deploy new revision"
},
"revisions": {
"current": "CURRENT",
"by": "by",
"rollback": "← Rollback to this revision",
"rollbackTitle": "Rollback to revision?",
"rollbackMessage": "Create a new revision from rev {n} and redeploy the stack.",
"rollbackConfirm": "Rollback"
},
"logs": {
"service": "Service:",
"allServices": "All services",
"fetching": "Fetching…",
"fetch": "Fetch logs",
"empty": "— no logs loaded. tap fetch. —"
},
"delete": {
"title": "Delete stack?",
"messageBase": "This runs 'docker compose down' and removes \"{name}\".",
"messageVolumes": " Named volumes will also be removed.",
"confirm": "Delete"
},
"errors": {
"load": "Failed to load stack",
"stop": "Stop failed",
"start": "Start failed",
"update": "Update failed",
"rollback": "Rollback failed",
"delete": "Delete failed",
"fetchLogs": "Failed to load logs"
}
}
}
}
+122
View File
@@ -890,5 +890,127 @@
"language": {
"en": "Английский",
"ru": "Русский"
},
"stacks": {
"eyebrow": "КУЗНИЦА",
"title": "Стеки",
"lede": "Compose-чертежи, выкованные как <em>атомарные единицы</em>. Запускайте сервисы, меняйте ревизии и откатывайтесь без нервов.",
"newStack": "Новый стек",
"refresh": "Обновить",
"total": "Всего",
"running": "Работают",
"deploying": "Куются",
"failed": "Сбой",
"stopped": "Холодные",
"empty": {
"title": "Наковальня остыла.",
"desc": "Загрузите docker-compose.yml, чтобы выковать первый стек."
},
"card": {
"noDescription": "Без описания",
"updated": "Обновлён",
"start": "Запустить",
"stop": "Остановить",
"delete": "Удалить",
"open": "Открыть"
},
"new": {
"eyebrow": "НОВЫЙ ЧЕРТЁЖ",
"title": "Выковать новый стек.",
"lede": "Загрузите или вставьте <code>docker-compose.yml</code>. Все сервисы чертежа разворачиваются как одна атомарная единица.",
"back": "Стеки",
"name": "Имя",
"namePlaceholder": "мой-стек",
"nameHint": "Строчные буквы, через дефис. Используется как имя compose-проекта.",
"description": "Описание",
"descriptionPlaceholder": "Что делает этот стек?",
"composeYaml": "Compose YAML",
"required": "обязательно",
"optional": "необязательно",
"loadSample": "Загрузить пример",
"uploadFile": "Загрузить файл",
"dropHere": "Перетащите сюда docker-compose.yml",
"dropSub": "или нажмите для выбора · или используйте <strong>Загрузить пример</strong> выше",
"lines": "{n} строк",
"bytes": "{n} байт",
"clear": "Очистить",
"deployImmediate": "Развернуть сразу",
"deployHint": "Куй железо, пока горячо. Без галочки стек сохраняется холодным.",
"cancel": "Отмена",
"forging": "Куём…",
"forgeAndDeploy": "Выковать и развернуть",
"saveBlueprint": "Сохранить чертёж",
"errorRequired": "Имя и compose YAML обязательны.",
"errorCreate": "Не удалось создать стек"
},
"detail": {
"manifest": "МАНИФЕСТ",
"loading": "Загрузка чертежа…",
"composeProject": "COMPOSE-ПРОЕКТ",
"noDescription": "Без описания",
"refresh": "Обновить",
"start": "Запустить",
"stop": "Остановить",
"delete": "Удалить",
"fault": "СБОЙ",
"err": "ОШБ",
"stats": {
"services": "Сервисы",
"servicesSub": "в чертеже",
"running": "Работают",
"runningSub": "активных контейнеров",
"revisions": "Ревизии",
"revisionsSub": "в истории",
"current": "Текущая",
"currentSub": "развёрнута"
},
"services": {
"title": "Сервисы",
"count": "{n} в работе",
"empty": "— нет запущенных контейнеров —"
},
"tabs": {
"blueprint": "Чертёж",
"revisions": "Ревизии",
"logs": "Логи"
},
"yaml": {
"currentRevision": "Текущая ревизия",
"edit": "Править и развернуть",
"cancel": "Отмена",
"forging": "Куём…",
"deployNew": "Развернуть новую ревизию"
},
"revisions": {
"current": "ТЕКУЩАЯ",
"by": "автор",
"rollback": "← Откатиться к этой ревизии",
"rollbackTitle": "Откатить ревизию?",
"rollbackMessage": "Создать новую ревизию из rev {n} и развернуть стек заново.",
"rollbackConfirm": "Откатить"
},
"logs": {
"service": "Сервис:",
"allServices": "Все сервисы",
"fetching": "Загрузка…",
"fetch": "Получить логи",
"empty": "— логи не загружены. нажмите получить. —"
},
"delete": {
"title": "Удалить стек?",
"messageBase": "Будет выполнен 'docker compose down' и удалён \"{name}\".",
"messageVolumes": " Именованные тома также будут удалены.",
"confirm": "Удалить"
},
"errors": {
"load": "Не удалось загрузить стек",
"stop": "Остановка не удалась",
"start": "Запуск не удался",
"update": "Обновление не удалось",
"rollback": "Откат не удался",
"delete": "Удаление не удалось",
"fetchLogs": "Не удалось загрузить логи"
}
}
}
}
+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 {