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

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

1716 lines
54 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto, beforeNavigate } from '$app/navigation';
import type { HookKinds, PluginWorkloadInput } from '$lib/types';
import type { RedeployTrigger } from '$lib/api';
import * as api from '$lib/api';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import TriggerKindForm, {
createTriggerKindFormState,
isTriggerFormValid,
buildTriggerInput
} from '$lib/components/TriggerKindForm.svelte';
import SourceKindPicker from '$lib/components/workload/SourceKindPicker.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 AppManifest, { type ManifestRow } from '$lib/components/workload/AppManifest.svelte';
import WizardRail from '$lib/components/wizard/WizardRail.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import {
emptyImageState,
emptyComposeState,
emptyStaticState,
emptyDockerfileState,
seedImageState,
seedComposeState,
seedStaticState,
seedDockerfileState,
imageToConfig,
composeToConfig,
staticToConfig,
dockerfileToConfig,
stringifyConfig,
type ImageFormState,
type ComposeFormState,
type StaticFormState,
type DockerfileFormState,
type GitSourceState
} from '$lib/workload/sourceForms';
import { t } from '$lib/i18n';
// `triggers` is no longer hardcoded into the workload row — the
// kind list is kept on `kinds` only for reference but the wizard
// now composes a standalone Trigger record (or picks one) and
// binds it after the workload is created.
let kinds = $state<HookKinds>({ sources: [], triggers: [] });
let name = $state('');
let sourceKind = $state('image');
let sourceConfig = $state('{}');
let publicSubdomain = $state('');
let publicDomain = $state('');
let publicPort = $state(0);
// Trigger UX modes — three branches:
// inline → create a new trigger inline; bind after workload create.
// pick → select an existing trigger; bind after workload create.
// skip → create the workload without any binding.
type TriggerMode = 'inline' | 'pick' | 'skip';
let triggerMode = $state<TriggerMode>('inline');
let triggerForm = $state(createTriggerKindFormState({ kind: 'registry' }));
// Existing-trigger picker. `existingTriggers` is loaded lazily on
// mount; if the request fails the operator can still create one
// inline or skip altogether — we don't block the wizard.
let existingTriggers = $state<RedeployTrigger[]>([]);
let pickedTriggerId = $state('');
// ── Source-config form state ──────────────────────────────────────
// One state object per source kind, modelled by the shared (pure,
// unit-tested) `sourceForms.ts` module. The form bodies live in
// dedicated components under $lib/components/workload/ that bind these
// objects; serialization to the `source_config` POST body is done here
// via the module's serializers so the shape stays byte-identical to the
// legacy inline helpers. The advancedJson toggle swaps the chosen form
// component for the raw-JSON editor.
let advancedJson = $state(false);
let imageState = $state<ImageFormState>(emptyImageState());
let composeState = $state<ComposeFormState>(emptyComposeState());
let staticState = $state<StaticFormState>(emptyStaticState());
let dockerfileState = $state<DockerfileFormState>(emptyDockerfileState());
// ── Discovery transient status (lifted out of the source forms) ───
// StaticSourceForm / DockerfileSourceForm (and the StaticDiscoveryWizard
// they embed) unmount whenever the operator flips Advanced JSON or
// switches source kind, which would normally reset the loaded folder
// tree + the detect/test pills + the mode-override sentinel. Holding that
// transient status HERE (the page does not unmount on those toggles) and
// binding it down via the wizard's optional bindable props makes it
// persist for the session: re-opening the form shows the already-loaded
// tree and the detect/test results again. The picker open-flags + items
// stay local to the wizard (modals are transient; re-opening reloads).
//
// Static and dockerfile keep SEPARATE status objects because they are
// distinct sources sharing only the git field VALUES — never their
// discovery results. The git field values themselves already persist via
// the staticState/dockerfileState objects above; this is only the status.
let staticDetectStatus = $state<'idle' | 'pending' | 'ok' | 'error'>('idle');
let staticDetectError = $state('');
let staticTestStatus = $state<'idle' | 'pending' | 'ok' | 'error'>('idle');
let staticTestError = $state('');
let staticTree = $state<api.FolderEntry[]>([]);
let staticModeUserOverride = $state(false);
let staticTreeLoaded = $state(false);
let dockerfileDetectStatus = $state<'idle' | 'pending' | 'ok' | 'error'>('idle');
let dockerfileDetectError = $state('');
let dockerfileTestStatus = $state<'idle' | 'pending' | 'ok' | 'error'>('idle');
let dockerfileTestError = $state('');
const useComposeForm = $derived(sourceKind === 'compose' && !advancedJson);
const useImageForm = $derived(sourceKind === 'image' && !advancedJson);
const useStaticForm = $derived(sourceKind === 'static' && !advancedJson);
const useDockerfileForm = $derived(sourceKind === 'dockerfile' && !advancedJson);
// Image-ref conflict detection. The ImageSourceForm runs the debounced
// /api/discovery/image/conflicts lookup and writes its results back into
// this triplet (bound below). The submit gate reads them: the first
// submit while conflicts are present is blocked + acknowledged; a second
// click proceeds.
let imageConflicts = $state<api.ImageConflict[]>([]);
let imageConflictAcknowledged = $state(false);
let imageConflictBlocked = $state(false);
// Registry list for the image registry_name picker. Loaded lazily on
// mount; failure to load is non-fatal — the field falls back to a plain
// text input via the empty-list path so the form still works.
let registries = $state<{ name: string; url: string }[]>([]);
let loading = $state(true);
let submitting = $state(false);
let error = $state('');
// Unsaved-changes guard state. `submitted` flips true around a
// successful create so the navigation guard does NOT prompt when we
// programmatically route to the new workload's detail page. It stays
// true thereafter (the page is being torn down anyway).
let submitted = $state(false);
// Cache the schema sample per kind so flipping the dropdown back and
// forth doesn't re-fetch. Plugins can grow new fields server-side
// without a frontend rebuild — that's the whole point of routing the
// initial form body through /api/hooks/kinds/{kind}/schema instead of
// hardcoding samples in this file.
const schemaCache = new Map<string, string>();
async function fetchSampleJSON(kind: string): Promise<string> {
if (!kind) return '{}';
const cached = schemaCache.get(kind);
if (cached !== undefined) return cached;
try {
const res = await api.getHookKindSchema(kind);
const text = JSON.stringify(res.sample ?? {}, null, 2);
schemaCache.set(kind, text);
return text;
} catch {
return sourceConfigSample(kind) || '{}';
}
}
// Seed the per-kind state object from a source_config JSON blob. Thin
// wrappers over the shared `sourceForms.ts` seeders — only the chosen
// kind's object is reseeded so switching kinds preserves the others.
function seedFormState(kind: string, jsonText: string): void {
if (kind === 'compose') composeState = seedComposeState(jsonText);
else if (kind === 'image') imageState = seedImageState(jsonText);
else if (kind === 'static') staticState = seedStaticState(jsonText);
else if (kind === 'dockerfile') dockerfileState = seedDockerfileState(jsonText);
}
// Serialize the per-kind state object back to the canonical JSON string
// for the Advanced-JSON editor view. env/volumes (image) and storage_* /
// unknown keys (static/dockerfile) are preserved from the current
// sourceConfig via the module serializers.
function formStateToJSON(kind: string): string {
if (kind === 'compose') return stringifyConfig(composeToConfig(composeState));
if (kind === 'image') return stringifyConfig(imageToConfig(imageState, sourceConfig));
if (kind === 'static') return stringifyConfig(staticToConfig(staticState, sourceConfig));
if (kind === 'dockerfile') return stringifyConfig(dockerfileToConfig(dockerfileState, sourceConfig));
return sourceConfig;
}
onMount(async () => {
try {
kinds = await api.listHookKinds();
if (kinds.sources.length > 0 && !kinds.sources.includes(sourceKind)) {
sourceKind = kinds.sources[0];
}
sourceConfig = await fetchSampleJSON(sourceKind);
seedFormState(sourceKind, sourceConfig);
// Best-effort registry list for the picker. If the user
// is on an installation without registries configured,
// the field gracefully renders as a free-text input.
try {
registries = (await api.listRegistries()).map((r) => ({ name: r.name, url: r.url }));
} catch {
registries = [];
}
// Best-effort fetch of existing triggers — feeds the
// "Pick existing" mode. Failure leaves the picker empty
// and the operator can still create one inline.
try {
existingTriggers = await api.listTriggers();
} catch {
existingTriggers = [];
}
} catch (e) {
error = e instanceof Error ? e.message : $t('apps.new.loadingKinds');
} finally {
loading = false;
}
});
function sourceConfigSample(kind: string): string {
switch (kind) {
case 'image':
return JSON.stringify(
{
image: 'registry.example.com/owner/app',
registry_name: '',
port: 8080,
healthcheck: '/healthz',
env: {},
volumes: [],
cpu_limit: 0,
memory_limit: 0,
default_tag: 'latest'
},
null,
2
);
case 'compose':
return JSON.stringify(
{
compose_yaml: 'services:\n web:\n image: nginx:alpine\n ports:\n - "80"\n',
compose_project_name: ''
},
null,
2
);
case 'static':
return JSON.stringify(
{
provider: 'gitea',
base_url: 'https://git.example.com',
repo_owner: 'owner',
repo_name: 'pages',
branch: 'main',
folder_path: '',
mode: 'static',
render_markdown: false
},
null,
2
);
case 'dockerfile':
return JSON.stringify(
{
provider: 'gitea',
base_url: 'https://git.example.com',
repo_owner: 'owner',
repo_name: 'myservice',
branch: 'main',
context_path: '',
dockerfile_path: 'Dockerfile',
port: 8080
},
null,
2
);
default:
return '{}';
}
}
// Tracks the previously-selected sourceKind across an onSourceChange
// transition. Used to detect static ↔ dockerfile (both git-clone
// sources sharing the static* state vars for provider/repo/branch/
// token) so we can preserve those fields across the switch instead
// of overwriting them with sample placeholders.
//
// Initialized with the same literal as `sourceKind` (intentional
// mirror, not a snapshot-of-state — the value is updated manually
// inside onSourceChange, so no Svelte reactivity is needed).
let prevSourceKind = $state<string>('image');
async function onSourceChange() {
const previous = prevSourceKind;
const current = sourceKind;
prevSourceKind = current;
const wasGit = previous === 'static' || previous === 'dockerfile';
const isGit = current === 'static' || current === 'dockerfile';
// Cross-git transition: snapshot the shared git-discovery slice
// before fetching the new sample so we can restore after seed.
// Without this, switching static → dockerfile (or back) clobbers
// the user's already-typed provider/baseURL/repo/branch/token
// with the placeholder values baked into the sample blob. The
// slice lives on whichever git state object was active before.
let gitSnapshot: GitSourceState | null = null;
if (wasGit && isGit) {
const prevState = previous === 'dockerfile' ? dockerfileState : staticState;
gitSnapshot = {
provider: prevState.provider,
baseURL: prevState.baseURL,
repoOwner: prevState.repoOwner,
repoName: prevState.repoName,
branch: prevState.branch,
accessToken: prevState.accessToken
};
}
sourceConfig = await fetchSampleJSON(current);
// Switching INTO any kind seeds its form state from the sample so
// the operator sees something sensible immediately. Switching OUT
// leaves the other kinds' state alone — re-entry restores it.
seedFormState(current, sourceConfig);
advancedJson = false;
// Restore the shared git fields if we were just on a git source and
// are switching to another git source. Their values are still valid;
// the new kind's seed only matters for kind-specific fields
// (folder/mode for static; context/dockerfile/port for dockerfile).
if (gitSnapshot) {
const target = current === 'dockerfile' ? dockerfileState : staticState;
target.provider = gitSnapshot.provider;
target.baseURL = gitSnapshot.baseURL;
target.repoOwner = gitSnapshot.repoOwner;
target.repoName = gitSnapshot.repoName;
target.branch = gitSnapshot.branch;
target.accessToken = gitSnapshot.accessToken;
// The TARGET kind's git fields were just overwritten with the other
// source's values, so any persisted detect/test result (or loaded
// tree) for the target no longer corresponds to what's shown.
// Invalidate it — same "fields changed → invalidate downstream"
// philosophy the wizard's own selectRepo/selectBranch follow. The
// SOURCE kind's status is left intact so flipping back restores it.
invalidateDiscoveryStatus(current);
// Keep sourceConfig in sync so the Advanced JSON view reflects
// the actual (preserved) state, not the sample placeholders.
sourceConfig = formStateToJSON(current);
}
}
// Reset the lifted transient discovery status for a git source kind. Used
// when a cross-git switch overwrites that kind's repo coordinates, so a
// stale "connection OK" pill / loaded tree doesn't linger against fields
// the user never probed under this kind.
function invalidateDiscoveryStatus(kind: string): void {
if (kind === 'static') {
staticDetectStatus = 'idle';
staticDetectError = '';
staticTestStatus = 'idle';
staticTestError = '';
staticTree = [];
staticTreeLoaded = false;
} else if (kind === 'dockerfile') {
dockerfileDetectStatus = 'idle';
dockerfileDetectError = '';
dockerfileTestStatus = 'idle';
dockerfileTestError = '';
}
}
// Toggle between the kind-aware form and the raw JSON editor.
// Direction matters: going to Advanced JSON commits the form fields
// into sourceConfig first so the user sees the same data they were
// editing; going back to the form re-seeds from whatever they typed
// in the JSON so manual JSON edits aren't lost.
function toggleAdvancedJSON() {
if (!advancedJson) {
sourceConfig = formStateToJSON(sourceKind);
advancedJson = true;
} else {
seedFormState(sourceKind, sourceConfig);
advancedJson = false;
}
}
// JSON validity hints — non-blocking, just a small status pill in the
// editor footer. Server still gets the parse error if the user submits.
function jsonOk(s: string): boolean {
try {
JSON.parse(s);
return true;
} catch {
return false;
}
}
const sourceValid = $derived(jsonOk(sourceConfig));
const sourceLines = $derived(sourceConfig.split('\n').length);
const sourceBytes = $derived(new Blob([sourceConfig]).size);
// Trigger-step validity. Inline mode requires a complete kind+name+config;
// pick mode requires a chosen trigger; skip mode is always valid.
const triggerStepValid = $derived.by(() => {
if (triggerMode === 'skip') return true;
if (triggerMode === 'pick') return !!pickedTriggerId;
return isTriggerFormValid(triggerForm);
});
// ── Manifest summary (Review step) ──────────────────────────────
// A read-only spec-sheet of the whole workload, rendered below the
// public-face inputs on the final step. All value-derivation lives here
// (the page owns the state); AppManifest is pure presentation. Each row
// is { label, value, mono? } — mono marks machine-readable literals
// (image refs, repo paths, branches, ports, FQDNs).
// Source row value — the kind's 1-2 key identifiers. The kind itself is
// shown as a badge in the manifest header, so this is just the values.
const sourceSummary = $derived.by((): { value: string; mono: boolean } => {
const dash = ' · ';
if (sourceKind === 'image') {
const ref = imageState.ref.trim();
const tag = imageState.defaultTag.trim() || 'latest';
const reg = imageState.registryName.trim() || $t('apps.new.manifest.registryPublic');
const refTag = ref ? `${ref}:${tag}` : '';
return { value: refTag ? `${refTag}${dash}${reg}` : reg, mono: true };
}
if (sourceKind === 'compose') {
const project = composeState.projectName.trim();
return {
value: project || $t('apps.new.composeProjectPlaceholder'),
mono: !!project
};
}
if (sourceKind === 'static') {
const repo = gitRepoRef(staticState.repoOwner, staticState.repoName, staticState.branch);
const folder = staticState.folderPath.trim() || $t('apps.new.manifest.folderRoot');
return { value: `${repo}${dash}${folder}${dash}${staticState.mode}`, mono: true };
}
if (sourceKind === 'dockerfile') {
const repo = gitRepoRef(
dockerfileState.repoOwner,
dockerfileState.repoName,
dockerfileState.branch
);
const dfPath = dockerfileState.dockerfilePath.trim() || 'Dockerfile';
const port =
typeof dockerfileState.port === 'number' && dockerfileState.port > 0
? `:${dockerfileState.port}`
: '';
return { value: `${repo}${dash}${dfPath}${port}`, mono: true };
}
return { value: sourceKind, mono: true };
});
// owner/repo@branch — the canonical git source identifier used by both
// static and dockerfile sources. Falls back gracefully on empty fields.
function gitRepoRef(owner: string, name: string, branch: string): string {
const o = owner.trim();
const n = name.trim();
const b = branch.trim() || 'main';
const slug = o && n ? `${o}/${n}` : o || n || '—';
return `${slug}@${b}`;
}
// Trigger row value — inline reads the inline form's kind+name; pick reads
// the chosen existing trigger; skip is "Manual only".
const triggerSummary = $derived.by((): string => {
if (triggerMode === 'skip') return $t('apps.new.manifest.triggerManual');
if (triggerMode === 'pick') {
const picked = existingTriggers.find((tr) => tr.id === pickedTriggerId);
if (picked) return `${picked.name} · ${picked.kind}`;
return $t('apps.new.triggers.pickPlaceholder');
}
const nm = triggerForm.name.trim();
return nm ? `${nm} · ${triggerForm.kind}` : triggerForm.kind;
});
// Public-face row value — FQDN + port, or "Internal only" when nothing set.
const faceSummary = $derived.by((): { value: string; mono: boolean } => {
const sub = publicSubdomain.trim();
const dom = publicDomain.trim();
const hasPort = typeof publicPort === 'number' && publicPort > 0;
if (!sub && !dom && !hasPort) {
return { value: $t('apps.new.manifest.internalOnly'), mono: false };
}
const host = [sub, dom].filter(Boolean).join('.');
const port = hasPort ? `:${publicPort}` : '';
return { value: `${host || '—'}${port}`, mono: true };
});
const manifestRows = $derived<ManifestRow[]>([
{ label: $t('apps.new.manifest.name'), value: name.trim() || $t('apps.new.manifest.unnamed') },
{ label: $t('apps.new.manifest.source'), value: sourceSummary.value, mono: sourceSummary.mono },
{ label: $t('apps.new.manifest.trigger'), value: triggerSummary },
{ label: $t('apps.new.manifest.publicFace'), value: faceSummary.value, mono: faceSummary.mono }
]);
// ── Wizard step state ───────────────────────────────────────────
// The form is split into four progressive steps. `currentStep` is
// 1-based; `maxReached` tracks the furthest validly-reached step so
// the rail can jump back (never skip forward past an invalid step).
// Per-step validity reuses the exact conditions the final submit gate
// enforced before the wizard split, so nothing regresses.
const STEP_COUNT = 4;
let currentStep = $state(1);
let maxReached = $state(1);
const step1Valid = $derived(name.trim() !== '' && sourceKind !== '');
// Cleared <input type=number> binds to null in Svelte 5 (not 0), and
// `null > 0` is false — guard against null/NaN/non-positive so a blank
// port field doesn't pass. Mirrors the legacy dockerfilePortValid check.
const dockerfilePortValid = $derived(
typeof dockerfileState.port === 'number' &&
Number.isFinite(dockerfileState.port) &&
dockerfileState.port > 0
);
// Per-step validity reuses the EXACT conditions the page enforced before
// the extraction (owner+name for git sources; +port for dockerfile; the
// raw-JSON path gates on parseability). The stricter sourceForms.ts
// predicates additionally require base_url, so we keep these inline to
// avoid regressing the gate.
const step2Valid = $derived(
!(
(!useComposeForm && !useImageForm && !useStaticForm && !useDockerfileForm && !sourceValid) ||
(useImageForm && !imageState.ref.trim()) ||
(useStaticForm && (!staticState.repoOwner.trim() || !staticState.repoName.trim())) ||
(useDockerfileForm &&
(!dockerfileState.repoOwner.trim() ||
!dockerfileState.repoName.trim() ||
!dockerfilePortValid))
)
);
const step3Valid = $derived(triggerStepValid);
function stepValid(step: number): boolean {
if (step === 1) return step1Valid;
if (step === 2) return step2Valid;
if (step === 3) return step3Valid;
return true;
}
// Active step's validity — gates the Next button.
const canAdvance = $derived(stepValid(currentStep));
const stepLabels = $derived([
$t('apps.new.wizard.stepBasics'),
$t('apps.new.wizard.stepConfigure'),
$t('apps.new.wizard.stepTrigger'),
$t('apps.new.wizard.stepReview')
]);
function goToStep(step: number) {
// Rail navigation: only into an already-reached step.
if (step < 1 || step > maxReached) return;
currentStep = step;
}
function goNext() {
if (currentStep >= STEP_COUNT || !canAdvance) return;
currentStep += 1;
if (currentStep > maxReached) maxReached = currentStep;
}
function goBack() {
if (currentStep > 1) currentStep -= 1;
}
// ── Unsaved-changes guard ─────────────────────────────────────────
// `dirty` is intentionally conservative (not over-eager): it trips
// once the operator has invested meaningful effort — named the
// workload, advanced past the Basics step, or named an inline
// trigger. The seeded source-config placeholders (e.g. owner/app)
// deliberately do NOT count, so simply landing on the page and
// switching source kinds won't nag on leave.
const dirty = $derived(
name.trim() !== '' || currentStep > 1 || triggerForm.name.trim() !== ''
);
// SvelteKit in-app navigation guard. A successful create sets
// `submitted` first so the goto(`/apps/{id}`) is never blocked. Rather
// than the browser's native confirm, cancel the navigation and open the
// app's ConfirmDialog; on confirm, re-issue the nav with `confirmedLeave`
// set so the second beforeNavigate pass lets it through. Hard unloads
// (tab close / reload / external) can't show custom UI, so those fall
// through to the native beforeunload prompt below.
let leaveConfirmOpen = $state(false);
let pendingNavUrl = $state<string | null>(null);
let confirmedLeave = $state(false);
beforeNavigate((nav) => {
if (submitted || submitting || confirmedLeave || !dirty) return;
if (nav.willUnload || !nav.to) return;
nav.cancel();
pendingNavUrl = nav.to.url.href;
leaveConfirmOpen = true;
});
function confirmLeave() {
leaveConfirmOpen = false;
confirmedLeave = true;
const url = pendingNavUrl;
pendingNavUrl = null;
if (url) void goto(url);
}
function cancelLeave() {
leaveConfirmOpen = false;
pendingNavUrl = null;
}
// Browser-level guard for tab close / reload / external navigation,
// which beforeNavigate does not cover. Setting returnValue triggers
// the native "Leave site?" prompt. Removed on destroy.
function onBeforeUnload(event: BeforeUnloadEvent) {
if (submitted || submitting || !dirty) return;
event.preventDefault();
// Legacy browsers require returnValue to be set to a string.
event.returnValue = '';
}
onMount(() => {
window.addEventListener('beforeunload', onBeforeUnload);
});
onDestroy(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('beforeunload', onBeforeUnload);
}
});
async function submit(e: Event) {
e.preventDefault();
// Enter pressed inside a non-final step advances the wizard rather
// than submitting a half-filled form.
if (currentStep < STEP_COUNT) {
goNext();
return;
}
error = '';
submitting = true;
try {
let parsedSource: unknown;
if (useComposeForm) {
// All four form paths route through the shared sourceForms.ts
// serializers so the POSTed source_config is byte-identical to
// the legacy inline helpers (key order + preserve/scrub rules
// included). env/volumes (image) and storage_* / unknown keys
// (static/dockerfile) are preserved from `sourceConfig`.
parsedSource = composeToConfig(composeState);
} else if (useImageForm) {
parsedSource = imageToConfig(imageState, sourceConfig);
// Conflict guard: if the resolved image ref already belongs to
// other workloads and the operator hasn't acknowledged that
// fact, intercept the first click so they get a chance to
// review. A second click (with imageConflictAcknowledged
// flipped true) goes through unchanged.
if (imageConflicts.length > 0 && !imageConflictAcknowledged) {
imageConflictAcknowledged = true;
imageConflictBlocked = true;
submitting = false;
return;
}
imageConflictBlocked = false;
} else if (useStaticForm) {
parsedSource = staticToConfig(staticState, sourceConfig);
} else if (useDockerfileForm) {
parsedSource = dockerfileToConfig(dockerfileState, sourceConfig);
} else {
try {
parsedSource = JSON.parse(sourceConfig);
} catch {
throw new Error($t('apps.new.errors.sourceConfigInvalid'));
}
}
// Triggers no longer ride on the workload row; the backend
// still accepts the legacy fields but the new code path
// passes a manual placeholder + empty config and binds a
// real Trigger record after creation.
const body: PluginWorkloadInput = {
name: name.trim(),
source_kind: sourceKind,
source_config: parsedSource,
trigger_kind: '',
trigger_config: {}
};
if (publicSubdomain || publicDomain || publicPort > 0) {
body.public_faces = [
{
subdomain: publicSubdomain,
domain: publicDomain,
target_service: '',
target_port: publicPort,
access_list_id: 0,
enable_ssl: true
}
];
}
const created = await api.createPluginWorkload(body);
// The workload now exists — mark submitted so the navigation
// guard does not prompt on the goto below (or on any redirect
// the bind step might trigger).
submitted = true;
// Bind a trigger to the freshly-created workload. Keep going
// to the detail page even on bind failure — the operator can
// retry from the workload's Triggers panel without losing
// their work.
if (triggerMode === 'inline' || triggerMode === 'pick') {
try {
if (triggerMode === 'inline') {
const inline = buildTriggerInput(triggerForm);
await api.bindTriggerToWorkload(created.id, { inline });
} else if (pickedTriggerId) {
await api.bindTriggerToWorkload(created.id, {
trigger_id: pickedTriggerId
});
}
} catch (be) {
const msg = be instanceof Error ? be.message : $t('apps.new.errors.triggerBindUnknown');
// Surface the bind failure to the user, then still
// route to the detail page where they can retry.
try {
sessionStorage.setItem(
`tinyforge.bindError.${created.id}`,
$t('apps.new.triggers.bindError', { error: msg })
);
} catch {
// session storage may be disabled — ignore.
}
}
}
goto(`/apps/${created.id}`);
} catch (e) {
error = e instanceof Error ? e.message : $t('apps.new.errors.createFailed');
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>{$t('apps.new.pageTitle')}</title>
</svelte:head>
<div class="forge">
{#snippet newLede()}
{$t('apps.new.ledePrefix')} <em>{$t('apps.new.ledeSourceLabel')}</em>
{$t('apps.new.ledeSourceMid')} <em>{$t('apps.new.ledeTriggerLabel')}</em> {$t('apps.new.ledeSuffix')}
{/snippet}
<ForgeHero
backHref="/apps"
backLabel={$t('apps.new.backLabel')}
eyebrowSuffix={$t('apps.new.eyebrowSuffix')}
title={$t('apps.new.title')}
size="lg"
lede_html={newLede}
/>
{#if loading}
<div class="loading-line">
<span class="spinner" aria-hidden="true"></span>
<span>{$t('apps.new.loadingKinds')}</span>
</div>
{:else}
<div class="wizard-shell">
<WizardRail
steps={stepLabels.map((label) => ({ label }))}
current={currentStep}
{maxReached}
onselect={goToStep}
/>
<form onsubmit={submit} class="form" novalidate>
<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>
{#if error}
<div class="alert"><span class="alert-tag">{$t('apps.new.alertTag')}</span><span>{error}</span></div>
{/if}
{#if currentStep === 1}
<div class="wizard-step">
<div class="field">
<label for="app-name" class="field-label">
<span class="num">01</span>
<span class="lbl">{$t('apps.new.fieldName')}</span>
<span class="req">{$t('apps.new.fieldNameRequired')}</span>
</label>
<input
id="app-name"
type="text"
bind:value={name}
required
placeholder={$t('apps.new.fieldNamePlaceholder')}
class="input"
autocomplete="off"
spellcheck="false"
/>
<p class="hint">{$t('apps.new.fieldNameHint')}</p>
</div>
<div class="field">
<div class="field-label">
<span class="num">02</span>
<span class="lbl">{$t('apps.new.fieldSourcePlugin')}</span>
<span class="opt">{$t('apps.new.fieldNameRequired')}</span>
</div>
<SourceKindPicker
kinds={kinds.sources}
bind:value={sourceKind}
onchange={onSourceChange}
ariaLabel={$t('apps.new.fieldSourcePlugin')}
/>
<p class="hint">{$t('apps.new.fieldSourceHint')}</p>
</div>
</div>
{/if}
{#if currentStep === 2}
<div class="wizard-step">
<div class="field">
<div class="field-label">
<span class="num">03</span>
<span class="lbl">{$t('apps.new.fieldSourceConfig')}</span>
<span class="req">
{useComposeForm
? $t('apps.new.fieldConfigYaml')
: useImageForm || useStaticForm || useDockerfileForm
? $t('apps.new.fieldConfigForm')
: $t('apps.new.fieldConfigJson')}
</span>
</div>
{#if useComposeForm}
<ComposeSourceForm bind:form={composeState} onAdvanced={toggleAdvancedJSON} />
{:else if useImageForm}
<!-- Image source form. Owns inspect + conflict-lookup
async UX internally; writes the conflict triplet back
here so the submit gate can run the two-click flow. -->
<ImageSourceForm
bind:form={imageState}
{registries}
bind:submitting
bind:conflicts={imageConflicts}
bind:conflictAcknowledged={imageConflictAcknowledged}
bind:conflictBlocked={imageConflictBlocked}
onAdvanced={toggleAdvancedJSON}
/>
{:else if useStaticForm}
<!-- Static source form. Provider + repo + mode in
dedicated controls; access_token treated as a
password input. Augmented with inline discovery:
provider auto-detect, test-connection probe, and
repo / branch / folder pickers fed by the
/api/discovery/git/* endpoints. -->
<StaticSourceForm
bind:form={staticState}
bind:modeUserOverride={staticModeUserOverride}
bind:treeLoaded={staticTreeLoaded}
bind:tree={staticTree}
bind:detectStatus={staticDetectStatus}
bind:detectError={staticDetectError}
bind:testStatus={staticTestStatus}
bind:testError={staticTestError}
onAdvanced={toggleAdvancedJSON}
/>
{:else if useDockerfileForm}
<!-- Dockerfile source form. Shares the provider + repo +
branch + token git-discovery wiring with the static
source (StaticDiscoveryWizard in its compact variant —
same handlers, no folder tree). The build-step controls
(context path, dockerfile path, port) are the only
dockerfile-specific UI. -->
<DockerfileSourceForm
bind:form={dockerfileState}
bind:detectStatus={dockerfileDetectStatus}
bind:detectError={dockerfileDetectError}
bind:testStatus={dockerfileTestStatus}
bind:testError={dockerfileTestError}
onAdvanced={toggleAdvancedJSON}
/>
{: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.new.sourceConfigJsonTitle', { kind: sourceKind })}</span>
<span class="spacer"></span>
{#if sourceKind === 'compose' || sourceKind === 'image' || sourceKind === 'static' || sourceKind === 'dockerfile'}
<button
type="button"
class="editor-chip"
onclick={toggleAdvancedJSON}
title={$t('apps.new.switchToFormTitle')}
>
{$t('apps.new.backToForm')}
</button>
{/if}
<button
type="button"
class="editor-chip"
onclick={() => (sourceConfig = sourceConfigSample(sourceKind))}
>
{$t('apps.new.resetSample')}
</button>
</div>
<textarea
id="app-source-config"
bind:value={sourceConfig}
rows="12"
spellcheck="false"
class="code-area"
aria-label={$t('apps.new.sourceConfigJsonAria')}
></textarea>
<div class="editor-foot">
<span class="foot-status" class:bad={!sourceValid}>
<span class="foot-dot" aria-hidden="true"></span>
{sourceValid ? $t('apps.new.jsonOk') : $t('apps.new.jsonInvalid')}
</span>
<span class="sep">·</span>
<span>{sourceLines} {$t('apps.new.linesUnit')}</span>
<span class="sep">·</span>
<span>{sourceBytes} B</span>
</div>
</div>
{/if}
<!-- The repo + branch EntityPicker mounts now live inside
StaticDiscoveryWizard (one mount each), so the static and
dockerfile forms share them automatically. -->
</div>
</div>
{/if}
{#if currentStep === 3}
<div class="wizard-step">
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num">04</span>
<span class="lbl">{$t('apps.new.triggers.section')}</span>
<span class="opt">{$t('apps.new.triggerNumOptional')}</span>
</legend>
<p class="hint">{$t('apps.new.triggers.sectionSub')}</p>
<!-- Mode selector — three short cards. The "active" card
reveals its sub-form below. Skipping is explicit so
users can ship the workload now and wire triggers
later from the detail page. -->
<div
class="trig-mode-row"
role="radiogroup"
aria-label={$t('apps.new.triggers.section')}
>
<button
type="button"
role="radio"
aria-checked={triggerMode === 'inline'}
class="trig-mode-card"
class:active={triggerMode === 'inline'}
onclick={() => (triggerMode = 'inline')}
>
<span class="trig-mode-tag mono">{$t('apps.new.triggerNewTag')}</span>
<span class="trig-mode-name">{$t('apps.new.triggers.modeInline')}</span>
<span class="trig-mode-hint">{$t('apps.new.triggers.modeInlineHint')}</span>
</button>
<button
type="button"
role="radio"
aria-checked={triggerMode === 'pick'}
class="trig-mode-card"
class:active={triggerMode === 'pick'}
onclick={() => (triggerMode = 'pick')}
>
<span class="trig-mode-tag mono">{$t('apps.new.triggerPickTag')}</span>
<span class="trig-mode-name">{$t('apps.new.triggers.modePick')}</span>
<span class="trig-mode-hint">{$t('apps.new.triggers.modePickHint')}</span>
</button>
<button
type="button"
role="radio"
aria-checked={triggerMode === 'skip'}
class="trig-mode-card"
class:active={triggerMode === 'skip'}
onclick={() => (triggerMode = 'skip')}
>
<span class="trig-mode-tag mono">{$t('apps.new.triggerSkipTag')}</span>
<span class="trig-mode-name">{$t('apps.new.triggers.modeSkip')}</span>
<span class="trig-mode-hint">{$t('apps.new.triggers.modeSkipHint')}</span>
</button>
</div>
{#if triggerMode === 'inline'}
<div class="trig-sub">
<TriggerKindForm
bind:state={triggerForm}
idPrefix="app-trig"
showName={true}
showWebhook={true}
showKindPicker={true}
/>
</div>
{:else if triggerMode === 'pick'}
<div class="trig-sub">
{#if existingTriggers.length === 0}
<div class="note muted-note">
<span class="note-tag">{$t('apps.new.noteEmptyTag')}</span>
<p>{$t('apps.new.triggers.pickEmpty')}</p>
</div>
{:else}
<label class="sub" for="app-trig-pick">
<span class="sub-label">{$t('apps.new.triggers.pickLabel')}</span>
<select
id="app-trig-pick"
class="input"
bind:value={pickedTriggerId}
>
<option value="">{$t('apps.new.triggers.pickPlaceholder')}</option>
{#each existingTriggers as tr (tr.id)}
<option value={tr.id}>
{tr.name} · {tr.kind}{tr.webhook_enabled
? ` · ${$t('apps.new.triggers.pickWebhookOn')}`
: ''}
</option>
{/each}
</select>
<span class="hint">{$t('apps.new.triggers.pickHint')}</span>
</label>
{/if}
</div>
{:else}
<div class="trig-sub">
<div class="note muted-note">
<span class="note-tag">{$t('apps.new.noteSkipTag')}</span>
<p>{$t('apps.new.triggers.skippedNote')}</p>
</div>
</div>
{/if}
</fieldset>
</div>
{/if}
{#if currentStep === 4}
<div class="wizard-step">
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num">05</span>
<span class="lbl">{$t('apps.new.faceLabel')}</span>
<span class="opt">{$t('apps.new.faceOptional')}</span>
</legend>
<div class="row three">
<label class="sub" for="app-public-subdomain">
<span class="sub-label">{$t('apps.new.faceSubdomain')}</span>
<input
id="app-public-subdomain"
type="text"
class="input"
bind:value={publicSubdomain}
placeholder={$t('apps.new.faceSubdomainPlaceholder')}
autocomplete="off"
/>
</label>
<label class="sub" for="app-public-domain">
<span class="sub-label">{$t('apps.new.faceDomain')}</span>
<input
id="app-public-domain"
type="text"
class="input"
bind:value={publicDomain}
placeholder={$t('apps.new.faceDomainPlaceholder')}
autocomplete="off"
/>
</label>
<label class="sub" for="app-public-port">
<span class="sub-label">{$t('apps.new.facePort')}</span>
<input
id="app-public-port"
type="number"
class="input"
bind:value={publicPort}
min="0"
max="65535"
/>
</label>
</div>
<p class="hint">{$t('apps.new.faceHint')}</p>
</fieldset>
<AppManifest rows={manifestRows} {sourceKind} />
</div>
{/if}
{#if currentStep === STEP_COUNT && useImageForm && imageConflictBlocked && imageConflicts.length > 0}
<div class="note conflict-blocked-note" role="status" aria-live="polite">
<span class="note-tag">{$t('apps.new.imageConflictTag')}</span>
<p>{$t('apps.new.imageConflictBlockedSubmit')}</p>
</div>
{/if}
<div class="wizard-nav">
{#if currentStep > 1}
<button type="button" class="forge-btn-ghost" onclick={goBack}>
<span class="arrow back" aria-hidden="true">←</span>
<span>{$t('apps.new.wizard.back')}</span>
</button>
{:else}
<a href="/apps" class="forge-btn-ghost">{$t('apps.new.cancel')}</a>
{/if}
{#if currentStep < STEP_COUNT}
<button type="button" class="forge-btn" onclick={goNext} disabled={!canAdvance}>
<span>{$t('apps.new.wizard.next')}</span>
<span class="arrow" aria-hidden="true">→</span>
</button>
{:else}
<button
class="forge-btn"
class:btn-warn={useImageForm && imageConflictBlocked && imageConflicts.length > 0}
type="submit"
disabled={submitting || !step1Valid || !step2Valid || !step3Valid}
>
<span>
{#if submitting}
{$t('apps.new.submitting')}
{:else if useImageForm && imageConflictBlocked && imageConflicts.length > 0}
{$t('apps.new.submitAnyway')}
{:else}
{$t('apps.new.submit')}
{/if}
</span>
<span class="arrow" aria-hidden="true">→</span>
</button>
{/if}
</div>
</form>
</div>
{/if}
<ConfirmDialog
open={leaveConfirmOpen}
title={$t('apps.new.unsavedChangesTitle')}
message={$t('apps.new.unsavedChanges')}
confirmLabel={$t('apps.new.unsavedChangesConfirm')}
onconfirm={confirmLeave}
oncancel={cancelLeave}
/>
</div>
<style>
.forge {
--accent: var(--forge-accent);
--accent-soft: var(--forge-accent-soft);
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 880px;
margin: 0 auto;
}
.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);
}
}
/* ── Form shell with registration corners ─────── */
.form {
position: relative;
display: flex;
flex-direction: column;
gap: 1.5rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-2xl);
padding: 1.75rem;
}
/* ── Wizard: step rail + progressive steps ─────── */
.wizard-shell {
display: flex;
align-items: flex-start;
gap: var(--space-8);
}
.wizard-shell > .form {
flex: 1;
min-width: 0;
}
/* One step visible at a time; preserve the form's inter-field rhythm. */
.wizard-step {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Staggered entrance — one orchestrated reveal as each step lands. */
.wizard-step > * {
animation: step-rise 360ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
}
.wizard-step > *:nth-child(2) {
animation-delay: 60ms;
}
.wizard-step > *:nth-child(3) {
animation-delay: 120ms;
}
.wizard-step > *:nth-child(n + 4) {
animation-delay: 180ms;
}
@keyframes step-rise {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.wizard-step > * {
animation: none;
}
}
.wizard-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-secondary);
}
.arrow.back {
margin-right: 0.1rem;
}
@media (max-width: 820px) {
.wizard-shell {
flex-direction: column;
gap: var(--space-5);
}
.wizard-shell > .form {
width: 100%;
}
}
.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);
}
/* ── Alert ─────────────────────────────────────── */
.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;
}
/* ── Fields ────────────────────────────────────── */
.field {
display: flex;
flex-direction: column;
gap: 0.55rem;
margin: 0;
padding: 0;
border: 0;
}
.field.group {
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.field-label {
display: flex;
align-items: center;
gap: 0.55rem;
margin: 0;
padding: 0;
}
.field-label.as-legend {
float: none;
width: 100%;
}
.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.1rem;
letter-spacing: -0.01em;
line-height: 1;
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);
}
.input:focus-visible {
outline: none;
}
.hint {
font-size: 0.78rem;
color: var(--text-tertiary);
margin: 0;
line-height: 1.45;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9rem;
}
.row.three {
grid-template-columns: 1fr 1fr 1fr;
}
@media (max-width: 600px) {
.row,
.row.three {
grid-template-columns: 1fr;
}
}
.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);
}
/* ── Code editor frame ─────────────────────────── */
.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;
letter-spacing: 0.02em;
}
.spacer {
flex: 1;
}
.editor-chip {
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
padding: 0.22rem 0.55rem;
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;
}
.editor-chip:hover {
border-color: var(--color-brand-400);
color: var(--text-primary);
background: var(--surface-card-hover);
}
.editor-chip:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.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;
}
.code-area::placeholder {
color: var(--text-tertiary);
}
.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;
}
.sep {
opacity: 0.5;
}
/* Wizard nav uses the app-wide primary button (`.forge-btn`, defined in
app.css) so Next/Create match every other primary action in the app.
The only local additions are the arrow-nudge affordance and the
conflict-state `.btn-warn` variant preserved from the legacy button. */
.forge-btn .arrow {
transition: transform 150ms ease;
}
.forge-btn:hover:not(:disabled) .arrow {
transform: translateX(2px);
}
.forge-btn:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
/* Variant used on the second submit click when image-conflicts are
present but the operator chose to proceed anyway. The colour shift
plus label change ("Forge anyway") signals that the click did
register without forcing a modal. */
.forge-btn.btn-warn {
background: var(--color-warning, var(--color-danger));
color: #fff;
}
.forge-btn.btn-warn:hover:not(:disabled) {
filter: brightness(1.05);
}
/* Three-column row grid — used by the public-face step. (The image /
dockerfile form variants of this grid moved into their components.) */
.row.three {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.9rem;
}
@media (max-width: 720px) {
.row.three {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 480px) {
.row.three {
grid-template-columns: 1fr;
}
}
/* ── Trigger mode picker ──────────────────────────
Three short cards (NEW / PICK / SKIP). The active
card lights up its tag in brand colour and reveals
the matching sub-form below in a soft inset panel. */
.trig-mode-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.55rem;
}
@media (max-width: 720px) {
.trig-mode-row {
grid-template-columns: 1fr;
}
}
.trig-mode-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.75rem 0.85rem;
text-align: left;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease,
transform 150ms ease;
}
.trig-mode-card:hover {
border-color: color-mix(in srgb, var(--forge-accent) 40%, var(--border-primary));
transform: translateY(-1px);
}
.trig-mode-card.active {
border-color: var(--forge-accent);
background: var(--forge-accent-soft);
box-shadow: inset 0 0 0 1px var(--forge-accent);
}
.trig-mode-tag {
display: inline-flex;
align-self: flex-start;
padding: 0.18rem 0.5rem;
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;
}
.trig-mode-card.active .trig-mode-tag {
background: var(--forge-accent);
}
.trig-mode-name {
font-weight: 600;
font-size: 0.92rem;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.trig-mode-hint {
font-size: 0.7rem;
color: var(--text-tertiary);
line-height: 1.45;
}
.trig-sub {
margin-top: 0.2rem;
padding: 0.95rem 1rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--surface-input);
}
.note {
display: flex;
gap: 0.7rem;
align-items: flex-start;
padding: 0.65rem 0.85rem;
background: var(--surface-card-hover);
border: 1px dashed var(--border-primary);
border-radius: var(--radius-lg);
}
.muted-note {
background: transparent;
}
.note-tag {
padding: 0.16rem 0.4rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.56rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
flex: 0 0 auto;
line-height: 1.4;
}
.note p {
margin: 0;
font-size: 0.83rem;
color: var(--text-secondary);
line-height: 1.5;
}
.conflict-blocked-note {
border-color: var(--color-warning, var(--forge-accent));
}
.conflict-blocked-note .note-tag {
background: var(--color-warning, var(--forge-accent));
}
</style>