<script lang="ts">
	import { onDestroy } from 'svelte';
	import { goto } from '$app/navigation';
	import { page } from '$app/stores';
	import { get } from 'svelte/store';
	import type { Container, EventLogEntry, PluginWorkloadInput, Workload } from '$lib/types';
	import type { RedeployTrigger, WorkloadTriggerBinding } from '$lib/api';
	import * as api from '$lib/api';
	import {
		IconRefresh,
		IconDeploy,
		IconTrash,
		IconEdit,
		IconCopy,
		IconCheck,
		IconChevronDown,
		IconServer,
		IconExternalLink,
		IconPlus,
		IconX,
		IconPlay,
		IconStop,
		IconGlobe,
		IconHardDrive,
		IconClock,
		IconLoader,
		IconLock
	} from '$lib/components/icons';
	import ForgeHero from '$lib/components/ForgeHero.svelte';
	import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
	import { toasts } from '$lib/stores/toast';
	import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
	import ContainerLogs from '$lib/components/ContainerLogs.svelte';
	import ContainerStats from '$lib/components/ContainerStats.svelte';
	import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
	import WorkloadNotificationsPanel from '$lib/components/WorkloadNotificationsPanel.svelte';
	import WorkloadSnapshotsPanel from '$lib/components/WorkloadSnapshotsPanel.svelte';
	import DeployHistoryPanel from '$lib/components/DeployHistoryPanel.svelte';
	import WorkloadMetricsPanel from '$lib/components/WorkloadMetricsPanel.svelte';
	import GitOpsPanel from '$lib/components/GitOpsPanel.svelte';
	import TriggerKindForm, {
		createTriggerKindFormState,
		isTriggerFormValid,
		buildTriggerInput
	} from '$lib/components/TriggerKindForm.svelte';
	import ImageSourceForm from '$lib/components/workload/ImageSourceForm.svelte';
	import ComposeSourceForm from '$lib/components/workload/ComposeSourceForm.svelte';
	import StaticSourceForm from '$lib/components/workload/StaticSourceForm.svelte';
	import DockerfileSourceForm from '$lib/components/workload/DockerfileSourceForm.svelte';
	import {
		emptyImageState,
		emptyComposeState,
		emptyStaticState,
		emptyDockerfileState,
		seedImageState,
		seedComposeState,
		seedStaticState,
		seedDockerfileState,
		imageToConfig,
		composeToConfig,
		staticToConfig,
		dockerfileToConfig,
		stringifyConfig,
		isDockerfileValid,
		type ImageFormState,
		type ComposeFormState,
		type StaticFormState,
		type DockerfileFormState
	} from '$lib/workload/sourceForms';
	import { t } from '$lib/i18n';
	import { fmt } from '$lib/format/datetime';
	import { formatBytes } from '$lib/format/bytes';
	import { connectGlobalEvents, toEventLogEntry, type SSEConnection } from '$lib/sse';
	import { workloadDetailCache } from '$lib/stores/caches';

	// Route params come back as `string | undefined`; the route file
	// guarantees `id` exists, but the empty-string fallback satisfies
	// the type checker — server validation rejects empty ids anyway.
	const id = $derived($page.params.id ?? '');

	// Warm-seed the workload from the per-id cache so the body — gated on
	// `loading && !workload` — skips the full-page skeleton on revisit (the
	// other sections populate when load() resolves). Seeding synchronously at
	// init (not just in the load effect) avoids even a one-frame skeleton on a
	// fresh mount. Edit-mode forms seed on-demand from `workload`, so there is
	// no on-load form state to clobber.
	const _seed = workloadDetailCache.peek(get(page).params.id ?? '').value;
	let workload = $state<Workload | null>(_seed?.workload ?? null);
	// Containers are seeded too (not just the workload) so the live-status badge
	// + Stop/Start toolbar reflect last-known state on a warm revisit instead of
	// momentarily reading the empty array as "not deployed".
	let containers = $state<Container[]>(_seed?.containers ?? []);
	let loading = $state(_seed == null);
	let error = $state('');
	let deploying = $state(false);
	let deployRef = $state('');
	let lastDeployMsg = $state('');

	// Edit-mode state. Mirrors the workload's source config as pretty-
	// printed JSON so the user edits the same shape /apps/new produced.
	// Trigger configuration moved out into the standalone Triggers
	// panel — workloads no longer carry an embedded trigger config.
	let editing = $state(false);
	let saving = $state(false);
	let editName = $state('');
	let editParentID = $state('');
	let editSourceConfig = $state('');
	let editPublicFaces = $state('');
	let confirmDelete = $state(false);
	let deleting = $state(false);

	// ── Source-config edit form state ──────────────────────────────────
	// One state object per source kind, modelled by the shared (pure,
	// unit-tested) `sourceForms.ts` module — the SAME model the /apps/new
	// wizard uses. The form bodies live in dedicated components under
	// $lib/components/workload/ that bind these objects; serialization to
	// the PUT body's `source_config` is done in saveEdit via the module's
	// serializers so the shape stays byte-identical to the legacy inline
	// helpers (key order + env/volumes/storage/unknown-key rules included).
	// The kind is LOCKED on edit, so only the object matching the
	// workload's source_kind is ever rendered/serialized.
	let editAdvancedJson = $state(false);
	let editImageState = $state<ImageFormState>(emptyImageState());
	let editComposeState = $state<ComposeFormState>(emptyComposeState());
	let editStaticState = $state<StaticFormState>(emptyStaticState());
	let editDockerfileState = $state<DockerfileFormState>(emptyDockerfileState());

	const useEditComposeForm = $derived(
		(workload?.source_kind ?? '') === 'compose' && !editAdvancedJson
	);
	const useEditImageForm = $derived(
		(workload?.source_kind ?? '') === 'image' && !editAdvancedJson
	);
	const useEditStaticForm = $derived(
		(workload?.source_kind ?? '') === 'static' && !editAdvancedJson
	);
	const useEditDockerfileForm = $derived(
		(workload?.source_kind ?? '') === 'dockerfile' && !editAdvancedJson
	);

	// Registry list — populated when the edit form opens, falls back to a
	// plain text input if the request fails. Bound into ImageSourceForm.
	let editRegistries = $state<{ name: string; url: string }[]>([]);

	// Source-config keys the repo's .tinyforge.yml declares for a
	// GitOps-managed workload. Fetched (fire-and-forget) when the edit form
	// opens on a gitops_enabled workload; drives the read-only gate banner that
	// warns these fields are overwritten on the next Sync. Empty = no gate.
	let gitopsManagedFields = $state<string[]>([]);
	// Viewer role drives whether the GitOps panel offers its admin affordances
	// (enable toggle, Sync). Fetched once; the server also enforces AdminOnly.
	let isAdmin = $state(false);
	let roleFetched = false;
	const editGitOpsGated = $derived(
		(workload?.gitops_enabled ?? false) && gitopsManagedFields.length > 0
	);

	// A cleared <input type="number"> binds to null (not 0) in Svelte 5, and
	// `null <= 0` is false — so a bare `port <= 0` check would pass validation
	// on an empty field and POST `port: null`. The module's isDockerfileValid
	// guards against null/NaN/non-positive identically to the legacy check.
	const editDockerfilePortValid = $derived(isDockerfileValid(editDockerfileState));

	// View-mode collapse state per config block. Trigger config is no
	// longer carried on the workload, so the trigger panel is gone —
	// only source + faces JSON viewers remain here.
	let openSource = $state(true);
	let openFaces = $state(true);

	// ── Trigger bindings ──────────────────────────────────────
	// Workloads no longer embed a trigger; instead they hold a list of
	// bindings to standalone Trigger records. The bindings panel
	// renders this list with per-row enable/unbind actions and an
	// "Add trigger" modal that supports inline-create or pick-existing.
	let bindings = $state<WorkloadTriggerBinding[]>([]);
	let bindingsLoading = $state(false);
	let bindingsError = $state('');
	let confirmUnbindId = $state<string | null>(null);

	// ── Per-binding override editor ──────────────────────────
	// Each binding can override fields of its parent trigger's
	// config via `binding_config` (top-level merge, binding wins).
	// State is keyed by binding id. The base trigger config is
	// lazy-fetched the first time a row's override panel is opened;
	// the editable JSON text mirrors the binding's current
	// `binding_config` and is round-tripped to the backend on save.
	const BINDING_CONFIG_MAX_BYTES = 8 * 1024;
	let openOverrideId = $state<string | null>(null);
	let overrideTexts = $state<Record<string, string>>({});
	let overrideBaseConfigs = $state<Record<string, string>>({});
	let overrideBaseLoading = $state<Record<string, boolean>>({});
	let overrideSaving = $state<Record<string, boolean>>({});
	let overrideErrors = $state<Record<string, string>>({});

	function formatJsonValue(v: unknown): string {
		try {
			return JSON.stringify(v ?? {}, null, 2);
		} catch {
			return '{}';
		}
	}

	function parseOverrideObject(text: string): { ok: true; value: Record<string, unknown> } | { ok: false } {
		const t = text.trim();
		if (!t) return { ok: true, value: {} };
		try {
			const parsed = JSON.parse(t);
			if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
				return { ok: false };
			}
			return { ok: true, value: parsed as Record<string, unknown> };
		} catch {
			return { ok: false };
		}
	}

	function overrideKeyCount(v: unknown): number {
		if (!v || typeof v !== 'object' || Array.isArray(v)) return 0;
		return Object.keys(v as Record<string, unknown>).length;
	}

	function byteLength(s: string): number {
		try {
			return new TextEncoder().encode(s).length;
		} catch {
			return s.length;
		}
	}

	function overrideJsonValid(text: string): boolean {
		const r = parseOverrideObject(text);
		return r.ok;
	}

	function overrideKeyCountFromText(text: string): number {
		const r = parseOverrideObject(text);
		return r.ok ? Object.keys(r.value).length : 0;
	}

	function overrideTextSize(text: string): number {
		const t = text.trim();
		if (!t) return 0;
		try {
			const parsed = JSON.parse(t);
			return byteLength(JSON.stringify(parsed));
		} catch {
			return byteLength(t);
		}
	}

	function mergedPreview(baseText: string, overrideText: string): string {
		let base: Record<string, unknown> = {};
		try {
			const parsed = JSON.parse(baseText || '{}');
			if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
				base = parsed as Record<string, unknown>;
			}
		} catch {
			// fall through with empty base
		}
		const ov = parseOverrideObject(overrideText);
		const merged = ov.ok ? { ...base, ...ov.value } : base;
		return formatJsonValue(merged);
	}

	async function toggleOverridePanel(b: WorkloadTriggerBinding): Promise<void> {
		if (openOverrideId === b.id) {
			openOverrideId = null;
			return;
		}
		openOverrideId = b.id;
		// Seed editable text with current binding_config (or "{}").
		if (!(b.id in overrideTexts)) {
			overrideTexts = {
				...overrideTexts,
				[b.id]: formatJsonValue(b.binding_config ?? {})
			};
		}
		// Lazy-fetch the trigger's base config the first time we open.
		if (!(b.trigger_id in overrideBaseConfigs) && !overrideBaseLoading[b.trigger_id]) {
			overrideBaseLoading = { ...overrideBaseLoading, [b.trigger_id]: true };
			try {
				const tr = await api.getTrigger(b.trigger_id);
				overrideBaseConfigs = {
					...overrideBaseConfigs,
					[b.trigger_id]: formatJsonValue(tr.config ?? {})
				};
			} catch (e) {
				overrideErrors = {
					...overrideErrors,
					[b.id]: e instanceof Error ? e.message : 'Failed to load trigger'
				};
			} finally {
				overrideBaseLoading = { ...overrideBaseLoading, [b.trigger_id]: false };
			}
		}
	}

	async function saveOverride(b: WorkloadTriggerBinding): Promise<void> {
		const text = overrideTexts[b.id] ?? '';
		const parsed = parseOverrideObject(text);
		if (!parsed.ok) {
			overrideErrors = {
				...overrideErrors,
				[b.id]: $t('apps.detail.bindings.override.errInvalidJson')
			};
			return;
		}
		if (overrideTextSize(text) > BINDING_CONFIG_MAX_BYTES) {
			overrideErrors = {
				...overrideErrors,
				[b.id]: $t('apps.detail.bindings.override.errTooLarge')
			};
			return;
		}
		overrideErrors = { ...overrideErrors, [b.id]: '' };
		overrideSaving = { ...overrideSaving, [b.id]: true };
		try {
			const updated = await api.updateBinding(b.id, { binding_config: parsed.value });
			bindings = bindings.map((x) =>
				x.id === b.id ? { ...x, binding_config: updated.binding_config } : x
			);
			// Re-pretty so the editor matches the canonical normalized form.
			overrideTexts = {
				...overrideTexts,
				[b.id]: formatJsonValue(updated.binding_config ?? {})
			};
		} catch (e) {
			overrideErrors = {
				...overrideErrors,
				[b.id]: e instanceof Error ? e.message : 'Save failed'
			};
		} finally {
			overrideSaving = { ...overrideSaving, [b.id]: false };
		}
	}

	async function resetOverride(b: WorkloadTriggerBinding): Promise<void> {
		overrideErrors = { ...overrideErrors, [b.id]: '' };
		overrideSaving = { ...overrideSaving, [b.id]: true };
		try {
			const updated = await api.updateBinding(b.id, { binding_config: {} });
			bindings = bindings.map((x) =>
				x.id === b.id ? { ...x, binding_config: updated.binding_config } : x
			);
			overrideTexts = {
				...overrideTexts,
				[b.id]: formatJsonValue(updated.binding_config ?? {})
			};
		} catch (e) {
			overrideErrors = {
				...overrideErrors,
				[b.id]: e instanceof Error ? e.message : 'Reset failed'
			};
		} finally {
			overrideSaving = { ...overrideSaving, [b.id]: false };
		}
	}

	// Add-binding modal state. The modal has two tabs (Inline / Pick)
	// and lazy-loads the existing-trigger list the first time the
	// "Pick" tab is opened.
	let addModalOpen = $state(false);
	let addModalTab = $state<'inline' | 'pick'>('inline');
	let addModalSubmitting = $state(false);
	let addModalError = $state('');
	let inlineTriggerForm = $state(createTriggerKindFormState({ kind: 'registry' }));
	let pickTriggerId = $state('');
	let pickKindFilter = $state('');
	let availableTriggers = $state<RedeployTrigger[]>([]);
	let availableTriggersLoaded = $state(false);

	// Filtered list for the picker, derived from the kind filter.
	const availableTriggersFiltered = $derived(
		pickKindFilter
			? availableTriggers.filter((t) => t.kind === pickKindFilter)
			: availableTriggers
	);

	// Tiny copy-button affordance — shows a check for 1.2s after success.
	let copied = $state<Record<string, boolean>>({});

	// ── Env vars ───────────────────────────────────────────────
	let envRows = $state<api.WorkloadEnv[]>([]);
	let envError = $state('');
	let envSaving = $state(false);
	let newEnvKey = $state('');
	let newEnvValue = $state('');
	let newEnvEncrypted = $state(true);

	// Workload-side webhook UI was removed in the hard legacy cutover —
	// inbound webhooks are now first-class Triggers. Use the bindings
	// panel + the /triggers detail page to manage the webhook URL.

	// ── Logs viewer ────────────────────────────────────────────
	let logContainerRowID = $state<string | null>(null);

	// ── Volumes ────────────────────────────────────────────────
	let volumeRows = $state<api.WorkloadVolume[]>([]);
	let volumeError = $state('');
	let volumeSaving = $state(false);
	let newVolSource = $state('');
	let newVolTarget = $state('');
	let newVolScope = $state('absolute');

	// ── Chain (parent / self / children) ──────────────────────
	let chain = $state<api.WorkloadChain | null>(null);
	let chainError = $state('');
	let promoting = $state<string | null>(null); // active targetID, for UI lock
	// Pending promotion awaiting confirmation. Promote copies the source's
	// running image tag onto the target and deploys it, so it's confirmed first.
	let confirmPromote = $state<{
		targetID: string;
		sourceID: string;
		targetName: string;
		sourceName: string;
	} | null>(null);

	// ── Branch preview environments ───────────────────────────
	// Preview children are the chain children the backend flagged
	// `is_preview` — per-branch deploys materialized from this workload
	// (the template) when a bound git trigger has a branch_pattern. The
	// chain DTO omits public_faces, so we lazy-fetch each preview child's
	// full workload to derive its slug-prefixed FQDN (reusing the same
	// deriveFaceFqdn helper the hero "open site" link uses).
	interface PreviewMeta {
		fqdn: string;
		state: string; // primary container state, '' when none/unknown
		loaded: boolean;
	}
	let previewMeta = $state<Record<string, PreviewMeta>>({});
	let confirmTeardownId = $state<string | null>(null); // preview child id pending teardown
	let tearingDown = $state(false);

	const previewChildren = $derived(
		chain ? chain.children.filter((c) => c.is_preview) : []
	);

	// Preview-armed detection: a workload is "armed" for previews when any
	// of its enabled git-trigger bindings carries a non-empty branch_pattern.
	// branch_pattern can live on the binding's override OR on the parent
	// trigger's config — the binding override wins (top-level merge), mirroring
	// how the backend resolves effective trigger config. triggersById is
	// loaded fire-and-forget on page load; if it hasn't arrived yet we fall
	// back to the binding override alone.
	function readBranchPattern(v: unknown): string {
		if (v && typeof v === 'object' && !Array.isArray(v)) {
			const bp = (v as Record<string, unknown>).branch_pattern;
			if (typeof bp === 'string') return bp.trim();
		}
		return '';
	}
	const armedBranchPattern = $derived.by(() => {
		for (const b of bindings) {
			if (!b.enabled || b.trigger_kind !== 'git') continue;
			const fromOverride = readBranchPattern(b.binding_config);
			if (fromOverride) return fromOverride;
			const fromTrigger = readBranchPattern(triggersById[b.trigger_id]?.config);
			if (fromTrigger) return fromTrigger;
		}
		return '';
	});
	const isPreviewArmed = $derived(armedBranchPattern !== '');

	// Lazy-load each preview child's full record to derive its FQDN +
	// container state. Sequenced per-load so a stale chain refresh can't
	// clobber a newer one; failures degrade to a row with no link.
	let previewMetaReqSeq = 0;
	async function loadPreviewMeta(): Promise<void> {
		const mine = ++previewMetaReqSeq;
		const kids = previewChildren;
		if (kids.length === 0) {
			previewMeta = {};
			return;
		}
		const results = await Promise.all(
			kids.map(async (child): Promise<[string, PreviewMeta]> => {
				try {
					const [w, cs] = await Promise.all([
						api.getWorkload(child.id),
						api.listWorkloadContainers(child.id).catch(() => [] as Container[])
					]);
					return [child.id, { fqdn: deriveFaceFqdn(w.public_faces), state: cs[0]?.state ?? '', loaded: true }];
				} catch {
					return [child.id, { fqdn: '', state: '', loaded: true }];
				}
			})
		);
		if (mine !== previewMetaReqSeq) return;
		const next: Record<string, PreviewMeta> = {};
		for (const [cid, meta] of results) next[cid] = meta;
		previewMeta = next;
	}

	// Fire-and-forget load of the most recent activity for this workload.
	// Non-fatal on failure: the panel just shows its empty state.
	async function loadActivity(): Promise<void> {
		activityLoading = true;
		try {
			activityEvents = await api.fetchWorkloadEvents(id, { limit: ACTIVITY_PAGE });
			activityOffset = activityEvents.length;
			activityHasMore = activityEvents.length === ACTIVITY_PAGE;
		} catch {
			// Non-fatal: panel shows empty state.
		} finally {
			activityLoading = false;
		}
	}

	// Page in older events below the live-prepended head. The global events
	// page can't filter by workload, so this is the only per-app history view.
	async function loadMoreActivity(): Promise<void> {
		if (activityLoadingMore || !activityHasMore) return;
		activityLoadingMore = true;
		try {
			const more = await api.fetchWorkloadEvents(id, { limit: ACTIVITY_PAGE, offset: activityOffset });
			// Dedup by id: a live SSE prepend can shift the offset window by one,
			// so a page boundary may re-return an already-shown row. {#each (entry.id)}
			// REQUIRES unique keys, so drop duplicates.
			const seen = new Set(activityEvents.map((e) => e.id));
			const fresh = more.filter((e) => !seen.has(e.id));
			activityEvents = [...activityEvents, ...fresh];
			activityOffset += more.length;
			activityHasMore = more.length === ACTIVITY_PAGE;
		} catch {
			// Non-fatal; leave the list as-is.
		} finally {
			activityLoadingMore = false;
		}
	}

	async function doTeardownPreview(): Promise<void> {
		if (!confirmTeardownId || tearingDown) return;
		const cid = confirmTeardownId;
		tearingDown = true;
		chainError = '';
		try {
			// Reuse the existing workload-delete endpoint — deleting a preview
			// child tears down its containers + proxy routes server-side. No
			// dedicated preview-mutation endpoint exists or is needed.
			await api.deletePluginWorkload(cid);
			confirmTeardownId = null;
			// Refresh the chain so the torn-down preview disappears.
			chain = await api.getWorkloadChain(id);
			await loadPreviewMeta();
		} catch (e) {
			chainError = e instanceof Error ? e.message : $t('apps.detail.previews.teardownFailed');
		} finally {
			tearingDown = false;
		}
	}

	const teardownTarget = $derived(
		confirmTeardownId ? previewChildren.find((c) => c.id === confirmTeardownId) ?? null : null
	);

	// ── Log scan rules (effective set for this workload) ─────
	// The backend's EffectiveLogScanRules resolves globals minus
	// per-workload overrides, plus workload-only additions, plus
	// the substituted override rows themselves. We classify each
	// row at render time so the UI can offer the right action
	// (override / edit / delete) per row.
	let logRules = $state<api.LogScanRule[]>([]);
	let logRulesError = $state('');
	let logRuleOverriding = $state<number | null>(null); // global id being overridden, for UI lock

	// ── Runtime / storage panels (static workloads) ─────────
	// Independent fetches off the main load() to keep their failures
	// isolated — runtime-state and storage probes can return 5xx if
	// the source container is mid-flight without blocking the rest of
	// the detail page from rendering.
	let runtimeState = $state<api.WorkloadRuntimeState | null>(null);
	let runtimeError = $state('');
	let storage = $state<api.WorkloadStorageUsage | null>(null);
	let storageError = $state('');
	let stopping = $state(false);
	let starting = $state(false);

	// ── Activity timeline (per-app deploy/event feed) ───────
	// Read-only panel: most-recent events for this workload, kept live by
	// the global SSE stream. activityNewIds drives the brief fade-in on
	// freshly-arrived rows, mirroring the global events page.
	const ACTIVITY_PAGE = 25;
	let activityEvents = $state<EventLogEntry[]>([]);
	let activityLoading = $state(true);
	let activityNewIds = $state<Set<number>>(new Set());
	let activityOffset = $state(0);
	let activityHasMore = $state(false);
	let activityLoadingMore = $state(false);
	// Client-side severity filter over the already-loaded rows (no refetch).
	let activitySeverity = $state<'all' | 'error'>('all');
	const visibleActivity = $derived(
		activitySeverity === 'all'
			? activityEvents
			: activityEvents.filter((e) => e.severity === activitySeverity)
	);

	// Sequence tokens + abort controllers so a slow in-flight probe
	// cannot overwrite a faster newer one's result, and so an in-flight
	// request is cancelled when the page unmounts (the user navigates
	// away from /apps/[id] before the probe resolves).
	let runtimeReqSeq = 0;
	let storageReqSeq = 0;
	let runtimeAbort: AbortController | null = null;
	let storageAbort: AbortController | null = null;

	// Live build-log tail (dockerfile workloads only). Capped to keep
	// memory bounded: a `RUN apt-get` step can emit tens of thousands of
	// lines and the DOM would melt without the cap. Latest lines win.
	const BUILD_LOG_MAX_LINES = 300;
	interface BuildLogLine {
		id: number;
		text: string;
	}
	let buildLogLines = $state<BuildLogLine[]>([]);
	// Monotonic id so the {#each} can key on a STABLE value. Keying by array
	// index re-keys nearly every row once the head-slice cap kicks in (index
	// i maps to a different string each append), forcing Svelte to re-render
	// all 300 nodes per line on the exact hot path the cap exists to protect.
	let buildLogSeq = 0;
	let buildLogTailEl: HTMLDivElement | null = $state(null);
	let sseConn: SSEConnection | null = null;

	function appendBuildLogLine(line: string): void {
		// Mutating-via-spread keeps Svelte's reactivity happy. Slice from
		// the head once the cap is exceeded so memory doesn't grow with
		// long builds.
		const entry: BuildLogLine = { id: buildLogSeq++, text: line };
		const next = buildLogLines.length >= BUILD_LOG_MAX_LINES
			? [...buildLogLines.slice(buildLogLines.length - BUILD_LOG_MAX_LINES + 1), entry]
			: [...buildLogLines, entry];
		buildLogLines = next;
		// Auto-scroll to bottom on next paint. requestAnimationFrame
		// rather than setTimeout(0) so the DOM has actually settled and
		// scrollHeight reflects the newly appended row.
		if (buildLogTailEl) {
			const el = buildLogTailEl;
			requestAnimationFrame(() => {
				el.scrollTop = el.scrollHeight;
			});
		}
	}

	function clearBuildLog(): void {
		buildLogLines = [];
	}

	// Trigger metadata map keyed by trigger id. Bindings already carry
	// kind + name, but `webhook_enabled` lives only on the parent
	// RedeployTrigger. We pull the full list once per page load and
	// look up flags from this map (cheap join over a small set).
	let triggersById = $state<Record<string, api.RedeployTrigger>>({});
	let triggersReqSeq = 0;
	let triggersAbort: AbortController | null = null;
	async function loadTriggerMeta() {
		const mine = ++triggersReqSeq;
		triggersAbort?.abort();
		const controller = new AbortController();
		triggersAbort = controller;
		try {
			const list = await api.listTriggers(undefined, controller.signal);
			if (mine !== triggersReqSeq) return;
			const next: Record<string, api.RedeployTrigger> = {};
			for (const tr of list) next[tr.id] = tr;
			triggersById = next;
		} catch {
			if (mine !== triggersReqSeq) return;
			if (controller.signal.aborted) return;
			// Soft-fail: the webhook summary panel will just render empty
			// rather than blocking the whole detail page on a triggers fetch.
			triggersById = {};
		}
	}

	// Source kinds that publish a typed runtime-state into containers.extra_json.
	// Both static and dockerfile use the same shape (status / last_commit_sha /
	// last_sync_at / last_error) so the same panel renders them identically.
	const runtimeStateKinds = new Set(['static', 'dockerfile']);
	const hasRuntimeState = $derived(
		!!workload && runtimeStateKinds.has(workload.source_kind)
	);

	async function loadRuntimeState() {
		if (!workload || !runtimeStateKinds.has(workload.source_kind)) return;
		const kind = workload.source_kind;
		const mine = ++runtimeReqSeq;
		runtimeAbort?.abort();
		const controller = new AbortController();
		runtimeAbort = controller;
		runtimeError = '';
		try {
			const result = await api.getWorkloadRuntimeState(id, controller.signal);
			// Re-check after the await: the active workload may have
			// switched kinds (or the page may have unmounted) while the
			// request was in flight. Both reqSeq and the kind snapshot
			// must still agree for us to commit the result.
			if (mine !== runtimeReqSeq || workload?.source_kind !== kind) return;
			runtimeState = result;
		} catch (e) {
			if (mine !== runtimeReqSeq) return;
			if (controller.signal.aborted) return;
			runtimeState = null;
			runtimeError = e instanceof Error ? e.message : $t('apps.detail.errors.runtimeStateFailed');
		}
	}

	async function loadStorage() {
		if (!workload || workload.source_kind !== 'static') return;
		// Only ping the storage endpoint when the source config opted in;
		// otherwise the backend returns enabled:false and we'd render an
		// empty card anyway.
		let enabled = false;
		try {
			const cfg = JSON.parse(workload.source_config || '{}') as Record<string, unknown>;
			enabled = cfg?.storage_enabled === true;
		} catch {
			enabled = false;
		}
		if (!enabled) {
			storage = null;
			return;
		}
		const kind = workload.source_kind;
		const mine = ++storageReqSeq;
		storageAbort?.abort();
		const controller = new AbortController();
		storageAbort = controller;
		storageError = '';
		try {
			const result = await api.getWorkloadStorage(id, controller.signal);
			if (mine !== storageReqSeq || workload?.source_kind !== kind) return;
			storage = result;
		} catch (e) {
			if (mine !== storageReqSeq) return;
			if (controller.signal.aborted) return;
			storage = null;
			storageError = e instanceof Error ? e.message : $t('apps.detail.errors.storageFailed');
		}
	}

	async function doStop() {
		if (stopping) return;
		stopping = true;
		error = '';
		try {
			await api.stopWorkload(id);
		} catch (e) {
			// Stop returns 409 when there's no container to act on and
			// 502 when every container failed. Narrow on ApiError so
			// the variant message is keyed off the status code rather
			// than a fragile regex over Error.message.
			if (e instanceof api.ApiError && e.status === 409) {
				error = $t('apps.detail.errors.stopNothing');
			} else if (e instanceof api.ApiError && e.status === 502) {
				error = $t('apps.detail.errors.stopAllFailed');
			} else if (e instanceof Error) {
				error = e.message;
			} else {
				error = $t('apps.detail.errors.stopFailed');
			}
		} finally {
			// Always reload — Docker may have moved containers to an
			// intermediate state we need to show even when the call
			// failed. `load()` clears `error` on success, so snapshot
			// the failure message and restore it after so the operator
			// still sees what went wrong.
			const failureMsg = error;
			try {
				await load();
				await loadRuntimeState();
			} catch {
				/* keep snapshot */
			}
			if (failureMsg) error = failureMsg;
			stopping = false;
		}
	}

	async function doStart() {
		if (starting) return;
		starting = true;
		error = '';
		try {
			await api.startWorkload(id);
		} catch (e) {
			if (e instanceof api.ApiError && e.status === 409) {
				error = $t('apps.detail.errors.startNothing');
			} else if (e instanceof api.ApiError && e.status === 502) {
				error = $t('apps.detail.errors.startAllFailed');
			} else if (e instanceof Error) {
				error = e.message;
			} else {
				error = $t('apps.detail.errors.startFailed');
			}
		} finally {
			// Same reload-on-finally + restore-error rule as doStop.
			const failureMsg = error;
			try {
				await load();
				await loadRuntimeState();
			} catch {
				/* keep snapshot */
			}
			if (failureMsg) error = failureMsg;
			starting = false;
		}
	}

	function runtimeStatusClass(s: string | undefined): string {
		const norm = (s || '').toLowerCase();
		if (norm === 'deployed') return 'rt-ok';
		if (norm === 'syncing') return 'rt-busy';
		if (norm === 'failed' || norm === 'error') return 'rt-bad';
		if (norm === 'stopped') return 'rt-idle';
		return 'rt-idle';
	}

	// Map a preview child's primary container state to the shared rt-badge
	// tone class + a human label. Reuses the rt-ok/busy/bad/idle palette so
	// the preview pills read consistently with the hero live-badge.
	function liveBadgeToneClass(state: string): string {
		const s = (state || '').toLowerCase();
		if (s === 'running') return 'rt-ok';
		if (s === 'restarting' || s === 'paused' || s === 'created' || s === 'starting')
			return 'rt-busy';
		if (s === 'exited' || s === 'dead' || s === 'stopped' || s === 'failed' || s === 'missing')
			return 'rt-bad';
		return 'rt-idle';
	}
	function previewStateLabel(state: string): string {
		const s = (state || '').toLowerCase();
		if (s === 'running') return $t('apps.detail.previews.stateRunning');
		if (s === 'restarting' || s === 'paused' || s === 'created' || s === 'starting')
			return $t('apps.detail.previews.statePending');
		if (s === 'exited' || s === 'dead' || s === 'stopped' || s === 'failed' || s === 'missing')
			return $t('apps.detail.previews.stateStopped');
		return $t('apps.detail.previews.stateUnknown');
	}

	function usageBarClass(pct: number): string {
		if (pct >= 0.9) return 'bar-red';
		if (pct >= 0.7) return 'bar-amber';
		return 'bar-green';
	}

	// Derived: visibility of toolbar controls.
	// We treat a running container OR a "deployed" status (which is the
	// post-build state for static workloads that don't carry a container)
	// as "stoppable". Conversely a "stopped" status or an explicitly
	// stopped container row makes the workload "startable". When the
	// signals disagree (e.g. status=deployed but state=exited) we err on
	// the side of showing both, letting the user choose.
	const primaryState = $derived(containers[0]?.state ?? '');
	const runtimeStatus = $derived(runtimeState?.status ?? '');
	const canStop = $derived(
		primaryState === 'running' ||
			runtimeStatus === 'deployed' ||
			runtimeStatus === 'syncing'
	);
	const canStart = $derived(
		primaryState === 'stopped' ||
			primaryState === 'exited' ||
			runtimeStatus === 'stopped' ||
			runtimeStatus === 'failed' ||
			(containers.length > 0 && primaryState !== 'running' && primaryState !== '')
	);
	// Derive a workload's primary public-face FQDN from its public_faces JSON
	// blob. Shared by the hero "open site ↗" affordance (via firstFaceFqdn)
	// and the preview-environments panel, which derives the same FQDN from
	// each preview child's own slug-prefixed public_faces. Returns '' when the
	// blob is empty/malformed or the first face has neither subdomain nor
	// domain.
	function deriveFaceFqdn(publicFacesJson: string | undefined | null): string {
		try {
			const parsed = JSON.parse(publicFacesJson || '[]');
			if (!Array.isArray(parsed) || parsed.length === 0) return '';
			const face = parsed[0] as { domain?: string; subdomain?: string };
			const dom = (face.domain || '').trim();
			const sub = (face.subdomain || '').trim();
			if (dom && sub) return `${sub}.${dom}`;
			return dom || sub;
		} catch {
			return '';
		}
	}
	const firstFaceFqdn = $derived(deriveFaceFqdn(workload?.public_faces));
	const storageEnabledOnSource = $derived.by(() => {
		if (!workload || workload.source_kind !== 'static') return false;
		try {
			const cfg = JSON.parse(workload.source_config || '{}') as Record<string, unknown>;
			return cfg?.storage_enabled === true;
		} catch {
			return false;
		}
	});
	const storageUsedPct = $derived.by(() => {
		if (!storage || !storage.limit_mb || storage.limit_mb <= 0) return 0;
		return Math.min(1, storage.used_bytes / (storage.limit_mb * 1024 * 1024));
	});

	// Universal "live state" pill rendered in the hero lede for every
	// source kind. Reflects ONLY container runtime state — for static
	// workloads the dedicated runtime panel below still owns the
	// deployed/syncing/failed nuance. Kept derived (no fetch) so it
	// updates immediately when the existing `containers` state refreshes.
	type LiveBadgeTone = 'ok' | 'busy' | 'bad' | 'idle';
	type LiveBadge = { tone: LiveBadgeTone; labelKey: string; count?: { running: number; total: number } };
	const liveBadge = $derived.by<LiveBadge>(() => {
		const total = containers.length;
		if (total === 0) {
			return { tone: 'idle', labelKey: 'apps.detail.liveBadge.notDeployed' };
		}
		let running = 0;
		let transitioning = 0;
		let stopped = 0;
		for (const c of containers) {
			const s = c.state;
			if (s === 'running') running += 1;
			else if (s === 'restarting' || s === 'paused' || s === 'created' || s === 'starting') transitioning += 1;
			else if (s === 'exited' || s === 'dead' || s === 'stopped') stopped += 1;
		}
		if (running === total) return { tone: 'ok', labelKey: 'apps.detail.liveBadge.running' };
		if (stopped === total) return { tone: 'bad', labelKey: 'apps.detail.liveBadge.stopped' };
		if (transitioning > 0 && running === 0 && stopped === 0) {
			return { tone: 'busy', labelKey: 'apps.detail.liveBadge.transitioning' };
		}
		return {
			tone: 'busy',
			labelKey: 'apps.detail.liveBadge.mixed',
			count: { running, total }
		};
	});

	// Webhook bindings — bindings whose underlying trigger has the
	// inbound webhook channel enabled. We surface kind + name from the
	// binding row directly and join `webhook_enabled` against the
	// triggers-by-id map (populated by loadTriggerMeta on page load).
	// Used by the read-only "Webhook bindings" summary panel.
	const webhookBindings = $derived.by(() =>
		bindings.filter((b) => triggersById[b.trigger_id]?.webhook_enabled === true)
	);

	async function load() {
		const k = id;
		// Reused-component nav (A→B): warm-seed B's header instantly if cached;
		// a cold id keeps the skeleton until the fetch resolves.
		const cached = workloadDetailCache.peek(k);
		if (cached.value) {
			workload = cached.value.workload;
			containers = cached.value.containers;
			// Reset the non-seeded dependent panels so a reused-component warm
			// nav (A→B) never renders the previous id's chain / bindings / rules
			// / env / volumes under the new id's header; they repopulate when
			// this id's Promise.all below resolves.
			chain = null;
			bindings = [];
			logRules = [];
			envRows = [];
			volumeRows = [];
			chainError = '';
			logRulesError = '';
			bindingsError = '';
		} else {
			loading = true;
		}
		error = '';
		try {
			const [w, c, env, vols, ch, lr, bs] = await Promise.all([
				api.getWorkload(id),
				api.listWorkloadContainers(id),
				api.listWorkloadEnv(id).catch(() => [] as api.WorkloadEnv[]),
				api.listWorkloadVolumes(id).catch(() => [] as api.WorkloadVolume[]),
				api.getWorkloadChain(id).catch((e) => {
					chainError = e instanceof Error ? e.message : 'chain load failed';
					return null;
				}),
				api.getEffectiveLogScanRules(id).catch((e) => {
					logRulesError = e instanceof Error ? e.message : 'rules load failed';
					return [] as api.LogScanRule[];
				}),
				api.listBindingsForWorkload(id).catch((e) => {
					bindingsError = e instanceof Error ? e.message : 'bindings load failed';
					return [] as WorkloadTriggerBinding[];
				})
			]);
			// Bail if the route id changed mid-flight — a newer load() owns the
			// page state and applying this stale result would clobber it.
			if (id !== k) return;
			workload = w;
			containers = c;
			workloadDetailCache.set(k, { workload: w, containers: c });
			envRows = env;
			volumeRows = vols;
			chain = ch;
			logRules = lr;
			bindings = bs;

			// Fire-and-forget trigger metadata load. The webhook-bindings
			// panel joins `webhook_enabled` off this map; failure renders
			// an empty webhook list, never blocks the page.
			void loadTriggerMeta();

			// Fire-and-forget per-preview FQDN + state probe. The chain DTO
			// omits public_faces, so the preview-environments panel resolves
			// each preview child's slug-prefixed URL from its full record.
			void loadPreviewMeta();

			// Fire-and-forget activity-timeline load. Failure is swallowed
			// inside loadActivity; the panel falls back to its empty state.
			void loadActivity();

			// Fire-and-forget runtime / storage probes for static workloads.
			// Failure is captured into their dedicated *_error fields and
			// must not break the rest of the detail page render.
			if (w.source_kind === 'static') {
				void loadRuntimeState();
				void loadStorage();
			} else {
				runtimeState = null;
				storage = null;
			}

			// If we routed here from /apps/new with a deferred bind
			// failure, surface it once and clear the flag.
			try {
				const key = `tinyforge.bindError.${id}`;
				const stored = sessionStorage.getItem(key);
				if (stored) {
					bindingsError = stored;
					sessionStorage.removeItem(key);
				}
			} catch {
				// session storage may be disabled — ignore.
			}
		} catch (e) {
			if (id !== k) return;
			// Clear the (possibly warm-seeded) workload so a 404 / failed load
			// resolves to the clean error page instead of a phantom, interactive
			// detail UI for an entity that no longer exists.
			workload = null;
			error = e instanceof Error ? e.message : $t('apps.detail.loadError');
		} finally {
			// Only the current id's load may clear the skeleton — a stale load
			// returning early must not flip a newer load's loading state.
			if (id === k) loading = false;
		}
	}

	// Classify each effective rule into one of three buckets so the
	// UI can offer the right action. Mirrors the backend's
	// EffectiveLogScanRules resolution but operates on the result
	// shape — no extra round-trip required.
	type RuleKind = 'global' | 'workload' | 'override';
	function classifyRule(r: api.LogScanRule): RuleKind {
		if (r.overrides_id !== 0) return 'override';
		if (r.workload_id !== '') return 'workload';
		return 'global';
	}

	// Create a per-workload override row that substitutes for the
	// global in this workload's effective set. The override starts
	// as a copy of the global so operators can tweak severity /
	// streams / cooldown without rebuilding the regex from scratch.
	async function overrideRule(global: api.LogScanRule) {
		logRulesError = '';
		logRuleOverriding = global.id;
		try {
			await api.createLogScanRule({
				workload_id: id,
				overrides_id: global.id,
				name: global.name,
				pattern: global.pattern,
				severity: global.severity,
				streams: global.streams,
				cooldown_seconds: global.cooldown_seconds,
				enabled: global.enabled
			});
			await reloadLogRules();
		} catch (e) {
			logRulesError = e instanceof Error ? e.message : 'Override failed';
		} finally {
			logRuleOverriding = null;
		}
	}

	async function reloadLogRules() {
		try {
			logRules = await api.getEffectiveLogScanRules(id);
			logRulesError = '';
		} catch (e) {
			logRulesError = e instanceof Error ? e.message : 'rules reload failed';
		}
	}

	// Open the confirm dialog for a promotion. Direction is encoded by which
	// (target, source) pair is passed: parent→self pulls the parent's version
	// into this workload; self→child pushes this workload's version to a child.
	function askPromote(targetID: string, sourceID: string, targetName: string, sourceName: string) {
		confirmPromote = { targetID, sourceID, targetName, sourceName };
	}

	async function doPromote() {
		const p = confirmPromote;
		confirmPromote = null;
		if (!p) return;
		chainError = '';
		promoting = p.targetID;
		try {
			const res = await api.promoteFromWorkload(p.targetID, p.sourceID, { deploy: true });
			toasts.success(
				$t('apps.detail.chainPromoteOk', { tag: res.promoted_tag, target: p.targetName })
			);
			await load();
		} catch (e) {
			chainError = e instanceof Error ? e.message : $t('apps.detail.chainPromoteFailed');
		} finally {
			promoting = null;
		}
	}

	async function addVolume() {
		volumeError = '';
		if (!newVolTarget.trim().startsWith('/')) {
			volumeError = $t('apps.detail.volumeTargetError');
			return;
		}
		volumeSaving = true;
		try {
			await api.setWorkloadVolume(id, {
				source: newVolSource.trim(),
				target: newVolTarget.trim(),
				scope: newVolScope
			});
			newVolSource = '';
			newVolTarget = '';
			volumeRows = await api.listWorkloadVolumes(id);
		} catch (e) {
			volumeError = e instanceof Error ? e.message : $t('apps.detail.volumeSetFailed');
		} finally {
			volumeSaving = false;
		}
	}

	async function removeVolume(volID: string) {
		volumeError = '';
		try {
			await api.deleteWorkloadVolume(id, volID);
			volumeRows = volumeRows.filter((v) => v.id !== volID);
		} catch (e) {
			volumeError = e instanceof Error ? e.message : $t('apps.detail.volumeDeleteFailed');
		}
	}

	async function addEnv() {
		envError = '';
		const key = newEnvKey.trim();
		if (!key) {
			envError = $t('apps.detail.envKeyRequired');
			return;
		}
		envSaving = true;
		try {
			await api.setWorkloadEnv(id, {
				key,
				value: newEnvValue,
				encrypted: newEnvEncrypted
			});
			newEnvKey = '';
			newEnvValue = '';
			envRows = await api.listWorkloadEnv(id);
		} catch (e) {
			envError = e instanceof Error ? e.message : $t('apps.detail.envSetFailed');
		} finally {
			envSaving = false;
		}
	}

	async function removeEnv(envID: string) {
		envError = '';
		try {
			await api.deleteWorkloadEnv(id, envID);
			envRows = envRows.filter((e) => e.id !== envID);
		} catch (e) {
			envError = e instanceof Error ? e.message : $t('apps.detail.envDeleteFailed');
		}
	}

	async function deploy() {
		deploying = true;
		lastDeployMsg = '';
		error = '';
		try {
			const body = deployRef ? { reference: deployRef } : undefined;
			const res = await api.deployPluginWorkload(id, body);
			lastDeployMsg = $t('apps.detail.manualDeployDispatched', {
				reference: res.reference || '(default)',
				by: res.triggered_by
			});
			setTimeout(() => {
				void load();
				void loadRuntimeState();
			}, 1500);
		} catch (e) {
			error = e instanceof Error ? e.message : $t('apps.detail.deployError');
		} finally {
			deploying = false;
		}
	}

	function prettyJson(s: string): string {
		try {
			return JSON.stringify(JSON.parse(s), null, 2);
		} catch {
			return s;
		}
	}

	function jsonOk(s: string): boolean {
		try {
			JSON.parse(s);
			return true;
		} catch {
			return false;
		}
	}

	function startEdit() {
		if (!workload) return;
		editName = workload.name;
		editParentID = workload.parent_workload_id || '';
		editSourceConfig = prettyJson(workload.source_config);
		editPublicFaces = prettyJson(workload.public_faces || '[]');
		editAdvancedJson = false;
		// Seed the matching kind's state object from the workload's
		// source_config. Kind is locked on edit, so only this one is ever
		// rendered/serialized — but reseeding via the module keeps the seed
		// logic identical to /apps/new (and to the legacy inline seeders).
		seedEditFormState(workload.source_kind, editSourceConfig);
		// Fire-and-forget registry fetch — purely for the picker
		// dropdown. Failure leaves the field as a plain text input.
		void api
			.listRegistries()
			.then((rs) => {
				editRegistries = rs.map((r) => ({ name: r.name, url: r.url }));
			})
			.catch(() => {
				editRegistries = [];
			});
		// Fire-and-forget: surface which fields the repo config manages so the
		// edit form can warn they'll be overwritten on Sync. Only relevant when
		// GitOps is on; failure leaves the gate off (no banner) — never blocks.
		gitopsManagedFields = [];
		if (workload.gitops_enabled) {
			// Guard against a slow response landing on a different workload after
			// an A→B nav (the component instance is reused).
			const forId = workload.id;
			void api
				.fetchWorkloadGitOps(forId)
				.then((g) => {
					if (forId === workload?.id) gitopsManagedFields = g.managed_fields ?? [];
				})
				.catch(() => {
					if (forId === workload?.id) gitopsManagedFields = [];
				});
		}
		editing = true;
	}

	// Seed the per-kind state object from a source_config JSON blob via the
	// shared `sourceForms.ts` seeders. Thin wrappers — only the workload's
	// (locked) kind is reseeded.
	function seedEditFormState(kind: string, jsonText: string): void {
		if (kind === 'compose') editComposeState = seedComposeState(jsonText);
		else if (kind === 'image') editImageState = seedImageState(jsonText);
		else if (kind === 'static') editStaticState = seedStaticState(jsonText);
		else if (kind === 'dockerfile') editDockerfileState = seedDockerfileState(jsonText);
	}

	// Serialize the per-kind state object back to the canonical JSON string
	// for the Advanced-JSON editor view. env/volumes (image), storage_*
	// (static), and unknown keys (dockerfile) are preserved from the current
	// editSourceConfig via the module serializers — byte-identical to the
	// legacy edit*FormBody helpers.
	function editFormStateToJSON(kind: string): string {
		if (kind === 'compose') return stringifyConfig(composeToConfig(editComposeState));
		if (kind === 'image') return stringifyConfig(imageToConfig(editImageState, editSourceConfig));
		if (kind === 'static') return stringifyConfig(staticToConfig(editStaticState, editSourceConfig));
		if (kind === 'dockerfile')
			return stringifyConfig(dockerfileToConfig(editDockerfileState, editSourceConfig));
		return editSourceConfig;
	}

	// Toggle between the kind-aware form and the raw JSON editor. Going to
	// Advanced JSON commits the form fields into editSourceConfig first so
	// the user sees the same data; going back re-seeds from the JSON so
	// manual edits aren't lost. Mirrors /apps/new's toggleAdvancedJSON.
	function toggleEditAdvancedJSON() {
		const kind = workload?.source_kind ?? '';
		if (!editAdvancedJson) {
			editSourceConfig = editFormStateToJSON(kind);
			editAdvancedJson = true;
		} else {
			seedEditFormState(kind, editSourceConfig);
			editAdvancedJson = false;
		}
	}

	function cancelEdit() {
		editing = false;
		error = '';
	}

	async function saveEdit() {
		if (!workload) return;
		saving = true;
		error = '';
		try {
			let parsedSource: unknown;
			let parsedFaces: unknown[];
			// All four form paths route through the shared sourceForms.ts
			// serializers so the PUT source_config is byte-identical to the
			// legacy inline edit*FormBody helpers (key order + env/volumes
			// (image), storage_* (static), and unknown-key preserve/scrub
			// (dockerfile) rules included). env/volumes/storage_*/unknown keys
			// are preserved from editSourceConfig.
			if (useEditComposeForm) {
				parsedSource = composeToConfig(editComposeState);
			} else if (useEditImageForm) {
				parsedSource = imageToConfig(editImageState, editSourceConfig);
			} else if (useEditStaticForm) {
				parsedSource = staticToConfig(editStaticState, editSourceConfig);
			} else if (useEditDockerfileForm) {
				parsedSource = dockerfileToConfig(editDockerfileState, editSourceConfig);
			} else {
				try {
					parsedSource = JSON.parse(editSourceConfig);
				} catch {
					throw new Error('source_config is not valid JSON');
				}
			}
			try {
				const f = JSON.parse(editPublicFaces);
				if (!Array.isArray(f)) throw new Error('public_faces must be an array');
				parsedFaces = f as unknown[];
			} catch (e) {
				throw new Error(
					`public_faces invalid: ${e instanceof Error ? e.message : 'parse error'}`
				);
			}

			// Trigger fields are no longer carried on the workload row.
			// We pass empty placeholders so the legacy backend path is
			// effectively a no-op; bindings are managed via the
			// dedicated Triggers panel below.
			const body: PluginWorkloadInput = {
				name: editName.trim(),
				parent_workload_id: editParentID.trim(),
				source_kind: workload.source_kind,
				source_config: parsedSource,
				trigger_kind: '',
				trigger_config: {},
				public_faces: parsedFaces as PluginWorkloadInput['public_faces']
			};
			await api.updatePluginWorkload(id, body);
			editing = false;
			await load();
		} catch (e) {
			error = e instanceof Error ? e.message : $t('apps.detail.saveError');
		} finally {
			saving = false;
		}
	}

	// ── Binding helpers ─────────────────────────────────────────
	async function reloadBindings(): Promise<void> {
		bindingsLoading = true;
		try {
			bindings = await api.listBindingsForWorkload(id);
			bindingsError = '';
		} catch (e) {
			bindingsError = e instanceof Error ? e.message : $t('apps.detail.bindings.loadError');
		} finally {
			bindingsLoading = false;
		}
	}

	async function toggleBinding(b: WorkloadTriggerBinding, next: boolean): Promise<void> {
		try {
			const updated = await api.updateBinding(b.id, { enabled: next });
			bindings = bindings.map((x) =>
				x.id === b.id ? { ...x, enabled: updated.enabled } : x
			);
			bindingsError = '';
		} catch (e) {
			bindingsError = e instanceof Error ? e.message : 'Update failed';
		}
	}

	async function doUnbind(): Promise<void> {
		if (!confirmUnbindId) return;
		const bid = confirmUnbindId;
		try {
			await api.deleteBinding(bid);
			bindings = bindings.filter((b) => b.id !== bid);
			bindingsError = '';
		} catch (e) {
			bindingsError = e instanceof Error ? e.message : 'Unbind failed';
		} finally {
			confirmUnbindId = null;
		}
	}

	function openAddTriggerModal(): void {
		// Reset every interaction: fresh form, no pre-picked trigger,
		// no stale error. Default to inline because that's the common
		// case for one-off setups.
		addModalOpen = true;
		addModalTab = 'inline';
		addModalError = '';
		addModalSubmitting = false;
		inlineTriggerForm = createTriggerKindFormState({ kind: 'registry' });
		pickTriggerId = '';
		pickKindFilter = '';
	}

	function closeAddTriggerModal(): void {
		if (addModalSubmitting) return;
		addModalOpen = false;
	}

	async function ensureAvailableTriggers(): Promise<void> {
		if (availableTriggersLoaded) return;
		try {
			availableTriggers = await api.listTriggers();
			availableTriggersLoaded = true;
		} catch (e) {
			addModalError = e instanceof Error ? e.message : 'Failed to load triggers';
		}
	}

	async function switchAddTab(tab: 'inline' | 'pick'): Promise<void> {
		addModalTab = tab;
		addModalError = '';
		if (tab === 'pick') {
			await ensureAvailableTriggers();
		}
	}

	const addModalCanSubmit = $derived.by(() => {
		if (addModalSubmitting) return false;
		if (addModalTab === 'inline') return isTriggerFormValid(inlineTriggerForm);
		return !!pickTriggerId;
	});

	async function submitAddTrigger(): Promise<void> {
		if (!addModalCanSubmit) return;
		addModalSubmitting = true;
		addModalError = '';
		try {
			if (addModalTab === 'inline') {
				const inline = buildTriggerInput(inlineTriggerForm);
				await api.bindTriggerToWorkload(id, { inline });
			} else {
				await api.bindTriggerToWorkload(id, { trigger_id: pickTriggerId });
			}
			addModalOpen = false;
			await reloadBindings();
			// Refresh trigger metadata too — a newly-created inline
			// trigger is otherwise absent from triggersById, which would
			// silently exclude it from the webhook bindings card.
			await loadTriggerMeta();
		} catch (e) {
			addModalError = e instanceof Error ? e.message : $t('apps.detail.bindings.modal.error');
		} finally {
			addModalSubmitting = false;
		}
	}

	const unbindTarget = $derived(
		confirmUnbindId ? bindings.find((b) => b.id === confirmUnbindId) ?? null : null
	);

	function triggerKindLabel(k: string): string {
		const key = `redeployTriggers.kind.${k}`;
		const v = $t(key);
		return v === key ? k : v;
	}

	async function doDelete() {
		deleting = true;
		error = '';
		try {
			await api.deletePluginWorkload(id);
			// Drop the cached entry so navigating back to this (now-deleted) id
			// doesn't warm-seed a phantom detail page.
			workloadDetailCache.remove(id);
			goto('/apps');
		} catch (e) {
			error = e instanceof Error ? e.message : $t('apps.detail.deleteError');
			deleting = false;
			confirmDelete = false;
		}
	}

	async function copyToClipboard(key: string, text: string) {
		try {
			await navigator.clipboard.writeText(text);
			copied = { ...copied, [key]: true };
			setTimeout(() => {
				copied = { ...copied, [key]: false };
			}, 1200);
		} catch {
			// Clipboard may be unavailable (insecure context). Fall back silently.
		}
	}

	function sourceBadge(kind: string): string {
		switch (kind) {
			case 'image':
				return 'src-image';
			case 'compose':
				return 'src-compose';
			case 'static':
				return 'src-static';
			default:
				return 'src-other';
		}
	}

	function stateClass(s: string): string {
		const norm = (s || '').toLowerCase();
		if (norm === 'running') return 'st-running';
		if (norm === 'failed' || norm === 'missing' || norm === 'error') return 'st-failed';
		if (norm === 'deploying' || norm === 'starting' || norm === 'restarting')
			return 'st-deploying';
		return 'st-idle';
	}

	const sourceValid = $derived(jsonOk(editSourceConfig));
	const facesValid = $derived(jsonOk(editPublicFaces));

	// SvelteKit reuses the component instance when navigating between
	// /apps/A → /apps/B, so onMount(load) only fires for the first
	// mount. Use a route-id-dependent effect so the page reloads when
	// the param changes; abort the prior request first so a slow
	// fetch for the previous id cannot land on the new id's state.
	$effect(() => {
		const _ = id; // explicit dependency
		// Resolve the viewer's role once (role is session-wide, not per-id).
		if (!roleFetched) {
			roleFetched = true;
			void api
				.getCurrentUser()
				.then((u) => {
					isAdmin = u.role === 'admin';
				})
				.catch(() => {
					isAdmin = false;
				});
		}
		runtimeAbort?.abort();
		storageAbort?.abort();
		triggersAbort?.abort();
		// Reset the live tail when switching workloads — otherwise
		// in-flight events from the previous workload's deploy could
		// land in the new one's panel.
		clearBuildLog();
		load();
	});

	// Subscribe to /api/events keyed on the route id. Build logs are now
	// streamed per-workload by the server (?workload_id=…) so a verbose
	// build can't flood every dashboard connection — which means the
	// connection MUST reconnect when the user navigates /apps/A → /apps/B
	// (SvelteKit reuses the component instance). The $effect cleanup closes
	// the prior connection on id change and on unmount.
	$effect(() => {
		const currentId = id;
		if (!currentId) return;
		const conn = connectGlobalEvents({
			buildLogWorkloadId: currentId,
			onEventLog: (payload) => {
				// EventLog frames broadcast to EVERY connection (only high-volume
				// build logs are workload-filtered server-side), so scope to this
				// app client-side before prepending to the activity timeline.
				if (payload.workload_id !== currentId) return;
				const entry = toEventLogEntry(payload);
				// Skip rows already shown (e.g. one that load-more just paged in)
				// — {#each (entry.id)} requires unique keys.
				if (activityEvents.some((e) => e.id === entry.id)) return;
				// Bound generously so a live prepend can't truncate paged-in
				// history (load-more grows the list intentionally).
				activityEvents = [entry, ...activityEvents].slice(0, 500);
				// Prune highlight ids to rows still present after the cap.
				const present = new Set(activityEvents.map((e) => e.id));
				activityNewIds = new Set([...activityNewIds, entry.id].filter((x) => present.has(x)));
				setTimeout(() => {
					activityNewIds = new Set([...activityNewIds].filter((x) => x !== entry.id));
				}, 3000);
			},
			onBuildLog: (payload) => {
				// Server already filters by workload_id; this is belt-and-braces.
				if (payload.workload_id !== currentId) return;
				appendBuildLogLine(payload.line);
			},
			onDeployStatus: (payload) => {
				// A preview child deploying live: its state pill is derived
				// from previewMeta, which otherwise only refreshes on a full
				// load(). Re-pull preview meta so the pill tracks the deploy
				// instead of going stale. (project_id is the workload id in
				// the workload era — same field the parent check uses.)
				if (previewChildren.some((c) => c.id === payload.project_id)) {
					void loadPreviewMeta();
					return;
				}
				// When a deploy lands as deployed/failed, refresh the
				// runtime-state card so the user sees the new SHA /
				// timestamp without a manual refresh.
				if (payload.project_id !== currentId) return;
				if (payload.status === 'deployed' || payload.status === 'failed') {
					loadRuntimeState();
				}
			}
		});
		sseConn = conn;
		return () => {
			conn.close();
			if (sseConn === conn) sseConn = null;
		};
	});

	// Cancel any in-flight runtime / storage probe so a late resolve
	// after the user navigates away can't mutate dead component state.
	onDestroy(() => {
		runtimeAbort?.abort();
		storageAbort?.abort();
		triggersAbort?.abort();
		sseConn?.close();
		sseConn = null;
	});
</script>

<svelte:head>
	<title>{workload?.name ?? $t('apps.detail.pageTitleFallback')} · Tinyforge</title>
</svelte:head>

<div class="forge">
	{#if loading && !workload}
		<div class="loading-line">
			<span class="spinner" aria-hidden="true"></span>
			<span>{$t('apps.detail.loading')}</span>
		</div>
	{:else if error && !workload}
		<div class="alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{error}</span></div>
	{:else if workload}
		{#snippet detailToolbar()}
			<!-- Open / Refresh / Edit / Delete collapse into a <details>
			     overflow menu under 640px so the toolbar stays single-row
			     on phones. Stop/Start/Deploy remain inline at every width.
			     Both copies render; a CSS media query swaps which is shown
			     so keyboard nav and aria semantics stay native. -->
			{#if !editing && firstFaceFqdn}
				<a
					class="forge-btn-ghost open-ext tb-wide"
					href={`https://${firstFaceFqdn}`}
					target="_blank"
					rel="noopener noreferrer"
					title={firstFaceFqdn}
				>
					<IconGlobe size={13} />
					<span>{$t('apps.detail.toolbar.openSite')}</span>
					<span class="open-ext-glyph" aria-hidden="true">↗</span>
				</a>
			{/if}
			{#if !editing && canStop}
				<button
					class="forge-btn-ghost"
					onclick={doStop}
					disabled={stopping}
					aria-label={$t('apps.detail.toolbar.stop')}
				>
					<IconStop size={13} />
					<span>{stopping ? '…' : $t('apps.detail.toolbar.stop')}</span>
				</button>
			{/if}
			{#if !editing && canStart}
				<button
					class="forge-btn-ghost"
					onclick={doStart}
					disabled={starting}
					aria-label={$t('apps.detail.toolbar.start')}
				>
					<IconPlay size={13} />
					<span>{starting ? '…' : $t('apps.detail.toolbar.start')}</span>
				</button>
			{/if}
			<button class="forge-btn-icon tb-wide" onclick={load} aria-label={$t('apps.detail.refreshLabel')}>
				<IconRefresh size={16} />
			</button>
			{#if !editing}
				<button class="forge-btn-ghost tb-wide" onclick={startEdit}>
					<IconEdit size={13} />
					<span>{$t('apps.detail.editButton')}</span>
				</button>
				<button class="forge-btn-ghost danger tb-wide" onclick={() => (confirmDelete = true)}>
					<IconTrash size={13} />
					<span>{$t('apps.detail.deleteButton')}</span>
				</button>

				<!-- Overflow menu: only visible <640px via CSS. Native
				     <details> handles ESC/space/tab focus without JS. -->
				<details class="tb-overflow">
					<summary class="forge-btn-ghost tb-more-summary" aria-label={$t('apps.detail.toolbar.more')}>
						<span>{$t('apps.detail.toolbar.more')}</span>
						<span class="tb-more-glyph" aria-hidden="true">⋯</span>
					</summary>
					<!-- Plain block with native focusable children — no
					     ARIA menu role, which would promise arrow-key
					     navigation we don't wire. Tab + ESC (via native
					     <details>) cover keyboard nav adequately. -->
					<div class="tb-menu">
						{#if firstFaceFqdn}
							<a
								class="tb-menu-item"
								href={`https://${firstFaceFqdn}`}
								target="_blank"
								rel="noopener noreferrer"
								title={firstFaceFqdn}
							>
								<IconGlobe size={13} />
								<span>{$t('apps.detail.toolbar.openSite')}</span>
								<span class="open-ext-glyph" aria-hidden="true">↗</span>
							</a>
						{/if}
						<button class="tb-menu-item" onclick={load}>
							<IconRefresh size={13} />
							<span>{$t('apps.detail.refreshLabel')}</span>
						</button>
						<button class="tb-menu-item" onclick={startEdit}>
							<IconEdit size={13} />
							<span>{$t('apps.detail.editButton')}</span>
						</button>
						<button class="tb-menu-item danger" onclick={() => (confirmDelete = true)}>
							<IconTrash size={13} />
							<span>{$t('apps.detail.deleteButton')}</span>
						</button>
					</div>
				</details>
			{/if}
		{/snippet}

		{#snippet detailLede()}
			<span class="lede-row">
				<!-- role+aria-live so the auto-refresh on load() is
				     announced once. No aria-label: the visible text is
				     the label, and duplicating it suppresses the visible
				     reading on some screen readers. -->
				<span
					class="rt-badge live-badge rt-{liveBadge.tone}"
					role="status"
					aria-live="polite"
				>
					<span class="rt-dot" aria-hidden="true"></span>
					{liveBadge.count
						? $t(liveBadge.labelKey, {
								running: String(liveBadge.count.running),
								total: String(liveBadge.count.total)
							})
						: $t(liveBadge.labelKey)}
				</span>
				<span class="badge {sourceBadge(workload!.source_kind)}">
					<span class="badge-dot" aria-hidden="true"></span>
					{workload!.source_kind}
				</span>
				{#if workload!.gitops_enabled}
					<span class="badge badge-gitops" title={$t('apps.detail.gitops.badgeTitle')}>
						<span class="badge-dot" aria-hidden="true"></span>
						{$t('apps.detail.gitops.badge')}
					</span>
				{/if}
				<span class="badge trigger">
					{bindings.length === 0
						? $t('apps.detail.chainTriggersZero')
						: bindings.length === 1
							? $t('apps.detail.chainTriggersOne')
							: $t('apps.detail.chainTriggersMany', { count: String(bindings.length) })}
				</span>
				<span class="lede-sep">·</span>
				<span class="lede-meta">
					{$t('apps.detail.createdAt')} <code>{workload!.created_at}</code>
				</span>
			</span>
		{/snippet}

		<ForgeHero
			backHref="/apps"
			backLabel={$t('apps.detail.backLabel')}
			eyebrowSuffix={$t('apps.detail.eyebrowSuffix')}
			title={workload.name}
			kicker={$t('apps.detail.kickerId', { id: workload.id })}
			size="lg"
			toolbar={detailToolbar}
			lede_html={detailLede}
		/>

		{#if error}
			<div class="alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{error}</span></div>
		{/if}

		{#if editing}
			<!-- ── Edit form ────────────────────────────── -->
			<section class="panel">
				<span class="reg reg-tl" aria-hidden="true"></span>
				<span class="reg reg-tr" aria-hidden="true"></span>
				<span class="reg reg-bl" aria-hidden="true"></span>
				<span class="reg reg-br" aria-hidden="true"></span>

				<header class="panel-head">
					<h2 class="panel-title">{$t('apps.detail.editTitle')}<span class="title-accent">.</span></h2>
					<span class="panel-sub">
						{$t('apps.detail.editSubPrefix')} <code>{workload.source_kind}</code> {$t('apps.detail.editSubSuffix')}
					</span>
				</header>

				{#if editGitOpsGated}
					<!-- Read-only gate: this workload's config is declared in the repo.
					     Edits here are valid but get overwritten on the next Sync, so
					     the banner steers the user to the file + lists the managed
					     fields. Calm (accent, not danger) — it's guidance, not an error. -->
					<div class="gitops-gate" role="note">
						<span class="gitops-gate-icon" aria-hidden="true"><IconLock size={15} /></span>
						<div class="gitops-gate-body">
							<p class="gitops-gate-title">{$t('apps.detail.gitops.gateTitle')}</p>
							<p class="gitops-gate-text">{$t('apps.detail.gitops.gateBody')}</p>
							<div class="gitops-gate-fields">
								<span class="gitops-gate-flabel">{$t('apps.detail.gitops.gateFieldsLabel')}</span>
								{#each gitopsManagedFields as f (f)}
									<code class="gitops-gate-field">{f}</code>
								{/each}
							</div>
						</div>
					</div>
				{/if}

				<div class="field">
					<label for="edit-name" class="field-label">
						<span class="num">01</span>
						<span class="lbl">{$t('apps.detail.editFieldName')}</span>
					</label>
					<input
						id="edit-name"
						type="text"
						class="input"
						bind:value={editName}
						autocomplete="off"
						spellcheck="false"
					/>
				</div>

				<div class="field">
					<label for="edit-parent" class="field-label">
						<span class="num">02</span>
						<span class="lbl">{$t('apps.detail.editFieldParent')}</span>
						<span class="opt">{$t('apps.detail.editFieldOptional')}</span>
					</label>
					<input
						id="edit-parent"
						type="text"
						class="input"
						bind:value={editParentID}
						placeholder={$t('apps.detail.editFieldParentPlaceholder')}
						autocomplete="off"
						spellcheck="false"
					/>
				</div>

				<div class="field">
					<div class="field-label">
						<span class="num">03</span>
						<span class="lbl">{$t('apps.detail.editSourceConfig')}</span>
						<span class="req">
							{useEditComposeForm
								? $t('apps.detail.editConfigYaml')
								: useEditImageForm || useEditStaticForm || useEditDockerfileForm
									? $t('apps.detail.editConfigForm')
									: $t('apps.detail.editConfigJson')}
						</span>
					</div>
					{#if useEditComposeForm}
						<ComposeSourceForm bind:form={editComposeState} onAdvanced={toggleEditAdvancedJSON} />
					{:else if useEditImageForm}
						<!-- Image source form. Conflict detection is OFF on the
						     edit page (enableConflicts={false}) — the workload
						     would otherwise flag itself as conflicting with its
						     own image. The Inspect button stays (it only fills
						     empty port/healthcheck fields and is harmless). -->
						<ImageSourceForm
							bind:form={editImageState}
							registries={editRegistries}
							enableConflicts={false}
							onAdvanced={toggleEditAdvancedJSON}
						/>
					{:else if useEditStaticForm}
						<StaticSourceForm bind:form={editStaticState} onAdvanced={toggleEditAdvancedJSON} />
					{:else if useEditDockerfileForm}
						<DockerfileSourceForm bind:form={editDockerfileState} onAdvanced={toggleEditAdvancedJSON} />
					{:else}
						<div class="editor">
							<div class="editor-head">
								<span class="dot"></span><span class="dot"></span><span class="dot"></span>
								<span class="editor-title">{$t('apps.detail.editSourceJsonHeader')}</span>
								<span class="spacer"></span>
								{#if (workload?.source_kind ?? '') === 'compose' || (workload?.source_kind ?? '') === 'image' || (workload?.source_kind ?? '') === 'static' || (workload?.source_kind ?? '') === 'dockerfile'}
									<button
										type="button"
										class="editor-chip"
										onclick={toggleEditAdvancedJSON}
										title={$t('apps.detail.switchToFormTitle')}
									>
										{$t('apps.detail.backToForm')}
									</button>
								{/if}
							</div>
							<textarea
								id="edit-source"
								bind:value={editSourceConfig}
								rows="12"
								spellcheck="false"
								class="code-area"
								aria-label={$t('apps.detail.editSourceJsonAria')}
							></textarea>
							<div class="editor-foot">
								<span class="foot-status" class:bad={!sourceValid}>
									<span class="foot-dot" aria-hidden="true"></span>
									{sourceValid ? $t('apps.detail.jsonOk') : $t('apps.detail.jsonInvalid')}
								</span>
							</div>
						</div>
					{/if}
				</div>

				<div class="field">
					<div class="field-label">
						<span class="num">04</span>
						<span class="lbl">{$t('apps.detail.editPublicFaces')}</span>
						<span class="opt">{$t('apps.detail.editPublicFacesTag')}</span>
					</div>
					<div class="editor">
						<div class="editor-head">
							<span class="dot"></span><span class="dot"></span><span class="dot"></span>
							<span class="editor-title">{$t('apps.detail.editPublicFacesHeader')}</span>
						</div>
						<textarea
							id="edit-faces"
							bind:value={editPublicFaces}
							rows="7"
							spellcheck="false"
							class="code-area"
							aria-label={$t('apps.detail.editPublicFacesAria')}
						></textarea>
						<div class="editor-foot">
							<span class="foot-status" class:bad={!facesValid}>
								<span class="foot-dot" aria-hidden="true"></span>
								{facesValid ? $t('apps.detail.jsonOk') : $t('apps.detail.jsonInvalid')}
							</span>
						</div>
					</div>
				</div>

				<div class="actions">
					<button class="forge-btn-ghost" onclick={cancelEdit} disabled={saving}>{$t('apps.detail.editCancel')}</button>
					<button
						class="forge-btn"
						onclick={saveEdit}
						disabled={saving ||
							!editName.trim() ||
							(!useEditComposeForm && !useEditImageForm && !useEditStaticForm && !useEditDockerfileForm && !sourceValid) ||
							(useEditImageForm && !editImageState.ref.trim()) ||
							(useEditStaticForm && (!editStaticState.repoOwner.trim() || !editStaticState.repoName.trim())) ||
							(useEditDockerfileForm && (!editDockerfileState.repoOwner.trim() || !editDockerfileState.repoName.trim() || !editDockerfilePortValid)) ||
							!facesValid}
					>
						{saving ? $t('apps.detail.editSaving') : $t('apps.detail.editSave')}
					</button>
				</div>
			</section>
		{/if}

		<!-- ── Runtime & storage (static workloads) ───────
		     Two narrow cards displayed in a 2-up grid so they read as
		     a single "operational status" row above Manual deploy. The
		     grid collapses to a single column on narrow viewports. -->
		{#if !editing && hasRuntimeState && (runtimeState !== null || runtimeError || storageEnabledOnSource)}
			<div class="rt-grid">
				<section class="panel rt-card" aria-labelledby="rt-runtime-heading">
					<span class="reg reg-tl" aria-hidden="true"></span>
					<span class="reg reg-tr" aria-hidden="true"></span>
					<span class="reg reg-bl" aria-hidden="true"></span>
					<span class="reg reg-br" aria-hidden="true"></span>

					<header class="panel-head">
						<h2 class="panel-title" id="rt-runtime-heading">
							<IconClock size={14} />
							<span>{$t('apps.detail.runtimeState.title')}</span><span class="title-accent">.</span>
						</h2>
						<span class="panel-sub">{$t('apps.detail.runtimeState.sub')}</span>
					</header>

					{#if runtimeError}
						<div class="alert inline-alert" role="alert">
							<span class="alert-tag">ERR</span><span>{runtimeError}</span>
						</div>
					{:else if runtimeState && !runtimeState.has_state}
						<p class="rt-empty">{$t('apps.detail.runtimeState.neverDeployed')}</p>
					{:else if runtimeState}
						<dl class="kv-grid">
							<dt>{$t('apps.detail.runtimeState.status')}</dt>
							<dd>
								<span class="rt-badge {runtimeStatusClass(runtimeState.status)}">
									<span class="rt-dot" aria-hidden="true"></span>
									{runtimeState.status || runtimeState.state || '—'}
								</span>
							</dd>

							<dt>{$t('apps.detail.runtimeState.lastCommit')}</dt>
							<dd>
								{#if runtimeState.last_commit_sha}
									<code class="mono-sha">{runtimeState.last_commit_sha.slice(0, 8)}</code>
								{:else}
									<span class="muted">—</span>
								{/if}
							</dd>

							<dt>{$t('apps.detail.runtimeState.lastSync')}</dt>
							<dd>
								{#if runtimeState.last_sync_at}
									<span class="mono-time">{$fmt.dateTime(runtimeState.last_sync_at)}</span>
								{:else}
									<span class="muted">—</span>
								{/if}
							</dd>

							{#if runtimeState.container_id}
								<dt>{$t('apps.detail.runtimeState.container')}</dt>
								<dd><code class="mono-sha">{runtimeState.container_id.slice(0, 12)}</code></dd>
							{/if}
						</dl>

						{#if runtimeState.last_error}
							<div class="alert inline-alert rt-error" role="alert">
								<span class="alert-tag">ERR</span><span>{runtimeState.last_error}</span>
							</div>
						{/if}
					{:else}
						<p class="hint">{$t('apps.detail.runtimeState.loading')}</p>
					{/if}
				</section>

				{#if storageEnabledOnSource}
					<section class="panel rt-card" aria-labelledby="rt-storage-heading">
						<span class="reg reg-tl" aria-hidden="true"></span>
						<span class="reg reg-tr" aria-hidden="true"></span>
						<span class="reg reg-bl" aria-hidden="true"></span>
						<span class="reg reg-br" aria-hidden="true"></span>

						<header class="panel-head">
							<h2 class="panel-title" id="rt-storage-heading">
								<IconHardDrive size={14} />
								<span>{$t('apps.detail.storage.title')}</span><span class="title-accent">.</span>
							</h2>
							<span class="panel-sub">{$t('apps.detail.storage.sub')}</span>
						</header>

						{#if storageError}
							<div class="alert inline-alert" role="alert">
								<span class="alert-tag">ERR</span><span>{storageError}</span>
							</div>
						{:else if storage}
							<dl class="kv-grid">
								<dt>{$t('apps.detail.storage.used')}</dt>
								<dd><span class="mono-time">{formatBytes(storage.used_bytes)}</span></dd>

								<dt>{$t('apps.detail.storage.limit')}</dt>
								<dd>
									{#if storage.limit_mb && storage.limit_mb > 0}
										<span class="mono-time">{storage.limit_mb} MB</span>
									{:else}
										<span class="muted">{$t('apps.detail.storage.unlimited')}</span>
									{/if}
								</dd>
							</dl>

							{#if storage.probe_error}
								<p class="rt-probe-note">{$t('apps.detail.storage.unavailable')}</p>
							{:else if storage.limit_mb && storage.limit_mb > 0}
								<div class="usage-bar" role="progressbar"
									aria-valuemin="0"
									aria-valuemax="100"
									aria-valuenow={Math.round(storageUsedPct * 100)}>
									<div
										class="usage-fill {usageBarClass(storageUsedPct)}"
										style:width={`${Math.round(storageUsedPct * 100)}%`}
									></div>
								</div>
								<p class="usage-caption">
									{Math.round(storageUsedPct * 100)}%
								</p>
							{/if}
						{:else}
							<p class="hint">{$t('apps.detail.storage.loading')}</p>
						{/if}
					</section>
				{/if}
			</div>
		{/if}

		<!-- ── Build logs (dockerfile workloads only) ─────────
		     Live tail of the docker daemon's NDJSON build stream for
		     dockerfile-source workloads. Hidden entirely for static /
		     image / compose kinds — they don't run a build step on the
		     server. Hidden until the first line arrives so the panel
		     doesn't take up space on never-built workloads. -->
		{#if !editing && workload.source_kind === 'dockerfile' && buildLogLines.length > 0}
			<section class="panel build-log-panel" aria-labelledby="build-log-heading">
				<span class="reg reg-tl" aria-hidden="true"></span>
				<span class="reg reg-tr" aria-hidden="true"></span>
				<span class="reg reg-bl" aria-hidden="true"></span>
				<span class="reg reg-br" aria-hidden="true"></span>

				<header class="panel-head build-log-head">
					<h2 class="panel-title" id="build-log-heading">
						<IconLoader size={14} />
						<span>{$t('apps.detail.buildLog.title')}</span><span class="title-accent">.</span>
					</h2>
					<button
						class="forge-btn-ghost build-log-clear"
						onclick={clearBuildLog}
						aria-label={$t('apps.detail.buildLog.clear')}
					>
						{$t('apps.detail.buildLog.clear')}
					</button>
				</header>

				<div
					class="build-log-tail"
					bind:this={buildLogTailEl}
					role="log"
					aria-live="polite"
					aria-relevant="additions"
				>
					{#each buildLogLines as line (line.id)}
						<div class="build-log-line">{line.text}</div>
					{/each}
				</div>
			</section>
		{/if}

		<!-- ── Resource usage (per-container CPU/memory) ─────
		     Renders one ContainerStats block per row in the workload's
		     containers table. Hidden entirely when the workload hasn't
		     materialized any container rows yet so the page doesn't
		     show an empty card. -->
		{#if !editing && containers.length > 0}
			<section class="panel stats-panel" aria-labelledby="stats-heading">
				<span class="reg reg-tl" aria-hidden="true"></span>
				<span class="reg reg-tr" aria-hidden="true"></span>
				<span class="reg reg-bl" aria-hidden="true"></span>
				<span class="reg reg-br" aria-hidden="true"></span>

				<header class="panel-head">
					<h2 class="panel-title" id="stats-heading">
						{$t('apps.detail.stats.title')}<span class="title-accent">.</span>
					</h2>
					<span class="panel-sub">
						{containers.length === 1
							? $t('apps.detail.stats.sub')
							: $t('apps.detail.stats.subMany', { count: String(containers.length) })}
					</span>
				</header>

				<ul class="stats-list">
					{#each containers as c (c.id)}
						{@const collapseByDefault = containers.length > 2}
						<li class="stats-row">
							{#if collapseByDefault}
								<!-- Compose stacks with N≥3 services would
								     otherwise stack a wall of charts. Collapse
								     each row into a native <details>; the
								     summary mirrors stats-row-head so the
								     visual rhythm matches the expanded variant. -->
								<details class="stats-collapse">
									<summary class="stats-row-head">
										<span class="stats-collapse-glyph" aria-hidden="true">›</span>
										<span class="stats-role mono">{c.role || c.image_ref || c.id.slice(0, 8)}</span>
										{#if c.container_id}
											<span class="stats-cid mono">{c.container_id.slice(0, 12)}</span>
										{/if}
									</summary>
									<ContainerStats
										source={{ kind: 'workload', workloadId: id, containerRowId: c.id }}
									/>
								</details>
							{:else}
								<div class="stats-row-head">
									<span class="stats-role mono">{c.role || c.image_ref || c.id.slice(0, 8)}</span>
									{#if c.container_id}
										<span class="stats-cid mono">{c.container_id.slice(0, 12)}</span>
									{/if}
								</div>
								<ContainerStats
									source={{ kind: 'workload', workloadId: id, containerRowId: c.id }}
								/>
							{/if}
						</li>
					{/each}
				</ul>
			</section>
		{/if}

		<!-- ── Webhook bindings summary (read-only) ──────────
		     Lists every binding whose underlying trigger has the inbound
		     webhook channel enabled. Read-only on purpose: rotate /
		     regenerate / test actions live on /triggers/[id]. -->
		<!-- Hide the entire section when there's nothing to show — the
		     bindings count chip in the lede already communicates the
		     empty case; an extra empty panel just adds scroll. -->
		{#if !editing && webhookBindings.length > 0}
			<section class="panel wh-panel" aria-labelledby="wh-heading">
				<span class="reg reg-tl" aria-hidden="true"></span>
				<span class="reg reg-tr" aria-hidden="true"></span>
				<span class="reg reg-bl" aria-hidden="true"></span>
				<span class="reg reg-br" aria-hidden="true"></span>

				<header class="panel-head">
					<h2 class="panel-title" id="wh-heading">
						<IconGlobe size={14} />
						<span>{$t('apps.detail.webhooks.title')}</span><span class="title-accent">.</span>
					</h2>
					<span class="panel-sub">{$t('apps.detail.webhooks.sub')}</span>
				</header>

				<ul class="wh-list">
					{#each webhookBindings as b (b.id)}
						<li class="wh-row" class:disabled={!b.enabled}>
							<span class="wh-icon" aria-hidden="true">
								<IconGlobe size={13} />
							</span>
							<!-- Name is plain text (no link) — the "Open
							     trigger" button on the right is the single
							     click target, so screen readers don't
							     announce two equivalent links per row. -->
							<span class="wh-name mono">
								{b.trigger_name || b.trigger_id}
							</span>
							<span class="wh-kind mono">
								{$t(`redeployTriggers.kindShort.${b.trigger_kind}`) ===
								`redeployTriggers.kindShort.${b.trigger_kind}`
									? b.trigger_kind.toUpperCase()
									: $t(`redeployTriggers.kindShort.${b.trigger_kind}`)}
							</span>
							{#if !b.enabled}
								<span class="wh-muted mono">{$t('apps.detail.webhooks.disabled')}</span>
							{/if}
							<a
								class="forge-btn-ghost xs wh-open"
								href={`/triggers/${b.trigger_id}`}
								title={$t('apps.detail.webhooks.openTrigger')}
							>
								<IconExternalLink size={12} />
								<span>{$t('apps.detail.webhooks.openTrigger')}</span>
							</a>
						</li>
					{/each}
				</ul>
			</section>
		{/if}

		<!-- ── Manual deploy ────────────────────────────── -->
		<section class="panel">
			<header class="panel-head">
				<h2 class="panel-title">{$t('apps.detail.manualDeployTitle')}<span class="title-accent">.</span></h2>
				<span class="panel-sub">{$t('apps.detail.manualDeploySub')}</span>
			</header>

			{#if lastDeployMsg}
				<div class="success">
					<span class="success-tag">{$t('apps.detail.manualDeployOk')}</span>
					<span>{lastDeployMsg}</span>
				</div>
			{/if}

			<div class="deploy-row">
				<label class="deploy-input" for="deploy-ref">
					<span class="sr-only">{$t('apps.detail.manualDeployRefAria')}</span>
					<input
						id="deploy-ref"
						type="text"
						bind:value={deployRef}
						placeholder={$t('apps.detail.manualDeployRefPlaceholder')}
						autocomplete="off"
						spellcheck="false"
					/>
				</label>
				<button class="forge-btn" onclick={deploy} disabled={deploying}>
					<IconDeploy size={14} />
					<span>{deploying ? $t('apps.detail.manualDeployDispatching') : $t('apps.detail.manualDeployButton')}</span>
				</button>
			</div>
			<p class="hint">{$t('apps.detail.manualDeployHint')}</p>
		</section>

		<!-- ── Triggers (bindings) ─────────────────────────
		     Replaces the legacy single-trigger panel. Lists every
		     binding with a per-row enabled toggle, a "View trigger"
		     deep-link, and an unbind action gated by ConfirmDialog.
		     "Add trigger" opens a modal with two tabs: inline-create
		     a new trigger record, or pick an existing one. -->
		{#if !editing}
			<section id="bindings" class="panel" aria-labelledby="trig-bindings-heading">
				<header class="panel-head split bindings-head">
					<div class="bindings-head-left">
						<h2 class="panel-title" id="trig-bindings-heading">
							{$t('apps.detail.bindings.title')}<span class="title-accent">.</span>
						</h2>
						<span class="panel-sub">
							{bindings.length === 0
								? $t('apps.detail.bindings.subEmpty')
								: bindings.length === 1
									? $t('apps.detail.bindings.subCount', { count: '1' })
									: $t('apps.detail.bindings.subCountMany', {
											count: String(bindings.length)
										})}
						</span>
					</div>
					<button
						type="button"
						class="forge-btn"
						onclick={openAddTriggerModal}
						aria-label={$t('apps.detail.bindings.addButton')}
					>
						<IconPlus size={13} />
						<span>{$t('apps.detail.bindings.addButton')}</span>
					</button>
				</header>

				{#if bindingsError}
					<div class="alert inline-alert" role="alert">
						<span class="alert-tag">ERR</span><span>{bindingsError}</span>
					</div>
				{/if}

				{#if bindingsLoading}
					<p class="hint">{$t('apps.detail.bindings.loading')}</p>
				{:else if bindings.length === 0}
					<div class="empty-inline">
						<span class="empty-mark" aria-hidden="true"></span>
						<span>{$t('apps.detail.bindings.subEmpty')}</span>
					</div>
				{:else}
					<ul class="bindings-list">
						{#each bindings as b, i (b.id)}
							{@const overrideOpen = openOverrideId === b.id}
							{@const overrideCount = overrideKeyCount(b.binding_config)}
							{@const editText = overrideTexts[b.id] ?? formatJsonValue(b.binding_config ?? {})}
							{@const baseText = overrideBaseConfigs[b.trigger_id] ?? ''}
							{@const baseLoading = overrideBaseLoading[b.trigger_id] === true}
							{@const editValid = overrideJsonValid(editText)}
							{@const editBytes = overrideTextSize(editText)}
							{@const editTooLarge = editBytes > BINDING_CONFIG_MAX_BYTES}
							{@const editKeys = overrideKeyCountFromText(editText)}
							{@const rowError = overrideErrors[b.id] ?? ''}
							{@const rowSaving = overrideSaving[b.id] === true}
							<li class="binding" class:has-override={overrideCount > 0} class:open={overrideOpen}>
								<div class="b-main">
									<span class="b-ref mono">
										{String(i + 1).padStart(2, '0')}
									</span>
									<a class="b-name" href={`/triggers/${b.trigger_id}`}>
										{b.trigger_name || b.trigger_id}
									</a>
									<span class="b-kind mono">
										{$t(`redeployTriggers.kindShort.${b.trigger_kind}`) ===
										`redeployTriggers.kindShort.${b.trigger_kind}`
											? b.trigger_kind.toUpperCase()
											: $t(`redeployTriggers.kindShort.${b.trigger_kind}`)}
									</span>
									<span
										class="b-state mono"
										class:on={b.enabled}
										class:off={!b.enabled}
									>
										{b.enabled
											? $t('apps.detail.bindings.rowEnabled')
											: $t('apps.detail.bindings.rowDisabled')}
									</span>
									{#if overrideCount > 0}
										<span
											class="b-override mono"
											title={$t('apps.detail.bindings.override.badgeTitle')}
										>
											{overrideCount === 1
												? $t('apps.detail.bindings.override.badgeOne')
												: $t('apps.detail.bindings.override.badgeMany', {
														count: String(overrideCount)
													})}
										</span>
									{/if}
								</div>
								<div class="b-actions">
									<ToggleSwitch
										checked={b.enabled}
										onchange={(next) => toggleBinding(b, next)}
										label={$t('apps.detail.bindings.rowEnabled')}
									/>
									<button
										type="button"
										class="forge-btn-ghost xs override-btn"
										class:active={overrideOpen}
										class:has-override={overrideCount > 0}
										onclick={() => toggleOverridePanel(b)}
										aria-expanded={overrideOpen}
										aria-controls={`override-panel-${b.id}`}
										title={$t('apps.detail.bindings.override.toggle')}
									>
										<IconEdit size={12} />
										<span>{$t('apps.detail.bindings.override.toggle')}</span>
										<IconChevronDown size={12} class="chev" />
									</button>
									<a
										class="forge-btn-icon"
										href={`/triggers/${b.trigger_id}`}
										aria-label={$t('apps.detail.bindings.openTrigger')}
										title={$t('apps.detail.bindings.openTrigger')}
									>
										<IconExternalLink size={14} />
									</a>
									<button
										type="button"
										class="forge-btn-icon danger"
										onclick={() => (confirmUnbindId = b.id)}
										aria-label={$t('apps.detail.bindings.unbindAction')}
										title={$t('apps.detail.bindings.unbindAction')}
									>
										<IconTrash size={14} />
									</button>
								</div>

								{#if overrideOpen}
									<div
										class="override-panel"
										id={`override-panel-${b.id}`}
										role="region"
										aria-label={$t('apps.detail.bindings.override.title')}
									>
										<header class="op-head">
											<h3 class="op-title">
												{$t('apps.detail.bindings.override.title')}
											</h3>
											<p class="op-sub">
												{$t('apps.detail.bindings.override.subtitle')}
											</p>
										</header>

										{#if rowError}
											<div class="alert inline-alert" role="alert">
												<span class="alert-tag">ERR</span><span>{rowError}</span>
											</div>
										{/if}

										<div class="op-grid">
											<section class="op-col">
												<div class="op-col-head">
													<span class="op-col-label">
														{$t('apps.detail.bindings.override.baseLabel')}
													</span>
													<span class="op-col-tag mono">READ-ONLY</span>
												</div>
												{#if baseLoading}
													<div class="op-readonly mono dim">
														{$t('apps.detail.bindings.override.baseLoading')}
													</div>
												{:else if baseText}
													<pre class="op-readonly mono">{baseText}</pre>
												{:else}
													<pre class="op-readonly mono dim">{'{}'}</pre>
												{/if}
												<p class="op-hint">
													{$t('apps.detail.bindings.override.baseHint')}
												</p>
											</section>

											<section class="op-col">
												<div class="op-col-head">
													<span class="op-col-label">
														{$t('apps.detail.bindings.override.editLabel')}
													</span>
														<span
														class="op-col-tag mono"
														class:warn={editTooLarge}
														class:bad={!editValid}
													>
														{editKeys}&nbsp;{editKeys === 1 ? $t('apps.detail.overrideKeyUnitSingular') : $t('apps.detail.overrideKeyUnitPlural')}
													</span>
												</div>
												<textarea
													class="op-editor mono"
													class:bad={!editValid}
													class:warn={editTooLarge}
													bind:value={overrideTexts[b.id]}
													rows="8"
													spellcheck="false"
													placeholder={'{}'}
													aria-invalid={!editValid}
												></textarea>
												<div class="op-meta">
													{#if !editValid}
														<span class="op-meta-msg bad">
															{$t('apps.detail.bindings.override.invalidJson')}
														</span>
													{:else if editTooLarge}
														<span class="op-meta-msg bad">
															{$t('apps.detail.bindings.override.tooLarge', {
																size: String(editBytes),
																limit: String(BINDING_CONFIG_MAX_BYTES)
															})}
														</span>
													{:else}
														<span class="op-meta-msg">
															{$t('apps.detail.bindings.override.editHint')}
														</span>
													{/if}
													<span class="op-meta-bytes mono" class:warn={editTooLarge}>
														{editBytes}/{BINDING_CONFIG_MAX_BYTES}B
													</span>
												</div>
											</section>
										</div>

										{#if editValid && !editTooLarge && baseText}
											<section class="op-preview">
												<div class="op-col-head">
													<span class="op-col-label">
														{$t('apps.detail.bindings.override.previewLabel')}
													</span>
													<span class="op-col-tag mono accent">EFFECTIVE</span>
												</div>
												<pre class="op-readonly mono">{mergedPreview(baseText, editText)}</pre>
												<p class="op-hint">
													{$t('apps.detail.bindings.override.previewHint')}
												</p>
											</section>
										{/if}

										<footer class="op-actions">
											<button
												type="button"
												class="forge-btn-ghost xs"
												onclick={() => resetOverride(b)}
												disabled={rowSaving || overrideCount === 0}
											>
												{$t('apps.detail.bindings.override.resetButton')}
											</button>
											<div class="op-actions-right">
												<button
													type="button"
													class="forge-btn-ghost"
													onclick={() => (openOverrideId = null)}
													disabled={rowSaving}
												>
													{$t('apps.detail.bindings.override.closeButton')}
												</button>
												<button
													type="button"
													class="forge-btn"
													onclick={() => saveOverride(b)}
													disabled={rowSaving || !editValid || editTooLarge}
													aria-busy={rowSaving}
												>
													{rowSaving
														? $t('apps.detail.bindings.override.saving')
														: $t('apps.detail.bindings.override.saveButton')}
												</button>
											</div>
										</footer>
									</div>
								{/if}
							</li>
						{/each}
					</ul>
					<p class="hint">{$t('apps.detail.bindings.rowEnableHint')}</p>
				{/if}
			</section>
		{/if}

		<!-- ── Containers ───────────────────────────────── -->
		<section class="panel">
			<header class="panel-head">
				<h2 class="panel-title">{$t('apps.detail.containersTitle')}<span class="title-accent">.</span></h2>
				<span class="panel-sub">
					{containers.length === 0
						? $t('apps.detail.containersEmpty')
						: $t('apps.detail.containersCount', { count: String(containers.length) })}
				</span>
			</header>
			{#if containers.length === 0}
				<div class="empty-inline">
					<span class="empty-mark" aria-hidden="true"></span>
					<span>{$t('apps.detail.containersEmptyInline')}</span>
				</div>
			{:else}
				{#if logContainerRowID}
					<div class="logs-mount">
						<ContainerLogs
							source={{
								kind: 'workload',
								workloadId: id,
								containerRowId: logContainerRowID
							}}
							onclose={() => (logContainerRowID = null)}
						/>
					</div>
				{/if}
				<div class="table-wrap">
					<table class="forge-table">
						<thead>
							<tr>
								<th>{$t('apps.detail.containersColRole')}</th>
								<th>{$t('apps.detail.containersColState')}</th>
								<th>{$t('apps.detail.containersColImage')}</th>
								<th>{$t('apps.detail.containersColSubdomain')}</th>
								<th>{$t('apps.detail.containersColLastSeen')}</th>
								<th class="t-right">{$t('apps.detail.containersColActions')}</th>
							</tr>
						</thead>
						<tbody>
							{#each containers as c (c.id)}
								<tr>
									<td>{c.role || '—'}</td>
									<td>
										<span class="state-pill {stateClass(c.state)}">
											<span class="pulse" aria-hidden="true"></span>
											{c.state}
										</span>
									</td>
									<td class="mono">{c.image_ref || '—'}</td>
									<td class="mono">{c.subdomain || '—'}</td>
									<td class="muted mono">{c.last_seen_at || '—'}</td>
									<td class="t-right">
										{#if c.container_id}
											<button
												class="forge-btn-ghost"
												onclick={() => (logContainerRowID = c.id)}
												aria-label={`${$t('apps.detail.containersLogsAction')}: ${c.role || c.id}`}
											>
												<IconServer size={13} />
												<span>{$t('apps.detail.containersLogsAction')}</span>
											</button>
										{:else}
											<span class="muted mono">—</span>
										{/if}
									</td>
								</tr>
							{/each}
						</tbody>
					</table>
				</div>
			{/if}
		</section>

		<!-- ── Stages chain ─────────────────────────────── -->
		{#if !editing && chain && (chain.parent || chain.children.length > 0)}
			<section class="panel">
				<header class="panel-head">
					<h2 class="panel-title">{$t('apps.detail.chainTitle')}<span class="title-accent">.</span></h2>
					<span class="panel-sub">
						{chain.parent ? $t('apps.detail.chainSubFromParent') : $t('apps.detail.chainSubParentOf')}
						{chain.children.length}
						{chain.children.length === 1 ? $t('apps.detail.chainChildSingular') : $t('apps.detail.chainChildPlural')}
					</span>
				</header>
				{#if chainError}
					<div class="alert inline-alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{chainError}</span></div>
				{/if}

				{#if chain.parent}
					<div class="chain-row">
						<span class="chain-label">{$t('apps.detail.chainParentLabel')}</span>
						<a class="chain-card" href={`/apps/${chain.parent.id}`}>
							<span class="chain-name">{chain.parent.name}</span>
							<span class="mono muted">{chain.parent.source_kind}</span>
						</a>
						{#if workload?.source_kind === 'image' && chain.parent.source_kind === 'image'}
							<button
								class="forge-btn-ghost"
								disabled={promoting !== null}
								onclick={() =>
									askPromote(id, chain!.parent!.id, workload?.name ?? '', chain!.parent!.name)}
							>
								{promoting === id ? $t('apps.detail.chainPromoting') : $t('apps.detail.chainPromoteButton')}
							</button>
						{/if}
					</div>
				{/if}

				<div class="chain-row">
					<span class="chain-label">{$t('apps.detail.chainSelfLabel')}</span>
					<div class="chain-card chain-self">
						<span class="chain-name">{workload?.name ?? '—'}</span>
						<span class="mono muted">
							{workload?.source_kind ?? ''} · {bindings.length === 0
								? $t('apps.detail.chainTriggersZero')
								: bindings.length === 1
									? $t('apps.detail.chainTriggersOne')
									: $t('apps.detail.chainTriggersMany', {
											count: String(bindings.length)
										})}
						</span>
					</div>
				</div>

				{#if chain.children.length > 0}
					<div class="chain-row chain-children">
						<span class="chain-label">{$t('apps.detail.chainChildrenLabel')}</span>
						<div class="chain-children-list">
							{#each chain.children as child (child.id)}
								<div class="chain-child-item">
									<a class="chain-card" href={`/apps/${child.id}`}>
										<span class="chain-name">
											{child.name}
											{#if child.is_preview}
												<span class="preview-tag" title={$t('apps.detail.previews.tagTitle')}
													>{$t('apps.detail.previews.tag')}</span
												>
											{/if}
										</span>
										<span class="mono muted">{child.source_kind}</span>
									</a>
									{#if workload?.source_kind === 'image' && child.source_kind === 'image' && !child.is_preview}
										<button
											class="forge-btn-ghost"
											disabled={promoting !== null}
											onclick={() => askPromote(child.id, id, child.name, workload?.name ?? '')}
										>
											{promoting === child.id
												? $t('apps.detail.chainPromoting')
												: $t('apps.detail.chainPromoteToChild')}
										</button>
									{/if}
								</div>
							{/each}
						</div>
					</div>
				{/if}
				<p class="hint">{@html $t('apps.detail.chainHint')}</p>
			</section>
		{/if}

		<!-- ── Branch preview environments ──────────────────
		     Lists the per-branch previews materialized from this workload.
		     Shows when there's at least one live preview OR the workload is
		     "armed" (a git-trigger binding carries a branch_pattern) so the
		     operator gets a clear "push to deploy a preview" cue. -->
		{#if !editing && (previewChildren.length > 0 || isPreviewArmed)}
			<section class="panel" aria-labelledby="previews-heading">
				<header class="panel-head">
					<h2 class="panel-title" id="previews-heading">
						{$t('apps.detail.previews.title')}<span class="title-accent">.</span>
					</h2>
					<span class="panel-sub">
						{previewChildren.length === 0
							? $t('apps.detail.previews.subEmpty')
							: previewChildren.length === 1
								? $t('apps.detail.previews.subCountOne')
								: $t('apps.detail.previews.subCount', {
										count: String(previewChildren.length)
									})}
					</span>
				</header>

				{#if previewChildren.length === 0}
					<!-- Armed-but-empty state. -->
					<div class="preview-empty">
						<IconGlobe size={15} />
						{#if armedBranchPattern}
							<span
								>{$t('apps.detail.previews.armedEmpty')}
								<code>{armedBranchPattern}</code></span
							>
						{:else}
							<span>{$t('apps.detail.previews.noneEmpty')}</span>
						{/if}
					</div>
				{:else}
					<div class="preview-list">
						{#each previewChildren as child (child.id)}
							{@const meta = previewMeta[child.id]}
							{@const st = meta?.state ?? ''}
							<div class="preview-row">
								<span class="preview-state rt-badge {liveBadgeToneClass(st)}" title={st || $t('apps.detail.previews.stateUnknown')}>
									<span class="rt-dot" aria-hidden="true"></span>
									{previewStateLabel(st)}
								</span>
								<a class="preview-branch mono" href={`/apps/${child.id}`} title={child.name}>
									{child.preview_branch || child.name}
								</a>
								<span class="preview-spacer"></span>
								{#if meta && meta.fqdn}
									<a
										class="forge-btn-ghost open-ext"
										href={`https://${meta.fqdn}`}
										target="_blank"
										rel="noopener noreferrer"
										title={meta.fqdn}
									>
										<IconExternalLink size={13} />
										<span>{$t('apps.detail.previews.open')}</span>
										<span class="open-ext-glyph" aria-hidden="true">↗</span>
									</a>
								{:else if meta && meta.loaded}
									<span class="preview-nourl">{$t('apps.detail.previews.noUrl')}</span>
								{:else}
									<span class="preview-nourl"><IconLoader size={13} /></span>
								{/if}
								<button
									class="forge-btn-ghost danger"
									onclick={() => (confirmTeardownId = child.id)}
									aria-label={$t('apps.detail.previews.teardown')}
								>
									<IconTrash size={13} />
									<span>{$t('apps.detail.previews.teardown')}</span>
								</button>
							</div>
						{/each}
					</div>
				{/if}
				{#if chainError}
					<div class="alert inline-alert" role="alert">
						<span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{chainError}</span>
					</div>
				{/if}
				<p class="hint">{@html $t('apps.detail.previews.hint')}</p>
			</section>
		{/if}

		<!-- ── Activity timeline (recent deploys + events) ──
		     Read-only feed of the most recent workload-scoped events, kept
		     live by the global SSE stream's onEventLog callback. -->
		{#if !editing}
			<section class="panel" aria-labelledby="activity-heading">
				<header class="panel-head">
					<h2 class="panel-title" id="activity-heading">
						{$t('apps.detail.activity.title')}<span class="title-accent">.</span>
					</h2>
					<span class="panel-sub">{$t('apps.detail.activity.subtitle')}</span>
					{#if activityEvents.length > 0}
						<div class="ml-auto inline-flex items-center rounded-lg bg-[var(--surface-card-hover)] p-0.5">
							<button
								type="button"
								aria-pressed={activitySeverity === 'all'}
								class="rounded-md px-2.5 py-1 text-xs font-medium transition-all duration-150
									{activitySeverity === 'all'
										? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-[var(--shadow-sm)]'
										: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'}"
								onclick={() => { activitySeverity = 'all'; }}
							>
								{$t('apps.detail.activity.filterAll')}
							</button>
							<button
								type="button"
								aria-pressed={activitySeverity === 'error'}
								class="rounded-md px-2.5 py-1 text-xs font-medium transition-all duration-150
									{activitySeverity === 'error'
										? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-[var(--shadow-sm)]'
										: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'}"
								onclick={() => { activitySeverity = 'error'; }}
							>
								{$t('apps.detail.activity.filterErrors')}
							</button>
						</div>
					{/if}
				</header>

				{#if activityLoading}
					<div class="flex items-center justify-center py-16">
						<IconLoader size={20} class="animate-spin text-[var(--color-brand-500)]" />
					</div>
				{:else if activityEvents.length === 0}
					<p class="hint">{$t('apps.detail.activity.empty')}</p>
				{:else if visibleActivity.length === 0}
					<p class="hint">{$t('apps.detail.activity.noErrors')}</p>
				{:else}
					<div
						class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] divide-y divide-[var(--border-secondary)]"
					>
						{#each visibleActivity as entry (entry.id)}
							<EventLogEntryComponent {entry} isNew={activityNewIds.has(entry.id)} />
						{/each}
					</div>
				{/if}

				{#if !activityLoading && activityHasMore}
					<div class="flex justify-center pt-2 pb-1">
						<button
							type="button"
							class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] disabled:opacity-50"
							onclick={loadMoreActivity}
							disabled={activityLoadingMore}
						>
							{#if activityLoadingMore}
								<IconLoader size={16} class="animate-spin" />
							{/if}
							{$t('apps.detail.activity.loadMore')}
						</button>
					</div>
				{/if}
			</section>
		{/if}

		<!-- ── Per-workload metrics (CPU/memory) ──────────── -->
		{#if !editing}
			<WorkloadMetricsPanel workloadId={id} />
		{/if}

		<!-- ── Deploy history + rollback ──────────────────── -->
		{#if !editing}
			<DeployHistoryPanel workloadId={id} />
		{/if}

		<!-- ── GitOps config-as-code (dockerfile/static) ──── -->
		{#if !editing && (workload.source_kind === 'dockerfile' || workload.source_kind === 'static')}
			<GitOpsPanel
				workloadId={id}
				sourceKind={workload.source_kind}
				{isAdmin}
				onSynced={load}
			/>
		{/if}

		<!-- ── Per-workload notification routes ───────────── -->
		{#if !editing}
			<WorkloadNotificationsPanel workloadId={id} />
		{/if}

		<!-- ── Data-volume snapshots (capture) ────────────── -->
		{#if !editing}
			<WorkloadSnapshotsPanel workloadId={id} />
		{/if}

		<!-- ── Log scan rules (effective set) ─────────────── -->
		{#if !editing}
			<section class="panel" aria-labelledby="log-rules-heading">
				<header class="panel-head">
					<h2 class="panel-title" id="log-rules-heading">
						{$t('logscan.panel.heading')}<span class="title-accent">.</span>
					</h2>
					<span class="panel-sub">
						{logRules.length === 0
							? $t('logscan.panel.subEmpty')
							: logRules.length === 1
								? $t('logscan.panel.subCountOne')
								: $t('logscan.panel.subCount', { count: String(logRules.length) })}
						· <a href="/log-scan-rules">{$t('observability.manage')}</a>
					</span>
				</header>
				{#if logRulesError}
					<div class="alert inline-alert" role="alert">
						<span class="alert-tag">ERR</span><span>{logRulesError}</span>
					</div>
				{/if}
				{#if logRules.length === 0}
					<p class="hint">
						{$t('logscan.panel.emptyHint')}
						<a href="/log-scan-rules/new">{$t('logscan.panel.newRule')}</a>.
					</p>
				{:else}
					<div class="table-wrap">
						<table class="forge-table">
							<thead>
								<tr>
									<th>{$t('logscan.list.name')}</th>
									<th>{$t('logscan.list.pattern')}</th>
									<th>{$t('logscan.list.scope')}</th>
									<th>{$t('logscan.list.severity')}</th>
									<th>{$t('logscan.list.streams')}</th>
									<th>{$t('logscan.list.status')}</th>
									<th class="t-right">{$t('triggers.list.action')}</th>
								</tr>
							</thead>
							<tbody>
								{#each logRules as r (r.id)}
									{@const kind = classifyRule(r)}
									<tr>
										<td>
											<a class="row-link" href={`/log-scan-rules/${r.id}`}>
												<span class="row-name">{r.name}</span>
											</a>
										</td>
										<td class="muted mono small log-pattern">/{r.pattern}/</td>
										<td>
											<span class="badge scope-{kind}">{$t(`logscan.filter.${kind === 'override' ? 'overrides' : kind}`).toLowerCase()}</span>
										</td>
										<td>
											<span class="severity sev-{r.severity}">{r.severity}</span>
										</td>
										<td class="mono small muted">{r.streams}</td>
										<td>
											<span class="status" class:on={r.enabled} class:off={!r.enabled}>
												<span class="status-dot" aria-hidden="true"></span>
												{r.enabled ? $t('logscan.status.on') : $t('logscan.status.off')}
											</span>
										</td>
										<td class="actions-cell">
											{#if kind === 'global'}
												<button
													type="button"
													class="forge-btn-ghost xs"
													onclick={() => overrideRule(r)}
													disabled={logRuleOverriding !== null}
													aria-busy={logRuleOverriding === r.id}
													title={$t('logscan.panel.overrideTitle')}
												>
													{logRuleOverriding === r.id
														? $t('logscan.panel.overriding')
														: $t('logscan.panel.override')}
												</button>
											{:else}
												<a class="row-action" href={`/log-scan-rules/${r.id}`}>
													{$t('observability.edit')} <span class="arrow" aria-hidden="true">→</span>
												</a>
											{/if}
										</td>
									</tr>
								{/each}
							</tbody>
						</table>
					</div>
					<p class="hint">{$t('logscan.panel.footerHint')}</p>
				{/if}
			</section>
		{/if}

		<!-- ── Volumes ──────────────────────────────────── -->
		{#if !editing}
			<section class="panel">
				<header class="panel-head">
					<h2 class="panel-title">{$t('apps.detail.volumesTitle')}<span class="title-accent">.</span></h2>
					<span class="panel-sub">
						{volumeRows.length === 0
							? $t('apps.detail.volumesEmpty')
							: volumeRows.length === 1
								? $t('apps.detail.volumesCountSingular', { count: '1' })
								: $t('apps.detail.volumesCountPlural', { count: String(volumeRows.length) })}
					</span>
				</header>
				{#if volumeError}
					<div class="alert inline-alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{volumeError}</span></div>
				{/if}
				{#if volumeRows.length > 0}
					<div class="table-wrap">
						<table class="forge-table">
							<thead>
								<tr>
									<th>{$t('apps.detail.volumesColTarget')}</th>
									<th>{$t('apps.detail.volumesColSource')}</th>
									<th>{$t('apps.detail.volumesColScope')}</th>
									<th class="t-right">{$t('apps.detail.volumesColUpdated')}</th>
									<th class="t-right">{$t('apps.detail.volumesColActions')}</th>
								</tr>
							</thead>
							<tbody>
								{#each volumeRows as v (v.id)}
									<tr>
										<td class="mono">{v.target}</td>
										<td class="mono">{v.source || '—'}</td>
										<td>
											<span class="state-pill st-encrypted">{v.scope}</span>
										</td>
										<td class="t-right muted mono">{v.updated_at}</td>
										<td class="t-right">
											<button
												class="forge-btn-ghost danger"
												onclick={() => removeVolume(v.id)}
												aria-label={`${$t('apps.detail.volumesColActions')}: ${v.target}`}
											>
												<IconTrash size={13} />
											</button>
										</td>
									</tr>
								{/each}
							</tbody>
						</table>
					</div>
				{/if}
				<form
					class="env-add"
					onsubmit={(ev) => {
						ev.preventDefault();
						addVolume();
					}}
				>
					<label class="env-field">
						<span>{$t('apps.detail.volumeSource')}</span>
						<input
							type="text"
							bind:value={newVolSource}
							placeholder={$t('apps.detail.volumeSourcePlaceholder')}
						/>
					</label>
					<label class="env-field">
						<span>{$t('apps.detail.volumeTarget')}</span>
						<input
							type="text"
							bind:value={newVolTarget}
							placeholder={$t('apps.detail.volumeTargetPlaceholder')}
							required
						/>
					</label>
					<label class="env-field">
						<span>{$t('apps.detail.volumeScope')}</span>
						<select bind:value={newVolScope}>
							<option value="absolute">absolute</option>
							<option value="named">named</option>
							<option value="project_named">project_named</option>
							<option value="ephemeral">ephemeral</option>
						</select>
					</label>
					<button class="forge-btn" type="submit" disabled={volumeSaving || !newVolTarget.trim()}>
						{volumeSaving ? $t('apps.detail.volumeSaving') : $t('apps.detail.volumeAddButton')}
					</button>
				</form>
				<p class="hint">{$t('apps.detail.volumeHint')}</p>
			</section>

		{/if}

		<!-- ── Env vars ─────────────────────────────────── -->
		{#if !editing}
			<section class="panel">
				<header class="panel-head">
					<h2 class="panel-title">{$t('apps.detail.envTitle')}<span class="title-accent">.</span></h2>
					<span class="panel-sub">
						{envRows.length === 0
							? $t('apps.detail.envEmpty')
							: envRows.length === 1
								? $t('apps.detail.envCountSingular', { count: '1' })
								: $t('apps.detail.envCountPlural', { count: String(envRows.length) })}
					</span>
				</header>
				{#if envError}
					<div class="alert inline-alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{envError}</span></div>
				{/if}
				{#if envRows.length > 0}
					<div class="table-wrap">
						<table class="forge-table">
							<thead>
								<tr>
									<th>{$t('apps.detail.envColKey')}</th>
									<th>{$t('apps.detail.envColValue')}</th>
									<th class="t-right">{$t('apps.detail.envColUpdated')}</th>
									<th class="t-right">{$t('apps.detail.envColActions')}</th>
								</tr>
							</thead>
							<tbody>
								{#each envRows as e (e.id)}
									<tr>
										<td class="mono">{e.key}</td>
										<td>
											{#if e.encrypted}
												<span class="state-pill st-encrypted">
													<span class="pulse" aria-hidden="true"></span>
													{$t('apps.detail.envEncrypted')}
												</span>
											{:else}
												<span class="mono">{e.value || '—'}</span>
											{/if}
										</td>
										<td class="t-right muted mono">{e.updated_at}</td>
										<td class="t-right">
											<button
												class="forge-btn-ghost danger"
												onclick={() => removeEnv(e.id)}
												aria-label={`${$t('apps.detail.envColActions')}: ${e.key}`}
											>
												<IconTrash size={13} />
											</button>
										</td>
									</tr>
								{/each}
							</tbody>
						</table>
					</div>
				{/if}
				<form
					class="env-add"
					onsubmit={(ev) => {
						ev.preventDefault();
						addEnv();
					}}
				>
					<label class="env-field">
						<span>{$t('apps.detail.envKey')}</span>
						<input
							type="text"
							bind:value={newEnvKey}
							placeholder={$t('apps.detail.envKeyPlaceholder')}
							pattern="[A-Za-z_][A-Za-z0-9_]*"
							required
						/>
					</label>
					<label class="env-field">
						<span>{$t('apps.detail.envValue')}</span>
						<input type="text" bind:value={newEnvValue} placeholder={$t('apps.detail.envValuePlaceholder')} />
					</label>
					<label class="env-toggle">
						<ToggleSwitch bind:checked={newEnvEncrypted} label={$t('apps.detail.envEncryptLabel')} />
						<span>{$t('apps.detail.envEncryptLabel')}</span>
					</label>
					<button class="forge-btn" type="submit" disabled={envSaving || !newEnvKey.trim()}>
						{envSaving ? $t('apps.detail.envSaving') : $t('apps.detail.envAddButton')}
					</button>
				</form>
				<p class="hint">{$t('apps.detail.envHint')}</p>
			</section>

			<!-- Webhook URL panel removed — inbound webhooks live on
			     the bound Triggers panel above. The trigger detail page
			     (/triggers/{id}) carries the URL + rotate action. -->
		{/if}

		<!-- ── Config viewers ───────────────────────────── -->
		{#if !editing}
			<section class="panel code-panel">
				<header class="panel-head split">
					<button
						type="button"
						class="head-toggle"
						onclick={() => (openSource = !openSource)}
						aria-expanded={openSource}
						aria-controls="src-cfg"
					>
						<span class="chev" class:rot={!openSource} aria-hidden="true">
							<IconChevronDown size={16} />
						</span>
						<h2 class="panel-title">{$t('apps.detail.sourceConfigTitle')}<span class="title-accent">.</span></h2>
						<span class="panel-sub mono">{workload.source_kind}</span>
					</button>
					<button
						type="button"
						class="copy-btn"
						onclick={() => copyToClipboard('source', prettyJson(workload!.source_config))}
						aria-label={$t('apps.detail.sourceConfigCopyAria')}
					>
						{#if copied.source}
							<IconCheck size={13} />
						{:else}
							<IconCopy size={13} />
						{/if}
						<span>{copied.source ? $t('apps.detail.sourceConfigCopied') : $t('apps.detail.sourceConfigCopy')}</span>
					</button>
				</header>
				{#if openSource}
					<div id="src-cfg" class="code-body">
						<pre class="code-view">{prettyJson(workload.source_config)}</pre>
					</div>
				{/if}
			</section>

			{#if workload.public_faces && workload.public_faces !== '[]'}
				<section class="panel code-panel">
					<header class="panel-head split">
						<button
							type="button"
							class="head-toggle"
							onclick={() => (openFaces = !openFaces)}
							aria-expanded={openFaces}
							aria-controls="faces-cfg"
						>
							<span class="chev" class:rot={!openFaces} aria-hidden="true">
								<IconChevronDown size={16} />
							</span>
							<h2 class="panel-title">{$t('apps.detail.publicFacesTitle')}<span class="title-accent">.</span></h2>
						</button>
						<button
							type="button"
							class="copy-btn"
							onclick={() => copyToClipboard('faces', prettyJson(workload!.public_faces))}
							aria-label={$t('apps.detail.publicFacesCopyAria')}
						>
							{#if copied.faces}
								<IconCheck size={13} />
							{:else}
								<IconCopy size={13} />
							{/if}
							<span>{copied.faces ? $t('apps.detail.sourceConfigCopied') : $t('apps.detail.sourceConfigCopy')}</span>
						</button>
					</header>
					{#if openFaces}
						<div id="faces-cfg" class="code-body">
							<pre class="code-view">{prettyJson(workload.public_faces)}</pre>
						</div>
					{/if}
				</section>
			{/if}
		{/if}
	{/if}
</div>

<ConfirmDialog
	open={confirmDelete}
	title={$t('apps.detail.deleteConfirmTitle')}
	message={$t('apps.detail.deleteConfirmMessage', {
		name: workload?.name ?? $t('apps.detail.deleteConfirmFallbackName')
	})}
	confirmLabel={deleting ? $t('apps.detail.deleteConfirmDeleting') : $t('apps.detail.deleteConfirmYes')}
	confirmVariant="danger"
	onconfirm={doDelete}
	oncancel={() => {
		if (!deleting) confirmDelete = false;
	}}
/>

<ConfirmDialog
	open={confirmPromote !== null}
	title={$t('apps.detail.chainPromoteConfirmTitle')}
	message={$t('apps.detail.chainPromoteConfirmMessage', {
		source: confirmPromote?.sourceName ?? '',
		target: confirmPromote?.targetName ?? ''
	})}
	confirmLabel={$t('apps.detail.chainPromoteConfirmYes')}
	confirmVariant="primary"
	onconfirm={doPromote}
	oncancel={() => (confirmPromote = null)}
/>

<ConfirmDialog
	open={confirmUnbindId !== null}
	title={$t('apps.detail.bindings.unbindTitle')}
	message={$t('apps.detail.bindings.unbindMessage', {
		name: unbindTarget?.trigger_name || unbindTarget?.trigger_id || ''
	})}
	confirmLabel={$t('apps.detail.bindings.unbindConfirm')}
	confirmVariant="danger"
	onconfirm={doUnbind}
	oncancel={() => (confirmUnbindId = null)}
/>

<ConfirmDialog
	open={confirmTeardownId !== null}
	title={$t('apps.detail.previews.teardownTitle')}
	message={$t('apps.detail.previews.teardownMessage', {
		name: teardownTarget?.preview_branch || teardownTarget?.name || ''
	})}
	confirmLabel={tearingDown
		? $t('apps.detail.previews.teardownPending')
		: $t('apps.detail.previews.teardownConfirm')}
	confirmVariant="danger"
	onconfirm={doTeardownPreview}
	oncancel={() => {
		if (!tearingDown) confirmTeardownId = null;
	}}
/>

<!-- ── Add-trigger modal ──────────────────────────────
     Tabbed dialog: "Create new" mounts the shared TriggerKindForm
     (kind picker + per-kind fields + webhook toggles); "Bind
     existing" mounts a kind filter + select. Submit posts to
     /api/workloads/{id}/triggers with either an inline trigger
     spec or an existing trigger id, then refreshes bindings. -->
{#if addModalOpen}
	<div
		class="fixed inset-0 z-40 bg-[var(--surface-overlay)] animate-fade-in"
		role="presentation"
		onclick={closeAddTriggerModal}
	></div>
	<div class="fixed inset-0 z-50 flex items-start justify-center p-4 overflow-y-auto modal-wrap">
		<div class="add-modal animate-scale-in">
			<header class="add-head">
				<div class="add-head-text">
					<h2 class="add-title">{$t('apps.detail.bindings.modal.title')}</h2>
					<p class="add-sub">{$t('apps.detail.bindings.modal.subtitle')}</p>
				</div>
				<button
					type="button"
					class="forge-btn-icon"
					onclick={closeAddTriggerModal}
					aria-label={$t('apps.detail.bindings.modal.cancel')}
					title={$t('apps.detail.bindings.modal.cancel')}
					disabled={addModalSubmitting}
				>
					<IconX size={14} />
				</button>
			</header>

			<div class="add-tabs" role="tablist" aria-label={$t('apps.detail.bindings.modal.title')}>
				<button
					type="button"
					role="tab"
					aria-selected={addModalTab === 'inline'}
					class="add-tab"
					class:active={addModalTab === 'inline'}
					onclick={() => switchAddTab('inline')}
				>
					{$t('apps.detail.bindings.modal.tabInline')}
				</button>
				<button
					type="button"
					role="tab"
					aria-selected={addModalTab === 'pick'}
					class="add-tab"
					class:active={addModalTab === 'pick'}
					onclick={() => switchAddTab('pick')}
				>
					{$t('apps.detail.bindings.modal.tabPick')}
				</button>
			</div>

			{#if addModalError}
				<div class="alert inline-alert" role="alert">
					<span class="alert-tag">ERR</span><span>{addModalError}</span>
				</div>
			{/if}

			<div class="add-body">
				{#if addModalTab === 'inline'}
					<TriggerKindForm
						bind:state={inlineTriggerForm}
						idPrefix="add-inline"
						showName={true}
						showWebhook={true}
						showKindPicker={true}
					/>
				{:else if availableTriggers.length === 0}
					<div class="empty-inline">
						<span class="empty-mark" aria-hidden="true"></span>
						<span>{$t('apps.detail.bindings.modal.pickEmpty')}</span>
					</div>
				{:else}
					<div class="pick-row">
						<label class="sub" for="pick-kind">
							<span class="sub-label">
								{$t('apps.detail.bindings.modal.pickKind')}
							</span>
							<select id="pick-kind" class="input" bind:value={pickKindFilter}>
								<option value="">
									{$t('apps.detail.bindings.modal.pickKindAll')}
								</option>
								<option value="registry">
									{triggerKindLabel('registry')}
								</option>
								<option value="git">{triggerKindLabel('git')}</option>
								<option value="manual">{triggerKindLabel('manual')}</option>
							</select>
						</label>
						<label class="sub" for="pick-trigger">
							<span class="sub-label">
								{$t('apps.detail.bindings.modal.pickLabel')}
							</span>
							<select id="pick-trigger" class="input" bind:value={pickTriggerId}>
								<option value="">
									{$t('apps.detail.bindings.modal.pickPlaceholder')}
								</option>
								{#each availableTriggersFiltered as tr (tr.id)}
									<option value={tr.id}>
										{tr.name} · {tr.kind}{tr.webhook_enabled
											? ' · ' + $t('apps.new.triggers.pickWebhookOn')
											: ''}
									</option>
								{/each}
							</select>
						</label>
					</div>
				{/if}
			</div>

			<footer class="add-actions">
				<button
					type="button"
					class="forge-btn-ghost"
					onclick={closeAddTriggerModal}
					disabled={addModalSubmitting}
				>
					{$t('apps.detail.bindings.modal.cancel')}
				</button>
				<button
					type="button"
					class="forge-btn"
					onclick={submitAddTrigger}
					disabled={!addModalCanSubmit}
					aria-busy={addModalSubmitting}
				>
					{addModalSubmitting
						? $t('apps.detail.bindings.modal.submitting')
						: addModalTab === 'inline'
							? $t('apps.detail.bindings.modal.submitInline')
							: $t('apps.detail.bindings.modal.submitPick')}
				</button>
			</footer>
		</div>
	</div>
{/if}

<style>
	.forge {
		--accent: var(--forge-accent);
		--accent-soft: var(--forge-accent-soft);
		--glow: var(--forge-glow);
		--badge-image-color: var(--color-info);
		--badge-image-text: var(--color-info-dark);
		--badge-compose-color: var(--color-brand-500);
		--badge-compose-text: var(--color-brand-700);
		--badge-static-color: var(--color-success);
		--badge-static-text: var(--color-success-dark);
		display: flex;
		flex-direction: column;
		gap: 1.1rem;
		max-width: 1080px;
		margin: 0 auto;
	}
	:global([data-theme='dark']) .forge {
		--badge-image-text: #93c5fd;
		--badge-compose-text: #c4b5fd;
		--badge-static-text: #6ee7b7;
	}

	.loading-line {
		display: inline-flex;
		align-items: center;
		gap: 0.6rem;
		color: var(--text-secondary);
		font-family: var(--forge-mono);
		font-size: 0.78rem;
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}
	.spinner {
		width: 12px;
		height: 12px;
		border-radius: 50%;
		border: 1.5px solid var(--border-primary);
		border-top-color: var(--accent);
		animation: spin 0.9s linear infinite;
	}
	@keyframes spin {
		to {
			transform: rotate(360deg);
		}
	}

	/* ── Hero lede row (badges + meta) ─────────────── */
	.lede-row {
		display: inline-flex;
		align-items: center;
		gap: 0.5rem;
		flex-wrap: wrap;
		font-family: var(--font-family-sans);
	}
	.lede-sep {
		color: var(--text-tertiary);
		opacity: 0.6;
	}
	.lede-meta {
		font-size: 0.85rem;
		color: var(--text-tertiary);
	}
	.lede-meta code {
		font-family: var(--forge-mono);
		font-size: 0.78rem;
		color: var(--text-secondary);
	}

	.badge {
		display: inline-flex;
		align-items: center;
		gap: 0.35rem;
		padding: 0.18rem 0.6rem;
		border-radius: var(--radius-full);
		font-family: var(--forge-mono);
		font-size: 0.62rem;
		font-weight: 600;
		letter-spacing: 0.1em;
		text-transform: uppercase;
		border: 1px solid var(--border-primary);
	}
	.badge-dot {
		width: 6px;
		height: 6px;
		border-radius: 50%;
		background: currentColor;
	}
	.badge.src-image {
		background: color-mix(in srgb, var(--badge-image-color) 10%, transparent);
		color: var(--badge-image-text);
		border-color: color-mix(in srgb, var(--badge-image-color) 35%, transparent);
	}
	.badge.src-compose {
		background: color-mix(in srgb, var(--badge-compose-color) 10%, transparent);
		color: var(--badge-compose-text);
		border-color: color-mix(in srgb, var(--badge-compose-color) 35%, transparent);
	}
	.badge.src-static {
		background: color-mix(in srgb, var(--badge-static-color) 10%, transparent);
		color: var(--badge-static-text);
		border-color: color-mix(in srgb, var(--badge-static-color) 35%, transparent);
	}
	.badge.trigger {
		background: var(--surface-card-hover);
		color: var(--text-secondary);
	}
	/* GitOps-managed chip: ember-tinted so it reads as "this app's config
	   is driven from the repo", visually allied with the accent. */
	.badge-gitops {
		background: var(--forge-accent-soft);
		color: var(--color-brand-700);
		border-color: color-mix(in srgb, var(--color-brand-500) 35%, transparent);
	}
	:global([data-theme='dark']) .badge-gitops {
		color: var(--color-brand-300);
	}

	/* ── GitOps read-only gate banner (edit form) ──── */
	.gitops-gate {
		display: flex;
		gap: 0.7rem;
		padding: 0.85rem 1rem;
		border-radius: var(--radius-lg);
		background: color-mix(in srgb, var(--color-brand-500) 7%, var(--surface-card));
		border: 1px solid color-mix(in srgb, var(--color-brand-500) 30%, transparent);
	}
	.gitops-gate-icon {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: 28px;
		height: 28px;
		flex-shrink: 0;
		border-radius: var(--radius-md);
		color: #fff;
		background: var(--color-brand-600);
	}
	.gitops-gate-body {
		display: flex;
		flex-direction: column;
		gap: 0.3rem;
		min-width: 0;
	}
	.gitops-gate-title {
		margin: 0;
		font-size: 0.82rem;
		font-weight: 700;
		color: var(--text-primary);
	}
	.gitops-gate-text {
		margin: 0;
		font-size: 0.74rem;
		line-height: 1.5;
		color: var(--text-secondary);
	}
	.gitops-gate-fields {
		display: flex;
		align-items: center;
		gap: 0.4rem;
		flex-wrap: wrap;
		margin-top: 0.15rem;
	}
	.gitops-gate-flabel {
		font-family: var(--forge-mono);
		font-size: 0.62rem;
		letter-spacing: 0.06em;
		text-transform: uppercase;
		color: var(--text-tertiary);
	}
	.gitops-gate-field {
		font-family: var(--forge-mono);
		font-size: 0.7rem;
		padding: 0.1rem 0.42rem;
		border-radius: var(--radius-sm);
		background: var(--forge-accent-soft);
		border: 1px solid color-mix(in srgb, var(--color-brand-500) 30%, transparent);
		color: var(--color-brand-700);
	}
	:global([data-theme='dark']) .gitops-gate-field {
		color: var(--color-brand-300);
	}

	/* ── Runtime / storage panels ────────────────────
	   Two narrow operational-status cards displayed in a 2-up grid
	   above the Manual deploy panel. Collapses to one column under
	   720px so the labels never get clipped by the registration
	   corners. */
	.rt-grid {
		display: grid;
		grid-template-columns: repeat(2, minmax(0, 1fr));
		gap: 1rem;
		margin-top: 1rem;
	}
	@media (max-width: 720px) {
		.rt-grid {
			grid-template-columns: 1fr;
		}
	}
	.rt-card .panel-title :global(svg) {
		opacity: 0.7;
		margin-right: 0.35rem;
		vertical-align: -2px;
	}
	/* External-link affordance on the "Open" toolbar button — the
	   only ghost button that navigates away from the app, so it
	   gets the ↗ glyph to differentiate it from in-app actions. */
	.open-ext .open-ext-glyph {
		margin-left: 0.15rem;
		font-size: 0.78em;
		opacity: 0.65;
		transition: transform 120ms ease, opacity 120ms ease;
	}
	.open-ext:hover .open-ext-glyph {
		transform: translate(1px, -1px);
		opacity: 1;
	}

	.kv-grid {
		display: grid;
		grid-template-columns: minmax(7rem, max-content) 1fr;
		row-gap: 0.55rem;
		column-gap: 1rem;
		margin: 0.7rem 0 0;
		font-size: 0.875rem;
	}
	.kv-grid dt {
		font-family: var(--forge-mono);
		font-size: 0.68rem;
		letter-spacing: 0.12em;
		text-transform: uppercase;
		color: var(--text-tertiary);
		align-self: center;
	}
	.kv-grid dd {
		margin: 0;
		color: var(--text-secondary);
		align-self: center;
	}
	.mono-sha,
	.mono-time {
		font-family: var(--forge-mono);
		font-size: 0.78rem;
		color: var(--text-secondary);
	}
	.muted {
		color: var(--text-tertiary);
	}

	.rt-empty {
		font-family: var(--forge-mono);
		font-size: 0.75rem;
		letter-spacing: 0.08em;
		color: var(--text-tertiary);
		margin: 0.7rem 0 0;
	}

	.rt-badge {
		display: inline-flex;
		align-items: center;
		gap: 0.4rem;
		padding: 0.18rem 0.55rem;
		border-radius: var(--radius-full);
		font-family: var(--forge-mono);
		font-size: 0.68rem;
		font-weight: 600;
		letter-spacing: 0.12em;
		text-transform: uppercase;
		border: 1px solid var(--border-primary);
		background: var(--surface-card-hover);
		color: var(--text-secondary);
	}
	.rt-dot {
		width: 6px;
		height: 6px;
		border-radius: 50%;
		background: currentColor;
	}
	.rt-badge.rt-ok {
		/* Darken the foreground beyond -dark to clear 4.5:1 against the
		   12% tint on light backgrounds — color-mix the brand token with
		   black instead of relying on -dark alone. */
		background: color-mix(in srgb, var(--color-success) 16%, transparent);
		color: color-mix(in srgb, var(--color-success-dark) 80%, #000);
		border-color: color-mix(in srgb, var(--color-success) 45%, transparent);
	}
	:global([data-theme='dark']) .rt-badge.rt-ok {
		color: #6ee7b7;
	}
	.rt-badge.rt-busy {
		background: color-mix(in srgb, var(--color-warning) 12%, transparent);
		color: var(--color-warning-dark);
		border-color: color-mix(in srgb, var(--color-warning) 35%, transparent);
	}
	.rt-badge.rt-busy .rt-dot {
		animation: rt-pulse 1.4s ease-in-out infinite;
	}
	@keyframes rt-pulse {
		0%, 100% { opacity: 0.35; transform: scale(0.85); }
		50%      { opacity: 1;    transform: scale(1.15); }
	}
	:global([data-theme='dark']) .rt-badge.rt-busy {
		color: #fcd34d;
	}
	.rt-badge.rt-bad {
		background: color-mix(in srgb, var(--color-danger) 12%, transparent);
		color: var(--color-danger-dark);
		border-color: color-mix(in srgb, var(--color-danger) 35%, transparent);
	}
	:global([data-theme='dark']) .rt-badge.rt-bad {
		color: #fca5a5;
	}
	.rt-badge.rt-idle {
		color: var(--text-tertiary);
	}

	.rt-error {
		margin-top: 0.8rem;
	}
	.rt-probe-note {
		margin: 0.7rem 0 0;
		font-size: 0.78rem;
		color: var(--text-tertiary);
	}

	.usage-bar {
		margin-top: 0.85rem;
		height: 6px;
		width: 100%;
		background: var(--surface-card-hover);
		border-radius: var(--radius-full);
		overflow: hidden;
	}
	.usage-fill {
		height: 100%;
		transition: width 200ms ease;
		border-radius: var(--radius-full);
	}
	.usage-fill.bar-green {
		background: var(--color-success);
	}
	.usage-fill.bar-amber {
		background: var(--color-warning);
	}
	.usage-fill.bar-red {
		background: var(--color-danger);
	}
	.usage-caption {
		margin: 0.35rem 0 0;
		font-family: var(--forge-mono);
		font-size: 0.7rem;
		letter-spacing: 0.08em;
		color: var(--text-tertiary);
		text-align: right;
	}

	/* ── Alert / Success ───────────────────────────── */
	.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;
	}
	.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);
	}
	:global([data-theme='dark']) .alert {
		background: color-mix(in srgb, var(--color-danger) 14%, transparent);
		color: #fca5a5;
	}
	.success {
		display: flex;
		gap: 0.7rem;
		align-items: center;
		padding: 0.55rem 0.8rem;
		background: var(--color-success-light);
		color: var(--color-success-dark);
		border: 1px solid color-mix(in srgb, var(--color-success) 40%, transparent);
		border-left-width: 4px;
		border-radius: var(--radius-lg);
		font-size: 0.875rem;
	}
	.success-tag {
		font-family: var(--forge-mono);
		font-weight: 700;
		font-size: 0.62rem;
		letter-spacing: 0.16em;
		padding: 0.15rem 0.4rem;
		background: var(--color-success);
		color: #fff;
		border-radius: var(--radius-sm);
	}
	:global([data-theme='dark']) .success {
		background: color-mix(in srgb, var(--color-success) 14%, transparent);
		color: #86efac;
	}

	/* ── Panel ────────────────────────────────────── */
	.panel {
		position: relative;
		background: var(--surface-card);
		border: 1px solid var(--border-primary);
		border-radius: var(--radius-2xl);
		padding: 1.25rem 1.5rem 1.4rem;
		display: flex;
		flex-direction: column;
		gap: 1rem;
	}
	.panel.code-panel {
		padding: 0;
		gap: 0;
		overflow: hidden;
	}
	.reg {
		position: absolute;
		width: 10px;
		height: 10px;
		border-color: var(--color-brand-500);
		border-style: solid;
		border-width: 0;
		pointer-events: none;
	}
	.reg-tl {
		top: -1px;
		left: -1px;
		border-top-width: 2px;
		border-left-width: 2px;
		border-top-left-radius: var(--radius-2xl);
	}
	.reg-tr {
		top: -1px;
		right: -1px;
		border-top-width: 2px;
		border-right-width: 2px;
		border-top-right-radius: var(--radius-2xl);
	}
	.reg-bl {
		bottom: -1px;
		left: -1px;
		border-bottom-width: 2px;
		border-left-width: 2px;
		border-bottom-left-radius: var(--radius-2xl);
	}
	.reg-br {
		bottom: -1px;
		right: -1px;
		border-bottom-width: 2px;
		border-right-width: 2px;
		border-bottom-right-radius: var(--radius-2xl);
	}

	.panel-head {
		display: flex;
		align-items: center;
		gap: 0.7rem;
		flex-wrap: wrap;
	}
	.panel-head.split {
		gap: 0.5rem;
		background: var(--surface-card-hover);
		border-bottom: 1px solid var(--border-primary);
		padding: 0.5rem 0.7rem 0.5rem 1.15rem;
	}
	.head-toggle {
		flex: 1;
		display: inline-flex;
		align-items: center;
		gap: 0.7rem;
		min-width: 0;
		background: transparent;
		border: 0;
		padding: 0.3rem 0;
		margin: 0;
		cursor: pointer;
		text-align: left;
		color: inherit;
		font: inherit;
		transition: color 120ms ease;
	}
	.head-toggle:hover {
		color: var(--accent);
	}
	.head-toggle:hover .panel-title {
		color: var(--accent);
	}
	.head-toggle:focus-visible {
		outline: 2px solid var(--border-focus);
		outline-offset: 2px;
		border-radius: var(--radius-sm);
	}
	.panel-title {
		font-family: var(--font-family-sans);
		font-size: 0.98rem;
		font-weight: 700;
		letter-spacing: -0.005em;
		color: var(--text-primary);
		margin: 0;
	}
	.title-accent {
		color: var(--accent);
		font-weight: 700;
	}
	.panel-sub {
		font-size: 0.78rem;
		color: var(--text-tertiary);
	}
	.panel-sub.mono {
		font-family: var(--forge-mono);
		font-size: 0.72rem;
		padding: 0.12rem 0.45rem;
		border-radius: var(--radius-sm);
		background: var(--surface-card);
		border: 1px solid var(--border-primary);
		color: var(--text-secondary);
		text-transform: lowercase;
		letter-spacing: 0.02em;
	}
	.panel-sub :global(code),
	code {
		font-family: var(--forge-mono);
		font-size: 0.78rem;
	}
	.chev {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: 18px;
		height: 18px;
		color: var(--text-tertiary);
		transition: transform 180ms ease;
	}
	.chev.rot {
		transform: rotate(-90deg);
	}
	.copy-btn {
		display: inline-flex;
		align-items: center;
		gap: 0.35rem;
		padding: 0.3rem 0.6rem;
		border: 1px solid var(--border-primary);
		border-radius: var(--radius-md);
		background: var(--surface-card);
		font-family: var(--forge-mono);
		font-size: 0.6rem;
		font-weight: 600;
		letter-spacing: 0.1em;
		text-transform: uppercase;
		color: var(--text-secondary);
		cursor: pointer;
		transition: all 120ms ease;
	}
	.copy-btn:hover {
		border-color: var(--color-brand-400);
		color: var(--text-primary);
		background: var(--surface-card-hover);
	}
	.copy-btn:focus-visible {
		outline: 2px solid var(--border-focus);
		outline-offset: 2px;
	}

	.code-body {
		padding: 0;
		background: var(--surface-input);
	}
	.code-view {
		margin: 0;
		padding: 1rem 1.15rem;
		font-family: var(--forge-mono);
		font-size: 0.78rem;
		line-height: 1.6;
		color: var(--text-primary);
		white-space: pre;
		overflow: auto;
		max-height: 360px;
		tab-size: 2;
	}

	/* ── Deploy row ───────────────────────────────── */
	.deploy-row {
		display: flex;
		gap: 0.5rem;
	}
	.deploy-input {
		flex: 1;
		display: flex;
	}
	.deploy-input input {
		width: 100%;
		padding: 0.6rem 0.8rem;
		border: 1px solid var(--border-input);
		background: var(--surface-input);
		border-radius: var(--radius-lg);
		font-family: var(--forge-mono);
		font-size: 0.85rem;
		color: var(--text-primary);
		outline: none;
		transition: border-color 120ms ease, box-shadow 120ms ease;
	}
	.deploy-input input:focus {
		border-color: var(--border-focus);
		box-shadow: 0 0 0 3px var(--accent-soft);
	}
	.hint {
		font-size: 0.78rem;
		color: var(--text-tertiary);
		margin: 0;
		line-height: 1.5;
	}

	/* ── Containers table ─────────────────────────── */
	.empty-inline {
		display: flex;
		align-items: center;
		gap: 0.6rem;
		padding: 0.85rem 1rem;
		border: 1px dashed var(--border-primary);
		border-radius: var(--radius-lg);
		color: var(--text-tertiary);
		font-size: 0.88rem;
	}
	.empty-mark {
		width: 8px;
		height: 8px;
		border-radius: 50%;
		background: var(--border-input);
	}
	.table-wrap {
		border: 1px solid var(--border-primary);
		border-radius: var(--radius-lg);
		overflow: hidden;
	}
	.muted {
		color: var(--text-tertiary);
	}
	.mono {
		font-family: var(--forge-mono);
		font-size: 0.78rem;
	}
	.state-pill {
		display: inline-flex;
		align-items: center;
		gap: 0.4rem;
		padding: 0.2rem 0.55rem;
		border-radius: var(--radius-full);
		font-family: var(--forge-mono);
		font-size: 0.62rem;
		font-weight: 600;
		letter-spacing: 0.1em;
		text-transform: uppercase;
		background: var(--surface-card-hover);
		color: var(--text-secondary);
	}
	.state-pill .pulse {
		width: 6px;
		height: 6px;
		border-radius: 50%;
		background: var(--text-tertiary);
	}
	.state-pill.st-running {
		background: var(--color-success-light);
		color: var(--color-success-dark);
	}
	.state-pill.st-running .pulse {
		background: var(--color-success);
		animation: blink 1.8s infinite;
	}
	.state-pill.st-failed {
		background: var(--color-danger-light);
		color: var(--color-danger-dark);
	}
	.state-pill.st-failed .pulse {
		background: var(--color-danger);
		animation: blink 0.5s infinite;
	}
	.state-pill.st-deploying {
		background: var(--color-info-light);
		color: var(--color-info-dark);
	}
	.state-pill.st-deploying .pulse {
		background: var(--color-info);
		animation: blink 0.8s infinite;
	}
	:global([data-theme='dark']) .state-pill.st-running {
		background: color-mix(in srgb, var(--color-success) 16%, transparent);
		color: #86efac;
	}
	:global([data-theme='dark']) .state-pill.st-deploying {
		background: color-mix(in srgb, var(--color-info) 16%, transparent);
		color: #93c5fd;
	}
	:global([data-theme='dark']) .state-pill.st-failed {
		background: color-mix(in srgb, var(--color-danger) 16%, transparent);
		color: #fca5a5;
	}
	@keyframes blink {
		0%,
		60%,
		100% {
			opacity: 1;
		}
		70%,
		90% {
			opacity: 0.3;
		}
	}

	/* ── Edit form fields ─────────────────────────── */
	.field {
		display: flex;
		flex-direction: column;
		gap: 0.55rem;
	}
	.field-label {
		display: flex;
		align-items: center;
		gap: 0.55rem;
	}
	.num {
		display: inline-flex;
		width: 26px;
		height: 26px;
		justify-content: center;
		align-items: center;
		background: var(--text-primary);
		color: var(--surface-card);
		border-radius: var(--radius-sm);
		font-family: var(--forge-mono);
		font-size: 0.7rem;
		font-weight: 700;
	}
	.lbl {
		font-family: var(--font-family-sans);
		font-weight: 600;
		font-size: 1.05rem;
		letter-spacing: -0.01em;
		color: var(--text-primary);
	}
	.req,
	.opt {
		font-family: var(--forge-mono);
		font-size: 0.58rem;
		font-weight: 600;
		text-transform: uppercase;
		letter-spacing: 0.14em;
	}
	.req {
		color: var(--color-danger);
	}
	.opt {
		color: var(--text-tertiary);
	}
	.input {
		width: 100%;
		background: var(--surface-input);
		border: 1px solid var(--border-input);
		border-radius: var(--radius-lg);
		padding: 0.6rem 0.8rem;
		font-size: 0.92rem;
		color: var(--text-primary);
		font-family: inherit;
		outline: none;
		transition: border-color 120ms ease, box-shadow 120ms ease;
	}
	.input:focus {
		border-color: var(--border-focus);
		box-shadow: 0 0 0 3px var(--accent-soft);
	}

	.editor {
		border: 1px solid var(--border-primary);
		border-radius: var(--radius-lg);
		overflow: hidden;
		background: var(--surface-input);
		transition: border-color 150ms ease, box-shadow 150ms ease;
	}
	.editor:focus-within {
		border-color: var(--border-focus);
		box-shadow: 0 0 0 3px var(--accent-soft);
	}
	.editor-head {
		display: flex;
		align-items: center;
		gap: 0.5rem;
		padding: 0.55rem 0.8rem;
		background: var(--surface-card-hover);
		border-bottom: 1px solid var(--border-primary);
		font-family: var(--forge-mono);
		font-size: 0.7rem;
		color: var(--text-tertiary);
	}
	.editor-head .dot {
		width: 8px;
		height: 8px;
		border-radius: 50%;
		background: var(--border-input);
	}
	.editor-head .dot:nth-of-type(1) {
		background: #ef4444aa;
	}
	.editor-head .dot:nth-of-type(2) {
		background: #f59e0baa;
	}
	.editor-head .dot:nth-of-type(3) {
		background: #10b981aa;
	}
	.editor-title {
		margin-left: 0.4rem;
		color: var(--text-secondary);
		font-weight: 600;
	}
	.code-area {
		display: block;
		width: 100%;
		border: 0;
		background: transparent;
		padding: 0.85rem 1rem;
		font-family: var(--forge-mono);
		font-size: 0.82rem;
		line-height: 1.55;
		color: var(--text-primary);
		resize: vertical;
		outline: none;
		tab-size: 2;
	}
	.editor-foot {
		display: flex;
		align-items: center;
		gap: 0.5rem;
		padding: 0.45rem 0.8rem;
		border-top: 1px solid var(--border-primary);
		background: var(--surface-card-hover);
		font-family: var(--forge-mono);
		font-size: 0.62rem;
		color: var(--text-tertiary);
	}
	.foot-status {
		display: inline-flex;
		align-items: center;
		gap: 0.35rem;
		color: var(--color-success-dark);
		letter-spacing: 0.1em;
		font-weight: 600;
	}
	.foot-status .foot-dot {
		width: 6px;
		height: 6px;
		border-radius: 50%;
		background: var(--color-success);
	}
	.foot-status.bad {
		color: var(--color-danger-dark);
	}
	.foot-status.bad .foot-dot {
		background: var(--color-danger);
	}
	:global([data-theme='dark']) .foot-status {
		color: #86efac;
	}
	:global([data-theme='dark']) .foot-status.bad {
		color: #fca5a5;
	}

	.actions {
		display: flex;
		justify-content: flex-end;
		gap: 0.55rem;
		padding-top: 0.4rem;
	}

	/* Ghost-button danger variant — reuses .forge-btn-ghost from app.css
	   but tints the text + hover for destructive actions. */
	:global(.forge-btn-ghost.danger) {
		color: var(--color-danger);
	}
	:global(.forge-btn-ghost.danger:hover:not(:disabled)) {
		border-color: var(--color-danger);
		background: var(--color-danger-light);
		color: var(--color-danger-dark);
	}
	:global([data-theme='dark']) :global(.forge-btn-ghost.danger:hover:not(:disabled)) {
		background: color-mix(in srgb, var(--color-danger) 14%, transparent);
		color: #fca5a5;
	}

	.sr-only {
		position: absolute;
		width: 1px;
		height: 1px;
		padding: 0;
		margin: -1px;
		overflow: hidden;
		clip: rect(0, 0, 0, 0);
		white-space: nowrap;
		border-width: 0;
	}

	/* ── Chain panel ───────────────────────────────────────────
	   The chain panel shows parent / self / children for promote-from
	   linkages. The original markup leaned on shared classes (`panel`,
	   `forge-btn-ghost`, `alert`) and was functional but unpolished
	   compared with sibling panels. These rules give each row a
	   fixed-width label column and a bordered card per workload,
	   highlighting the "self" card so the user always sees where they
	   are in the chain. */
	.chain-row {
		display: flex;
		align-items: center;
		gap: 0.85rem;
		padding: 0.4rem 0;
	}
	.chain-row.chain-children {
		align-items: flex-start;
	}
	.chain-label {
		flex: 0 0 70px;
		font-family: var(--forge-mono);
		font-size: 0.62rem;
		font-weight: 600;
		letter-spacing: 0.14em;
		text-transform: uppercase;
		color: var(--text-secondary);
	}
	.chain-card {
		display: inline-flex;
		flex-direction: column;
		gap: 0.2rem;
		padding: 0.55rem 0.8rem;
		background: var(--surface-card);
		border: 1px solid var(--border-primary);
		border-radius: var(--radius-lg);
		color: var(--text-primary);
		text-decoration: none;
		min-width: 180px;
		transition: border-color 120ms ease, transform 120ms ease,
			box-shadow 120ms ease;
	}
	a.chain-card:hover {
		border-color: var(--color-brand-400);
		transform: translateY(-1px);
		box-shadow: 0 1px 0 0 var(--forge-glow);
	}
	.chain-card.chain-self {
		background: color-mix(in srgb, var(--color-brand-500) 8%, var(--surface-card));
		border-color: var(--color-brand-500);
		cursor: default;
	}
	.chain-name {
		font-weight: 600;
		font-size: 0.92rem;
		letter-spacing: -0.01em;
	}
	.chain-card .mono {
		font-family: var(--forge-mono);
		font-size: 0.68rem;
		letter-spacing: 0.04em;
	}
	.chain-card .muted {
		color: var(--text-tertiary);
	}
	.chain-children-list {
		display: flex;
		flex-wrap: wrap;
		gap: 0.5rem;
	}
	/* A child entry pairs its card with an optional "Promote to this" button. */
	.chain-child-item {
		display: flex;
		align-items: center;
		gap: 0.5rem;
	}
	@media (max-width: 600px) {
		.chain-row {
			flex-direction: column;
			align-items: flex-start;
			gap: 0.35rem;
		}
		.chain-label {
			flex: 0 0 auto;
		}
	}

	/* ── Branch preview environments ──────────────────────────
	   Each preview is a per-branch deploy of the template workload. The
	   panel lists them as rows: state pill (reusing the .rt-badge palette),
	   the branch name in mono, an open-↗ link to the slug-prefixed FQDN,
	   and a teardown button (gated behind ConfirmDialog). The "PREVIEW"
	   tag re-used in the chain panel marks the same rows there. */
	.preview-tag {
		display: inline-block;
		margin-left: 0.4rem;
		padding: 0.05rem 0.34rem;
		font-family: var(--forge-mono);
		font-size: 0.56rem;
		font-weight: 700;
		letter-spacing: 0.12em;
		text-transform: uppercase;
		vertical-align: middle;
		color: var(--color-brand-700);
		background: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
		border: 1px solid color-mix(in srgb, var(--color-brand-500) 40%, transparent);
		border-radius: var(--radius-sm);
	}
	:global([data-theme='dark']) .preview-tag {
		color: var(--color-brand-300, #fcd34d);
	}
	.preview-empty {
		display: flex;
		align-items: center;
		gap: 0.5rem;
		padding: 0.7rem 0.85rem;
		font-size: 0.85rem;
		color: var(--text-secondary);
		background: var(--surface-subtle, var(--surface-card));
		border: 1px dashed var(--border-primary);
		border-radius: var(--radius-lg);
	}
	.preview-empty code {
		font-family: var(--forge-mono);
		font-size: 0.78rem;
		color: var(--text-primary);
	}
	.preview-list {
		display: flex;
		flex-direction: column;
		gap: 0.5rem;
	}
	.preview-row {
		display: flex;
		align-items: center;
		gap: 0.6rem;
		padding: 0.5rem 0.7rem;
		background: var(--surface-card);
		border: 1px solid var(--border-primary);
		border-radius: var(--radius-lg);
	}
	.preview-branch {
		font-family: var(--forge-mono);
		font-size: 0.84rem;
		font-weight: 600;
		color: var(--text-primary);
		text-decoration: none;
		letter-spacing: -0.005em;
	}
	.preview-branch:hover {
		color: var(--color-brand-600);
		text-decoration: underline;
	}
	.preview-spacer {
		flex: 1 1 auto;
	}
	.preview-state {
		flex: 0 0 auto;
	}
	.preview-nourl {
		display: inline-flex;
		align-items: center;
		font-family: var(--forge-mono);
		font-size: 0.66rem;
		letter-spacing: 0.06em;
		color: var(--text-tertiary);
	}
	@media (max-width: 600px) {
		.preview-row {
			flex-wrap: wrap;
			gap: 0.4rem;
		}
		.preview-spacer {
			flex-basis: 100%;
			height: 0;
		}
	}

	/* ── Log rules table styling ──────────────────────────────
	   Mirrors the /log-scan-rules list-page badges so users see
	   consistent severity/scope shape across both surfaces. The
	   .scope-global/.scope-workload/.scope-override classes are
	   matched against the `kind` string produced by classifyRule. */
	.log-pattern {
		max-width: 280px;
		overflow: hidden;
		text-overflow: ellipsis;
		white-space: nowrap;
	}
	@media (max-width: 720px) {
		.log-pattern {
			max-width: 160px;
		}
	}
	.badge.scope-global {
		background: var(--surface-card-hover);
		border: 1px solid var(--border-primary);
		color: var(--text-secondary);
		font-family: var(--forge-mono);
		font-size: 0.62rem;
		font-weight: 600;
		letter-spacing: 0.1em;
		text-transform: uppercase;
		padding: 0.15rem 0.45rem;
		border-radius: var(--radius-sm);
	}
	.badge.scope-workload {
		background: color-mix(in srgb, var(--color-brand-500) 10%, transparent);
		border: 1px solid color-mix(in srgb, var(--color-brand-500) 30%, transparent);
		color: var(--color-brand-600);
		font-family: var(--forge-mono);
		font-size: 0.62rem;
		font-weight: 600;
		letter-spacing: 0.1em;
		text-transform: uppercase;
		padding: 0.15rem 0.45rem;
		border-radius: var(--radius-sm);
	}
	.badge.scope-override {
		background: color-mix(in srgb, var(--forge-accent) 12%, transparent);
		border: 1px solid color-mix(in srgb, var(--forge-accent) 35%, transparent);
		color: var(--forge-accent);
		font-family: var(--forge-mono);
		font-size: 0.62rem;
		font-weight: 600;
		letter-spacing: 0.1em;
		text-transform: uppercase;
		padding: 0.15rem 0.45rem;
		border-radius: var(--radius-sm);
	}
	.severity {
		display: inline-flex;
		align-items: center;
		padding: 0.12rem 0.4rem;
		border-radius: var(--radius-sm);
		font-family: var(--forge-mono);
		font-size: 0.62rem;
		font-weight: 700;
		letter-spacing: 0.14em;
		text-transform: uppercase;
	}
	.sev-info {
		background: var(--surface-card-hover);
		color: var(--text-secondary);
	}
	.sev-warn {
		background: color-mix(in srgb, #f59e0b 16%, transparent);
		color: #b45309;
	}
	.sev-error {
		background: var(--color-danger-light);
		color: var(--color-danger-dark);
	}
	:global(.forge-btn-ghost.xs) {
		padding: 0.2rem 0.55rem;
		font-size: 0.62rem;
	}

	/* The source-config edit form's bespoke styling (.image-form,
	   .static-mode, .radio, .checkbox-row, .row.three, the discovery
	   buttons/pills, and the test-connection row) moved into the extracted
	   $lib/components/workload/* source-form components along with the markup
	   they styled. */

	/* ── Bindings panel ─────────────────────────────────
	   Mirrors the bindings list on /triggers/[id] but
	   reads from the workload's perspective: each row
	   shows the bound trigger's name + kind, with the
	   enable toggle, deep-link, and unbind actions. */
	.bindings-head {
		justify-content: space-between;
		gap: 0.7rem;
		flex-wrap: wrap;
	}
	.bindings-head-left {
		display: flex;
		flex-direction: column;
		gap: 0.2rem;
		min-width: 0;
		flex: 1;
	}
	.bindings-list {
		list-style: none;
		margin: 0;
		padding: 0;
		display: flex;
		flex-direction: column;
		gap: 0.5rem;
	}
	.binding {
		display: flex;
		align-items: center;
		gap: 0.75rem;
		padding: 0.7rem 0.85rem;
		background: var(--surface-card-hover);
		border: 1px solid var(--border-primary);
		border-radius: var(--radius-lg);
		flex-wrap: wrap;
	}
	.b-main {
		display: inline-flex;
		align-items: center;
		gap: 0.7rem;
		flex: 1;
		min-width: 0;
		flex-wrap: wrap;
	}
	.b-ref {
		font-size: 0.66rem;
		letter-spacing: 0.1em;
		color: var(--text-tertiary);
		flex: 0 0 auto;
	}
	.b-name {
		font-weight: 600;
		color: var(--text-primary);
		text-decoration: none;
		overflow: hidden;
		text-overflow: ellipsis;
		white-space: nowrap;
		min-width: 0;
		transition: color 120ms ease;
	}
	.b-name:hover {
		color: var(--accent);
		text-decoration: underline;
	}
	.b-kind {
		display: inline-flex;
		padding: 0.16rem 0.45rem;
		background: var(--text-primary);
		color: var(--surface-card);
		font-family: var(--forge-mono);
		font-size: 0.58rem;
		font-weight: 700;
		letter-spacing: 0.18em;
		border-radius: var(--radius-sm);
		line-height: 1;
		flex: 0 0 auto;
	}
	.b-state {
		font-size: 0.58rem;
		font-weight: 700;
		letter-spacing: 0.16em;
		text-transform: uppercase;
		padding: 0.12rem 0.45rem;
		border-radius: var(--radius-sm);
		flex: 0 0 auto;
	}
	.b-state.on {
		background: color-mix(in srgb, var(--color-success) 12%, transparent);
		color: var(--color-success-dark);
	}
	.b-state.off {
		background: var(--surface-card);
		color: var(--text-tertiary);
	}
	.b-actions {
		display: inline-flex;
		gap: 0.35rem;
		align-items: center;
	}
	:global(.forge-btn-icon.danger) {
		color: var(--color-danger);
	}
	:global(.forge-btn-icon.danger:hover:not(:disabled)) {
		background: var(--color-danger-light);
	}
	:global([data-theme='dark']) :global(.forge-btn-icon.danger:hover:not(:disabled)) {
		background: color-mix(in srgb, var(--color-danger) 14%, transparent);
	}

	/* xs modifier for ghost buttons — matches the sizing
	   used by /triggers/[id] and other pages. */
	:global(.forge-btn-ghost.xs) {
		padding: 0.32rem 0.6rem;
		font-size: 0.6rem;
		gap: 0.3rem;
		border-radius: var(--radius-md);
	}

	/* ── Override editor (per-binding) ───────────────────
	   The expanded inline panel that lets operators set
	   a per-binding `binding_config` overlay. The host
	   binding row gains a left accent stripe when an
	   override is active so it's visually distinct from
	   pristine "inherit verbatim" rows. */
	.binding.has-override {
		border-left: 3px solid var(--forge-accent);
	}
	.binding.open {
		border-color: var(--forge-accent);
		box-shadow: 0 0 0 1px var(--forge-accent-soft);
	}
	.b-override {
		display: inline-flex;
		padding: 0.14rem 0.45rem;
		background: var(--forge-accent-soft);
		color: var(--forge-accent);
		font-family: var(--forge-mono);
		font-size: 0.56rem;
		font-weight: 700;
		letter-spacing: 0.16em;
		text-transform: uppercase;
		border-radius: var(--radius-sm);
		line-height: 1;
		flex: 0 0 auto;
		border: 1px solid color-mix(in srgb, var(--forge-accent) 35%, transparent);
	}
	.override-btn {
		display: inline-flex;
		align-items: center;
		gap: 0.3rem;
		font-family: var(--forge-mono);
		font-size: 0.6rem;
		letter-spacing: 0.12em;
		text-transform: uppercase;
	}
	.override-btn :global(.chev) {
		transition: transform 180ms ease;
	}
	.override-btn[aria-expanded='true'] :global(.chev) {
		transform: rotate(180deg);
	}
	.override-btn.has-override {
		color: var(--forge-accent);
	}
	.override-btn.active {
		border-color: var(--forge-accent);
		color: var(--forge-accent);
	}
	.override-panel {
		flex: 1 0 100%;
		display: flex;
		flex-direction: column;
		gap: 0.85rem;
		margin-top: 0.6rem;
		padding: 0.95rem 1rem 0.85rem;
		background: var(--surface-card);
		border: 1px solid var(--border-primary);
		border-radius: var(--radius-lg);
		animation: op-fade 180ms ease;
	}
	@keyframes op-fade {
		from {
			opacity: 0;
			transform: translateY(-4px);
		}
		to {
			opacity: 1;
			transform: translateY(0);
		}
	}
	.op-head {
		display: flex;
		flex-direction: column;
		gap: 0.2rem;
	}
	.op-title {
		margin: 0;
		font-size: 0.85rem;
		font-weight: 700;
		letter-spacing: -0.01em;
		color: var(--text-primary);
	}
	.op-sub {
		margin: 0;
		font-size: 0.76rem;
		color: var(--text-tertiary);
		line-height: 1.45;
	}
	.op-grid {
		display: grid;
		grid-template-columns: 1fr 1fr;
		gap: 0.85rem;
	}
	@media (max-width: 720px) {
		.op-grid {
			grid-template-columns: 1fr;
		}
	}
	.op-col,
	.op-preview {
		display: flex;
		flex-direction: column;
		gap: 0.4rem;
		min-width: 0;
	}
	.op-col-head {
		display: inline-flex;
		align-items: center;
		gap: 0.5rem;
		justify-content: space-between;
	}
	.op-col-label {
		font-family: var(--forge-mono);
		font-size: 0.6rem;
		font-weight: 600;
		letter-spacing: 0.14em;
		text-transform: uppercase;
		color: var(--text-secondary);
	}
	.op-col-tag {
		display: inline-flex;
		padding: 0.12rem 0.4rem;
		background: var(--surface-card-hover);
		color: var(--text-tertiary);
		font-family: var(--forge-mono);
		font-size: 0.54rem;
		font-weight: 700;
		letter-spacing: 0.16em;
		border-radius: var(--radius-sm);
		line-height: 1.3;
		border: 1px solid var(--border-primary);
	}
	.op-col-tag.accent {
		background: var(--forge-accent-soft);
		color: var(--forge-accent);
		border-color: color-mix(in srgb, var(--forge-accent) 30%, transparent);
	}
	.op-col-tag.warn {
		background: color-mix(in srgb, var(--color-warning, #f59e0b) 14%, transparent);
		color: var(--color-warning-dark, #b45309);
		border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent);
	}
	.op-col-tag.bad {
		background: color-mix(in srgb, var(--color-danger) 14%, transparent);
		color: var(--color-danger-dark, var(--color-danger));
		border-color: color-mix(in srgb, var(--color-danger) 35%, transparent);
	}
	.op-readonly {
		margin: 0;
		padding: 0.65rem 0.75rem;
		background: var(--surface-card-hover);
		border: 1px dashed var(--border-primary);
		border-radius: var(--radius-md);
		font-size: 0.78rem;
		line-height: 1.5;
		color: var(--text-secondary);
		max-height: 220px;
		overflow: auto;
		white-space: pre-wrap;
		word-break: break-word;
	}
	.op-readonly.dim {
		color: var(--text-tertiary);
		font-style: italic;
	}
	.op-editor {
		width: 100%;
		min-height: 160px;
		padding: 0.65rem 0.75rem;
		background: var(--surface-input);
		border: 1px solid var(--border-input);
		border-radius: var(--radius-md);
		font-size: 0.82rem;
		line-height: 1.5;
		color: var(--text-primary);
		font-family: var(--forge-mono);
		resize: vertical;
		outline: none;
		transition: border-color 120ms ease, box-shadow 120ms ease;
	}
	.op-editor:focus {
		border-color: var(--border-focus);
		box-shadow: 0 0 0 3px var(--forge-accent-soft);
	}
	.op-editor.bad {
		border-color: var(--color-danger);
	}
	.op-editor.bad:focus {
		box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 22%, transparent);
	}
	.op-editor.warn {
		border-color: var(--color-warning, #f59e0b);
	}
	.op-meta {
		display: flex;
		justify-content: space-between;
		align-items: center;
		gap: 0.5rem;
		flex-wrap: wrap;
	}
	.op-meta-msg {
		font-size: 0.72rem;
		color: var(--text-tertiary);
		line-height: 1.4;
	}
	.op-meta-msg.bad {
		color: var(--color-danger);
	}
	.op-meta-bytes {
		font-size: 0.62rem;
		letter-spacing: 0.1em;
		color: var(--text-tertiary);
	}
	.op-meta-bytes.warn {
		color: var(--color-warning-dark, #b45309);
		font-weight: 700;
	}
	.op-hint {
		margin: 0;
		font-size: 0.72rem;
		color: var(--text-tertiary);
		line-height: 1.4;
	}
	.op-preview {
		padding-top: 0.45rem;
		border-top: 1px dashed var(--border-primary);
	}
	.op-actions {
		display: flex;
		justify-content: space-between;
		align-items: center;
		gap: 0.6rem;
		padding-top: 0.55rem;
		border-top: 1px dashed var(--border-primary);
		flex-wrap: wrap;
	}
	.op-actions-right {
		display: inline-flex;
		gap: 0.45rem;
		align-items: center;
		margin-left: auto;
	}

	/* ── Add-trigger modal ──────────────────────────────
	   The dialog slides in over a backdrop. Two tabs at
	   the top swap between inline-create and pick-existing.
	   The body scrolls independently so very tall inline
	   forms don't push the actions off-screen. */
	.modal-wrap {
		padding-top: 4vh;
		padding-bottom: 4vh;
	}
	.add-modal {
		position: relative;
		width: 100%;
		max-width: 640px;
		background: var(--surface-card);
		border: 1px solid var(--border-primary);
		border-radius: var(--radius-2xl);
		box-shadow: var(--shadow-md);
		display: flex;
		flex-direction: column;
		max-height: 92vh;
	}
	.add-head {
		display: flex;
		align-items: flex-start;
		justify-content: space-between;
		gap: 1rem;
		padding: 1.1rem 1.25rem 0.55rem;
		border-bottom: 1px solid var(--border-primary);
	}
	.add-head-text {
		display: flex;
		flex-direction: column;
		gap: 0.25rem;
		flex: 1;
		min-width: 0;
	}
	.add-title {
		margin: 0;
		font-family: var(--font-family-sans);
		font-weight: 700;
		font-size: 1.1rem;
		letter-spacing: -0.01em;
		color: var(--text-primary);
	}
	.add-sub {
		margin: 0;
		font-size: 0.82rem;
		color: var(--text-tertiary);
		line-height: 1.45;
	}
	.add-tabs {
		display: inline-flex;
		gap: 0;
		padding: 0.55rem 1.25rem 0;
		border-bottom: 1px solid var(--border-primary);
	}
	.add-tab {
		padding: 0.55rem 0.95rem;
		background: transparent;
		border: 0;
		border-bottom: 2px solid transparent;
		font-family: var(--forge-mono);
		font-size: 0.65rem;
		font-weight: 600;
		letter-spacing: 0.12em;
		text-transform: uppercase;
		color: var(--text-tertiary);
		cursor: pointer;
		transition: color 120ms ease, border-color 120ms ease;
		margin-bottom: -1px;
	}
	.add-tab:hover {
		color: var(--text-secondary);
	}
	.add-tab.active {
		color: var(--forge-accent);
		border-bottom-color: var(--forge-accent);
	}
	.add-body {
		padding: 1rem 1.25rem;
		overflow-y: auto;
		flex: 1;
		min-height: 0;
	}
	.add-actions {
		display: flex;
		justify-content: flex-end;
		gap: 0.55rem;
		padding: 0.85rem 1.25rem;
		border-top: 1px solid var(--border-primary);
		background: var(--surface-card-hover);
		border-bottom-left-radius: var(--radius-2xl);
		border-bottom-right-radius: var(--radius-2xl);
	}
	.pick-row {
		display: grid;
		grid-template-columns: 1fr 2fr;
		gap: 0.85rem;
	}
	@media (max-width: 600px) {
		.pick-row {
			grid-template-columns: 1fr;
		}
	}
	.pick-row .sub {
		display: flex;
		flex-direction: column;
		gap: 0.35rem;
	}
	.sub-label {
		font-family: var(--forge-mono);
		font-size: 0.62rem;
		font-weight: 600;
		letter-spacing: 0.14em;
		text-transform: uppercase;
		color: var(--text-secondary);
	}
	.add-modal .alert.inline-alert {
		margin: 0.55rem 1.25rem 0;
	}

	/* ── Live-state pill (hero lede) ──────────────────
	   Reuses the existing .rt-badge palette tokens (rt-ok / rt-busy /
	   rt-bad / rt-idle) so the live container-state chip matches the
	   static runtime-status badge stylistically. .live-badge is just
	   a sizing tweak so it sits comfortably next to .badge chips. */
	.live-badge {
		padding: 0.16rem 0.55rem;
		/* Matches the bumped .rt-badge size (0.68rem) so the most
		   important status pill on the page is the most readable. */
		font-size: 0.66rem;
		letter-spacing: 0.14em;
	}

	/* ── Build log panel (dockerfile live tail) ──────── */
	.build-log-panel {
		margin-top: 1rem;
	}
	.build-log-head {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 0.75rem;
	}
	.build-log-clear {
		font-size: 0.7rem;
		letter-spacing: 0.12em;
		text-transform: uppercase;
		padding: 0.3rem 0.65rem;
	}
	.build-log-tail {
		margin-top: 0.7rem;
		max-height: 320px;
		overflow-y: auto;
		background: #0a0d12;
		color: #d4dbe5;
		border: 1px solid var(--border-primary);
		border-radius: 4px;
		padding: 0.65rem 0.85rem;
		font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
		font-size: 0.75rem;
		line-height: 1.45;
		scrollbar-gutter: stable;
	}
	.build-log-line {
		white-space: pre-wrap;
		word-break: break-word;
	}

	/* ── Resource usage panel ─────────────────────── */
	.stats-panel {
		margin-top: 1rem;
	}
	.stats-list {
		list-style: none;
		margin: 0.7rem 0 0;
		padding: 0;
		display: flex;
		flex-direction: column;
		gap: 0.9rem;
	}
	.stats-row {
		padding: 0.7rem 0.85rem 0.85rem;
		background: var(--surface-card-hover);
		border: 1px solid var(--border-primary);
		border-radius: var(--radius-lg);
	}
	.stats-row-head {
		display: inline-flex;
		align-items: baseline;
		gap: 0.6rem;
		flex-wrap: wrap;
		/* Breathing room between header and chart, matches .panel-head. */
		margin-bottom: 0.5rem;
	}
	.stats-role {
		font-size: 0.7rem;
		font-weight: 700;
		letter-spacing: 0.12em;
		text-transform: uppercase;
		color: var(--text-secondary);
	}
	.stats-cid {
		font-size: 0.68rem;
		color: var(--text-tertiary);
	}
	/* Per-row collapse when the workload has ≥3 containers — a compose
	   stack with N services would otherwise stack a wall of charts. */
	.stats-collapse > summary {
		cursor: pointer;
		list-style: none;
		padding: 0.1rem 0;
	}
	.stats-collapse > summary::-webkit-details-marker {
		display: none;
	}
	.stats-collapse-glyph {
		display: inline-block;
		margin-right: 0.15rem;
		font-family: var(--forge-mono);
		color: var(--text-tertiary);
		transition: transform 120ms ease;
	}
	.stats-collapse[open] .stats-collapse-glyph {
		transform: rotate(90deg);
	}

	/* ── Webhook bindings (read-only summary) ─────── */
	.wh-panel {
		margin-top: 1rem;
	}
	.wh-panel .panel-title :global(svg) {
		opacity: 0.7;
		margin-right: 0.35rem;
		vertical-align: -2px;
	}
	.wh-list {
		list-style: none;
		margin: 0.7rem 0 0;
		padding: 0;
		display: flex;
		flex-direction: column;
		gap: 0.45rem;
	}
	.wh-row {
		display: inline-flex;
		align-items: center;
		gap: 0.6rem;
		flex-wrap: wrap;
		padding: 0.55rem 0.7rem;
		background: var(--surface-card-hover);
		border: 1px solid var(--border-primary);
		border-radius: var(--radius-lg);
	}
	.wh-row.disabled {
		opacity: 0.68;
	}
	.wh-icon {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: 22px;
		height: 22px;
		border-radius: var(--radius-sm);
		background: var(--surface-card);
		color: var(--text-tertiary);
		border: 1px solid var(--border-primary);
		flex: 0 0 auto;
	}
	.wh-name {
		font-weight: 600;
		color: var(--text-primary);
		font-size: 0.85rem;
		overflow: hidden;
		text-overflow: ellipsis;
		white-space: nowrap;
		min-width: 0;
	}
	.wh-kind {
		display: inline-flex;
		padding: 0.16rem 0.45rem;
		background: var(--text-primary);
		color: var(--surface-card);
		font-family: var(--forge-mono);
		font-size: 0.58rem;
		font-weight: 700;
		letter-spacing: 0.18em;
		border-radius: var(--radius-sm);
		line-height: 1;
		flex: 0 0 auto;
	}
	.wh-muted {
		font-size: 0.6rem;
		letter-spacing: 0.14em;
		text-transform: uppercase;
		color: var(--text-tertiary);
	}
	.wh-open {
		margin-left: auto;
	}

	/* ── Responsive toolbar overflow ──────────────────
	   At ≥640px the overflow <details> stays hidden and every action
	   renders inline. Under 640px the .tb-wide buttons collapse into a
	   "More ⋯" menu. Both copies live in the DOM so keyboard/aria
	   semantics remain native — CSS only flips visibility. */
	.tb-overflow {
		position: relative;
		display: none;
	}
	@media (max-width: 639px) {
		.tb-wide {
			display: none !important;
		}
		.tb-overflow {
			display: inline-block;
		}
	}
	.tb-more-summary {
		list-style: none;
		cursor: pointer;
		user-select: none;
	}
	.tb-more-summary::-webkit-details-marker {
		display: none;
	}
	.tb-more-glyph {
		margin-left: 0.15rem;
		font-size: 0.95em;
		line-height: 1;
		opacity: 0.7;
	}
	.tb-menu {
		position: absolute;
		right: 0;
		top: calc(100% + 0.35rem);
		min-width: 11rem;
		padding: 0.35rem;
		display: flex;
		flex-direction: column;
		gap: 0.15rem;
		/* Sticky nav in this app uses z-index ≥40; bump well past so
		   the overflow menu always sits above page chrome. */
		z-index: 100;
		background: var(--surface-card);
		border: 1px solid var(--border-primary);
		border-radius: var(--radius-lg);
		box-shadow: 0 12px 28px -10px rgba(15, 23, 42, 0.35);
	}
	:global([data-theme='dark']) .tb-menu {
		box-shadow: 0 12px 28px -10px rgba(0, 0, 0, 0.55);
	}
	.tb-menu-item {
		display: inline-flex;
		align-items: center;
		gap: 0.5rem;
		padding: 0.5rem 0.65rem;
		background: transparent;
		border: 0;
		border-radius: var(--radius-md);
		color: var(--text-secondary);
		font: inherit;
		font-size: 0.82rem;
		text-align: left;
		text-decoration: none;
		cursor: pointer;
		transition: background 120ms ease, color 120ms ease;
	}
	.tb-menu-item:hover,
	.tb-menu-item:focus-visible {
		background: var(--surface-card-hover);
		color: var(--text-primary);
		outline: none;
	}
	.tb-menu-item.danger {
		color: var(--color-danger);
	}
	.tb-menu-item.danger:hover,
	.tb-menu-item.danger:focus-visible {
		background: var(--color-danger-light);
		color: var(--color-danger-dark);
	}
</style>
