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.
This commit is contained in:
2026-05-29 02:09:54 +03:00
parent 956943edbb
commit 410a131cec
112 changed files with 13285 additions and 2765 deletions
@@ -0,0 +1,181 @@
<!--
App manifest — a forged "spec-sheet" summarizing the whole workload on the
wizard's Review step so the operator can confirm everything before Create.
Pure presentation: the page computes the row set + source-kind via `$derived`
and passes them in. Values are rendered in a definition grid (mono uppercase
labels in the left column, values in the right). Machine-readable values
(image refs, repo paths, branches, ports, FQDNs) are set `mono` by the caller
so they read as the literals they are. The source kind is shown as an ember
badge in the manifest header.
-->
<script lang="ts">
import { t } from '$lib/i18n';
export interface ManifestRow {
label: string;
value: string;
mono?: boolean;
}
interface Props {
/** Definition rows: Name / Source / Trigger / Public face. */
rows: ManifestRow[];
/** Source-kind string (image / compose / static / dockerfile) — badge. */
sourceKind: string;
}
let { rows, sourceKind }: Props = $props();
</script>
<section class="manifest" aria-label={$t('apps.new.manifest.title')}>
<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="manifest-head">
<span class="forge-eyebrow">
<span class="forge-ember"></span>
<span class="eb-word">{$t('apps.new.manifest.title')}</span>
</span>
{#if sourceKind}
<span class="kind-badge mono">{sourceKind}</span>
{/if}
</header>
<dl class="manifest-grid">
{#each rows as row (row.label)}
<div class="manifest-row">
<dt class="manifest-label">{row.label}</dt>
<dd class="manifest-value" class:mono={row.mono}>{row.value}</dd>
</div>
{/each}
</dl>
</section>
<style>
/* Forged spec-sheet: subtle bordered panel with registration corners,
ember eyebrow header, and a label/value definition grid. Reuses the
forge token system end-to-end — no ad-hoc colours. */
.manifest {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-5) var(--space-5) var(--space-6);
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
}
.reg {
position: absolute;
width: 9px;
height: 9px;
border-color: var(--forge-accent);
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-xl);
}
.reg-tr {
top: -1px;
right: -1px;
border-top-width: 2px;
border-right-width: 2px;
border-top-right-radius: var(--radius-xl);
}
.reg-bl {
bottom: -1px;
left: -1px;
border-bottom-width: 2px;
border-left-width: 2px;
border-bottom-left-radius: var(--radius-xl);
}
.reg-br {
bottom: -1px;
right: -1px;
border-bottom-width: 2px;
border-right-width: 2px;
border-bottom-right-radius: var(--radius-xl);
}
.manifest-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--border-primary);
}
.eb-word {
font-weight: 700;
}
.kind-badge {
display: inline-flex;
align-self: flex-start;
padding: 0.2rem 0.55rem;
background: var(--forge-accent);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
border-radius: var(--radius-sm);
line-height: 1;
}
/* Definition grid — mono uppercase labels, values aligned in a second
column. Collapses to stacked rows on narrow viewports. */
.manifest-grid {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin: 0;
}
.manifest-row {
display: grid;
grid-template-columns: minmax(7rem, 0.32fr) 1fr;
gap: var(--space-4);
align-items: baseline;
padding: var(--space-2) 0;
border-bottom: 1px dashed var(--border-secondary);
}
.manifest-row:last-child {
border-bottom: 0;
}
.manifest-label {
margin: 0;
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.manifest-value {
margin: 0;
font-size: 0.9rem;
line-height: 1.5;
color: var(--text-primary);
word-break: break-word;
}
.manifest-value.mono {
font-family: var(--forge-mono);
font-size: 0.82rem;
}
@media (max-width: 560px) {
.manifest-row {
grid-template-columns: 1fr;
gap: var(--space-1);
}
}
</style>
@@ -0,0 +1,222 @@
<!--
Compose source form. Surfaces the YAML stack + optional project name as
proper controls instead of forcing the operator to hand-escape YAML inside
a JSON string. The parent owns the `ComposeFormState` (from
`$lib/workload/sourceForms`) and binds it here; serialization to the
`source_config` object is done by the parent via `composeToConfig` so the
shape stays byte-identical to the legacy inline path.
The "Advanced JSON" chip is rendered by the parent (it owns the raw-editor
toggle); this component is purely the form-field body.
-->
<script lang="ts">
import type { ComposeFormState } from '$lib/workload/sourceForms';
import { t } from '$lib/i18n';
interface Props {
form: ComposeFormState;
/** Flip to the raw-JSON editor (owned by the parent). */
onAdvanced: () => void;
}
let { form = $bindable(), onAdvanced }: Props = $props();
</script>
<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.composeHeader')}</span>
<span class="spacer"></span>
<button type="button" class="json-escape" onclick={onAdvanced} title={$t('apps.new.switchToJsonTitle')}>
{$t('apps.new.advancedJson')}
</button>
</div>
<textarea
id="app-compose-yaml"
bind:value={form.yaml}
rows="12"
spellcheck="false"
class="code-area"
placeholder={$t('apps.new.composePlaceholder')}
aria-label={$t('apps.new.composeAriaLabel')}
></textarea>
<div class="editor-foot">
<span class="foot-status">
<span class="foot-dot" aria-hidden="true"></span>
{$t('apps.new.fieldConfigYaml')}
</span>
<span class="sep">·</span>
<span>{form.yaml.split('\n').length} {$t('apps.new.linesUnit')}</span>
</div>
</div>
<label class="sub" for="app-compose-project">
<span class="sub-label">{$t('apps.new.composeProjectLabel')}</span>
<input
id="app-compose-project"
type="text"
class="input"
bind:value={form.projectName}
placeholder={$t('apps.new.composeProjectPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
</label>
<style>
.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;
}
.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;
}
/* ── "Edit as JSON" escape hatch ─────────────────────────
Quiet secondary text-link rather than a prominent chip, so it doesn't
compete with the form title. */
.json-escape {
background: none;
border: 0;
padding: 0;
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
cursor: pointer;
text-decoration: none;
text-underline-offset: 3px;
transition: color 120ms ease;
}
.json-escape:hover {
color: var(--forge-accent);
text-decoration: underline;
}
.json-escape:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.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);
}
:global([data-theme='dark']) .foot-status {
color: #86efac;
}
.sep {
opacity: 0.5;
}
</style>
@@ -0,0 +1,286 @@
<!--
Dockerfile source form. Shares the provider + repo + branch + token
git-discovery wiring with the static source (StaticDiscoveryWizard in its
compact `dockerfile` variant — same handlers, no folder tree). The
build-step controls (context path, dockerfile path, port) are the only
dockerfile-specific UI.
The parent owns the `DockerfileFormState` (from `$lib/workload/sourceForms`)
and binds it here; serialization to `source_config` is done by the parent
via `dockerfileToConfig` so the shape (incl. preserved unknown keys + the
scrubbed static-only keys) stays byte-identical. `DockerfileFormState
extends GitSourceState`, so the same object is bound into the wizard.
-->
<script lang="ts">
import type { DockerfileFormState } from '$lib/workload/sourceForms';
import StaticDiscoveryWizard from '$lib/components/workload/StaticDiscoveryWizard.svelte';
import { IconX } from '$lib/components/icons';
import { t } from '$lib/i18n';
interface Props {
form: DockerfileFormState;
/** Flip to the raw-JSON editor (owned by the parent). */
onAdvanced: () => void;
/**
* Transient discovery status — OPTIONAL pass-through to
* StaticDiscoveryWizard (dockerfile variant: detect + test only, no
* folder tree / mode). Each defaults to this component's own internal
* `$state`, so a parent that doesn't bind them keeps the original
* "resets on remount" behaviour (the detail/edit page `apps/[id]`
* binds none of these). The create wizard `apps/new` binds them up to
* the PAGE so the detect/test pills survive the form unmounting under
* the Advanced-JSON / source-kind toggles.
*/
detectStatus?: 'idle' | 'pending' | 'ok' | 'error';
detectError?: string;
testStatus?: 'idle' | 'pending' | 'ok' | 'error';
testError?: string;
}
let {
form = $bindable(),
onAdvanced,
detectStatus = $bindable('idle'),
detectError = $bindable(''),
testStatus = $bindable('idle'),
testError = $bindable('')
}: Props = $props();
// `touched` flips true on first blur — used by the pill to avoid shouting
// "required" the instant the user lands on the form.
let portTouched = $state(false);
// 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. Guard against null/NaN/non-positive here.
const portValid = $derived(
typeof form.port === 'number' && Number.isFinite(form.port) && form.port > 0
);
</script>
<div class="image-form">
<div class="image-form-head">
<span class="editor-title">{$t('apps.new.dockerfileHeader')}</span>
<button type="button" class="json-escape" onclick={onAdvanced} title={$t('apps.new.switchToJsonTitle')}>
{$t('apps.new.advancedJson')}
</button>
</div>
<StaticDiscoveryWizard
bind:git={form}
variant="dockerfile"
bind:detectStatus
bind:detectError
bind:testStatus
bind:testError
idPrefix="app-df"
/>
<!-- Build-step controls — the only dockerfile-only UI. The form is a
two-phase form (locate the code, describe how to build it). A
forge-eyebrow divider phrases the conceptual break. -->
<div class="df-section-break" aria-hidden="false">
<span class="forge-eyebrow">
<span class="forge-ember"></span>
<span class="eb-word">{$t('apps.new.dockerfileBuildEyebrow')}</span>
</span>
</div>
<div class="row">
<label class="sub" for="app-df-context">
<span class="sub-label">{$t('apps.new.dockerfileContextPath')}</span>
<input
id="app-df-context"
type="text"
class="input mono"
bind:value={form.contextPath}
placeholder={$t('apps.new.dockerfileContextPathPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
</label>
<label class="sub" for="app-df-dockerfile">
<span class="sub-label">{$t('apps.new.dockerfilePath')}</span>
<input
id="app-df-dockerfile"
type="text"
class="input mono"
bind:value={form.dockerfilePath}
placeholder="Dockerfile"
autocomplete="off"
spellcheck="false"
/>
</label>
</div>
<div class="row">
<label class="sub" for="app-df-port">
<span class="sub-label"
>{$t('apps.new.dockerfilePort')}<span class="req-star" aria-label={$t('apps.new.fieldRequired')}
>*</span
></span
>
<input
id="app-df-port"
type="number"
min="1"
max="65535"
class="input mono"
bind:value={form.port}
onblur={() => (portTouched = true)}
placeholder="8080"
required
/>
</label>
</div>
{#if portTouched && !portValid}
<div class="discover-pill discover-pill-bad">
<IconX size={12} />
<span>{$t('apps.new.dockerfilePortRequired')}</span>
</div>
{/if}
<p class="hint image-form-foot">{$t('apps.new.dockerfileFoot')}</p>
</div>
<style>
.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;
}
@media (max-width: 600px) {
.row {
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);
}
/* Required-field marker — same danger hue as the page-level `.req`
badge, rendered as a compact asterisk. */
.req-star {
margin-left: 0.2rem;
color: var(--color-danger);
font-weight: 700;
}
.editor-title {
margin-left: 0.4rem;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: 0.02em;
}
/* ── "Edit as JSON" escape hatch ─────────────────────────
Quiet secondary text-link rather than a prominent chip, so it doesn't
compete with the form title. */
.json-escape {
background: none;
border: 0;
padding: 0;
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
cursor: pointer;
text-decoration: none;
text-underline-offset: 3px;
transition: color 120ms ease;
}
.json-escape:hover {
color: var(--forge-accent);
text-decoration: underline;
}
.json-escape:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* ── Image source form shell (shared visual vocabulary) ── */
.image-form {
display: flex;
flex-direction: column;
gap: 0.9rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--surface-input);
padding: 0.85rem 1rem;
}
.image-form-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--border-primary);
margin-bottom: 0.2rem;
}
.image-form-foot {
margin-top: 0.2rem;
padding-top: 0.55rem;
border-top: 1px dashed var(--border-primary);
}
/* Conceptual section divider — separates git-discovery from build-step.
Same dashed border vocabulary as image-form-foot so it reads as a
sibling of the foot hint, not a new pattern. */
.df-section-break {
margin-top: 0.45rem;
padding-top: 0.55rem;
border-top: 1px dashed var(--border-primary);
}
.discover-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.28rem 0.55rem;
border-radius: var(--radius-md);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.06em;
line-height: 1;
align-self: flex-start;
}
.discover-pill-bad {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: var(--color-danger-dark);
border: 1px solid color-mix(in srgb, var(--color-danger) 40%, transparent);
}
:global([data-theme='dark']) .discover-pill-bad {
color: #fca5a5;
}
</style>
@@ -0,0 +1,741 @@
<!--
Image source form. Surfaces the most-used image-source fields as proper
controls (ref, port, healthcheck, default tag, registry, resource limits).
Env + volumes stay on the detail page where they have dedicated panels.
The parent owns the `ImageFormState` (from `$lib/workload/sourceForms`) and
binds it here; serialization to `source_config` is done by the parent via
`imageToConfig` so the shape stays byte-identical.
Two pieces of async UX live in this component but write their results back
through bindable props so the parent's submit gate can read them:
• Inspect — pulls port + healthcheck from the image metadata. Guarded by
an AbortController + ref re-check so a late response can't relabel a
newer ref. Touch sentinels stop Inspect overwriting fields the operator
already edited.
• Conflict lookup — debounced /api/discovery/image/conflicts call,
guarded by a sequence token so a slow earlier response can't clobber a
faster later one. The `conflicts` / `conflictAcknowledged` /
`conflictBlocked` triplet is bound to the parent which runs the
two-click "submit anyway" gate.
-->
<script lang="ts">
import { onDestroy } from 'svelte';
import type { ImageFormState } from '$lib/workload/sourceForms';
import * as api from '$lib/api';
import { IconSearch, IconLoader } from '$lib/components/icons';
import RegistryImagePicker from '$lib/components/RegistryImagePicker.svelte';
import { t } from '$lib/i18n';
interface Props {
form: ImageFormState;
/** Registry list for the picker; empty falls back to a text input. */
registries?: { name: string; url: string }[];
/** Bound submitting flag — gates the debounced conflict lookup. */
submitting?: boolean;
/** Bound conflict triplet — the parent's submit gate reads these. */
conflicts?: api.ImageConflict[];
conflictAcknowledged?: boolean;
conflictBlocked?: boolean;
/**
* Gate the debounced /api/discovery/image/conflicts lookup + the conflict
* warning panel. The create wizard wants it (default `true`) so an
* operator about to deploy a duplicate image is warned. The detail-page
* EDIT form must turn it OFF — there the workload would flag itself as a
* conflict with its own image. When off, the conflict triplet / submitting
* / registries props are unused and may be omitted by the parent.
*/
enableConflicts?: boolean;
/** Flip to the raw-JSON editor (owned by the parent). */
onAdvanced: () => void;
}
let {
form = $bindable(),
registries = [],
submitting = $bindable(false),
conflicts = $bindable([]),
conflictAcknowledged = $bindable(false),
conflictBlocked = $bindable(false),
enableConflicts = true,
onAdvanced
}: Props = $props();
// ── Inspect state ─────────────────────────────────────────────────
type InspectStatus = 'idle' | 'pending' | 'ok' | 'error';
let inspectStatus = $state<InspectStatus>('idle');
// AbortController + sequence guard so a late inspect response cannot
// mislabel the *current* image ref after the user typed a new one.
let inspectAbort: AbortController | null = null;
// Touch sentinels for fields with a "0 == empty" sentinel value (port,
// healthcheck). Once the user interacts, Inspect leaves them alone — even
// when still `0` / "" (some images really do listen on port 0 / have no
// healthcheck).
let portTouched = $state(false);
let healthcheckTouched = $state(false);
// The legacy inline seedImageFromJSON reset the touched sentinels on every
// reseed (mount, kind switch, Advanced↔form toggle). The parent reseeds by
// REASSIGNING the form object (a fresh seed* result), so the object's
// identity changes on reseed but not on in-place field edits. Track that
// identity to reset the sentinels exactly when a reseed happens — keeping
// Inspect's "only fill untouched fields" behaviour identical to before.
let lastSeenForm: ImageFormState | null = null;
$effect(() => {
if (form !== lastSeenForm) {
lastSeenForm = form;
portTouched = false;
healthcheckTouched = false;
}
});
// ── Conflict-lookup state ─────────────────────────────────────────
let conflictLoading = $state(false);
let conflictDebounce: ReturnType<typeof setTimeout> | null = null;
// Race token so a slow earlier response cannot overwrite a faster later one.
let conflictReqSeq = 0;
// Query the backend for workloads already using this image. Failures are
// silent (the existing list stays) — a transient network blip should never
// clear a real warning. The caller guards against empty / too-short refs.
async function fetchImageConflicts(ref: string): Promise<void> {
const mine = ++conflictReqSeq;
conflictLoading = true;
try {
const result = await api.listImageConflicts(ref);
if (mine === conflictReqSeq) {
conflicts = result;
}
} catch (e) {
if (mine === conflictReqSeq) {
// no-op; intentionally preserve prior conflicts
void e;
}
} finally {
if (mine === conflictReqSeq) {
conflictLoading = false;
}
}
}
function scheduleImageConflictLookup(ref: string) {
if (conflictDebounce) {
clearTimeout(conflictDebounce);
conflictDebounce = null;
}
// Conflict detection disabled (edit form) — never probe the backend.
if (!enableConflicts) return;
const trimmed = ref.trim();
if (trimmed.length < 3 || submitting) return;
conflictDebounce = setTimeout(() => {
conflictDebounce = null;
void fetchImageConflicts(trimmed);
}, 300);
}
function onImageRefInput() {
// Typing invalidates any prior acknowledgement and clears the stale
// list so the panel doesn't lie about the current ref; the debounced
// lookup will repopulate it.
conflictAcknowledged = false;
conflictBlocked = false;
conflicts = [];
// Also reset the inspect pill — its OK/error status belongs to the
// *previous* ref and would mislead the user otherwise.
inspectStatus = 'idle';
inspectAbort?.abort();
scheduleImageConflictLookup(form.ref);
}
function onImageRefBlur() {
if (!enableConflicts) return;
const trimmed = form.ref.trim();
if (trimmed.length < 3 || submitting) return;
if (conflictDebounce) {
clearTimeout(conflictDebounce);
conflictDebounce = null;
}
void fetchImageConflicts(trimmed);
}
// Tear down the pending debounce timer + cancel any in-flight inspect
// request if the user navigates away mid-window — otherwise the late
// resolve mutates dead state.
onDestroy(() => {
if (conflictDebounce) {
clearTimeout(conflictDebounce);
conflictDebounce = null;
}
inspectAbort?.abort();
});
// Pull port + healthcheck from the image's exposed metadata. Only
// overwrites untouched fields. A new call aborts any in-flight one, and we
// re-check the ref after the await so a late response can't relabel the
// *new* image ref the user just typed.
async function inspectImageRef() {
const ref = form.ref.trim();
if (!ref) return;
if (inspectStatus === 'pending') return;
inspectAbort?.abort();
const controller = new AbortController();
inspectAbort = controller;
inspectStatus = 'pending';
try {
const result = await api.inspectImage(ref, controller.signal);
// Late-arrival guard: if the user edited the ref during the flight,
// our success belongs to a stale value — discard.
if (form.ref.trim() !== ref) return;
// Only fill fields the operator hasn't touched. The sentinel is the
// touched flag, not the value — a user who deliberately types `0`
// or clears the healthcheck still owns the field.
if (!portTouched && typeof result.port === 'number') form.port = result.port;
if (!healthcheckTouched && typeof result.healthcheck === 'string') {
form.healthcheck = result.healthcheck;
}
inspectStatus = 'ok';
} catch (e) {
if (controller.signal.aborted) return;
if (form.ref.trim() !== ref) return;
// Show a friendly, localized message — never the raw backend
// string (the discovery handlers were just hardened to drop
// leaky daemon errors, so there is nothing useful to surface).
void e;
inspectStatus = 'error';
}
}
</script>
<div class="image-form">
<div class="image-form-head">
<span class="editor-title">{$t('apps.new.imageHeader')}</span>
<button type="button" class="json-escape" onclick={onAdvanced} title={$t('apps.new.switchToJsonTitle')}>
{$t('apps.new.advancedJson')}
</button>
</div>
<label class="sub" for="app-image-ref">
<span class="sub-label"
>{$t('apps.new.imageRefLabel')}<span class="req-star" aria-label={$t('apps.new.fieldRequired')}>*</span></span
>
<div class="input-with-button">
<input
id="app-image-ref"
type="text"
class="input mono"
bind:value={form.ref}
oninput={onImageRefInput}
onblur={onImageRefBlur}
placeholder={$t('apps.new.imageRefPlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<button
type="button"
class="discover-btn"
onclick={inspectImageRef}
disabled={!form.ref.trim() || inspectStatus === 'pending'}
title={$t('apps.new.imageInspectHint')}
>
{#if inspectStatus === 'pending'}
<IconLoader size={14} />
{:else}
<IconSearch size={14} />
{/if}
<span>{$t('apps.new.imageInspect')}</span>
</button>
<RegistryImagePicker
current={form.ref}
onpick={(ref, registryName) => {
form.ref = ref;
// Auto-select the registry the image came from so private
// images pull with the right credentials without a second
// manual step. Only adopt it when the picker surfaced a
// non-empty name (public images carry '') so we never wipe a
// registry the operator already chose.
if (registryName) form.registryName = registryName;
onImageRefInput();
}}
/>
</div>
<p class="hint">{$t('apps.new.imageRefHint')}</p>
{#if inspectStatus === 'ok'}
<span class="discover-pill discover-pill-ok inline">{$t('apps.new.imageInspectOk')}</span>
{:else if inspectStatus === 'error'}
<span class="discover-pill discover-pill-bad inline">{$t('apps.new.errors.inspectFailed')}</span>
{/if}
<!--
Conflict-checking indicator. Reserves no layout when idle and is a
quiet inline hint (not the full panel) while a lookup is in flight,
so a no-conflict blur no longer flashes the warning panel in then
out. The panel itself renders only for REAL conflicts below.
-->
{#if enableConflicts && conflictLoading}
<span class="conflict-checking" role="status" aria-live="polite">
<IconLoader size={12} />
<span>{$t('apps.new.imageConflictChecking')}</span>
</span>
{/if}
</label>
{#if enableConflicts && conflicts.length > 0}
<div class="conflict-panel" role="status" aria-live="polite">
<div class="conflict-panel-head">
<span class="conflict-tag">{$t('apps.new.imageConflictTag')}</span>
</div>
<p class="conflict-heading">
{$t('apps.new.imageConflictHeading', { count: String(conflicts.length) })}
<code class="conflict-ref mono">{form.ref.trim()}</code>
</p>
<ul class="conflict-list">
{#each conflicts as conflict (conflict.id)}
<li class="conflict-row">
<div class="conflict-row-text">
<span class="conflict-name">{conflict.name}</span>
<span class="conflict-image mono">{conflict.image}</span>
</div>
<a
href={`/apps/${conflict.id}`}
class="editor-chip conflict-open"
title={$t('apps.new.imageConflictOpenBtn')}
>
{$t('apps.new.imageConflictOpenBtn')}
</a>
</li>
{/each}
</ul>
<p class="conflict-foot">{$t('apps.new.imageConflictAcknowledgeNote')}</p>
</div>
{/if}
<div class="row three">
<label class="sub" for="app-image-port">
<span class="sub-label">{$t('apps.new.imagePort')}</span>
<input
id="app-image-port"
type="number"
min="0"
max="65535"
class="input"
bind:value={form.port}
oninput={() => (portTouched = true)}
/>
</label>
<label class="sub" for="app-image-healthcheck">
<span class="sub-label">{$t('apps.new.imageHealthcheck')}</span>
<input
id="app-image-healthcheck"
type="text"
class="input mono"
bind:value={form.healthcheck}
oninput={() => (healthcheckTouched = true)}
placeholder="/healthz"
autocomplete="off"
spellcheck="false"
/>
</label>
<label class="sub" for="app-image-default-tag">
<span class="sub-label">{$t('apps.new.imageDefaultTag')}</span>
<input
id="app-image-default-tag"
type="text"
class="input mono"
bind:value={form.defaultTag}
placeholder="latest"
autocomplete="off"
spellcheck="false"
/>
</label>
</div>
<label class="sub" for="app-image-registry">
<span class="sub-label">{$t('apps.new.imageRegistryLabel')}</span>
{#if registries.length > 0}
<select id="app-image-registry" class="input" bind:value={form.registryName}>
<option value="">{$t('apps.new.imageRegistryPublic')}</option>
{#each registries as r}
<option value={r.name}>{r.name} {r.url}</option>
{/each}
</select>
{:else}
<input
id="app-image-registry"
type="text"
class="input"
bind:value={form.registryName}
placeholder={$t('apps.new.imageRegistryPublic')}
autocomplete="off"
/>
{/if}
<p class="hint">{$t('apps.new.imageRegistryHint')}</p>
</label>
<div class="row three">
<label class="sub" for="app-image-cpu">
<span class="sub-label">{$t('apps.new.imageCpu')}</span>
<input id="app-image-cpu" type="number" min="0" step="0.1" class="input" bind:value={form.cpuLimit} />
<p class="hint">{$t('apps.new.imageCpuHint')}</p>
</label>
<label class="sub" for="app-image-memory">
<span class="sub-label">{$t('apps.new.imageMemory')}</span>
<input id="app-image-memory" type="number" min="0" class="input" bind:value={form.memoryLimit} />
<p class="hint">{$t('apps.new.imageMemoryHint')}</p>
</label>
<label class="sub" for="app-image-max">
<span class="sub-label">{$t('apps.new.imageMax')}</span>
<input id="app-image-max" type="number" min="1" class="input" bind:value={form.maxInstances} />
<p class="hint">{$t('apps.new.imageMaxHint')}</p>
</label>
</div>
<p class="hint image-form-foot">{$t('apps.new.imageFoot')}</p>
</div>
<style>
.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;
}
.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);
}
/* Required-field marker — same danger hue as the page-level `.req`
badge, rendered as a compact asterisk so it doesn't bloat the mono
sub-label. The aria-label carries the meaning for assistive tech. */
.req-star {
margin-left: 0.2rem;
color: var(--color-danger);
font-weight: 700;
}
.editor-title {
margin-left: 0.4rem;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: 0.02em;
}
.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;
}
/* ── "Edit as JSON" escape hatch ─────────────────────────
A quiet secondary text-link, not a prominent chip — it must not
compete with the form title. Mono, muted, underline-on-hover so it
reads as the rarely-used power-user door it is. */
.json-escape {
background: none;
border: 0;
padding: 0;
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
cursor: pointer;
text-decoration: none;
text-underline-offset: 3px;
transition: color 120ms ease;
}
.json-escape:hover {
color: var(--forge-accent);
text-decoration: underline;
}
.json-escape:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* ── Image source form ──────────────────────────────────
Same overall shell as the editor box (border + radius) but the
contents are a stack of labelled form rows. */
.image-form {
display: flex;
flex-direction: column;
gap: 0.9rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--surface-input);
padding: 0.85rem 1rem;
}
.image-form-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--border-primary);
margin-bottom: 0.2rem;
}
.image-form-foot {
margin-top: 0.2rem;
padding-top: 0.55rem;
border-top: 1px dashed var(--border-primary);
}
.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;
}
}
@media (max-width: 600px) {
.row {
grid-template-columns: 1fr;
}
}
/* ── Discovery-style input+button row + status pills ─── */
.input-with-button {
display: flex;
align-items: stretch;
gap: 0.4rem;
}
.input-with-button > .input {
flex: 1;
min-width: 0;
}
.discover-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0 0.7rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
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;
white-space: nowrap;
}
.discover-btn:hover:not(:disabled) {
border-color: var(--color-brand-400);
color: var(--text-primary);
background: var(--surface-card-hover);
}
.discover-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.discover-btn:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.discover-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.28rem 0.55rem;
border-radius: var(--radius-md);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.06em;
line-height: 1;
align-self: flex-start;
}
.discover-pill.inline {
align-self: center;
}
.discover-pill-ok {
background: color-mix(in srgb, var(--color-success) 14%, transparent);
color: var(--color-success-dark);
border: 1px solid color-mix(in srgb, var(--color-success) 40%, transparent);
}
.discover-pill-bad {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: var(--color-danger-dark);
border: 1px solid color-mix(in srgb, var(--color-danger) 40%, transparent);
}
:global([data-theme='dark']) .discover-pill-ok {
color: #86efac;
}
:global([data-theme='dark']) .discover-pill-bad {
color: #fca5a5;
}
/* ── Image-source conflict panel ──────────────────
Sibling of .image-form-foot. Reuses the dashed border + soft card
surface treatment lifted into a loud amber-leaning warning tag. */
.conflict-panel {
display: flex;
flex-direction: column;
gap: 0.55rem;
margin-top: 0.2rem;
padding: 0.75rem 0.9rem;
background: var(--surface-card-hover);
border: 1px dashed var(--color-warning, var(--forge-accent));
border-radius: var(--radius-lg);
}
.conflict-panel-head {
display: flex;
align-items: center;
gap: 0.5rem;
}
.conflict-tag {
display: inline-flex;
padding: 0.18rem 0.5rem;
background: var(--color-warning, var(--forge-accent));
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;
}
/* Quiet inline "checking…" hint shown near the image-ref input while a
conflict lookup is in flight. Deliberately NOT the full panel, so a
no-conflict blur doesn't flash a panel in and out. Self-aligned so it
sits with the inspect status pills without shifting form layout. */
.conflict-checking {
display: inline-flex;
align-items: center;
gap: 0.35rem;
align-self: flex-start;
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--text-tertiary);
}
.conflict-checking :global(svg) {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.conflict-heading {
margin: 0;
font-size: 0.84rem;
color: var(--text-secondary);
line-height: 1.5;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.4rem;
}
.conflict-ref {
padding: 0.1rem 0.35rem;
font-size: 0.78rem;
background: var(--surface-input);
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
color: var(--text-primary);
}
.conflict-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.conflict-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0.65rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
}
.conflict-row-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.conflict-name {
font-weight: 600;
font-size: 0.88rem;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.conflict-image {
font-size: 0.74rem;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.conflict-open {
flex: 0 0 auto;
text-decoration: none;
}
.conflict-foot {
margin: 0;
font-size: 0.74rem;
color: var(--text-tertiary);
line-height: 1.45;
}
</style>
@@ -0,0 +1,122 @@
<script lang="ts">
// Card-grid selector for the workload Source kind, replacing a bare
// <select>. Mirrors the trigger-mode card pattern already used in the
// wizard (role=radio buttons, mono tag + name + optional hint) so the
// two pickers read as one design language. Kinds come from the backend
// plugin registry; descriptions are optional (passed in when i18n keys
// exist) so this component never hardcodes copy.
interface Props {
kinds: string[];
value: string;
onchange: () => void;
descriptions?: Record<string, string>;
ariaLabel?: string;
}
let { kinds, value = $bindable(), onchange, descriptions = {}, ariaLabel }: Props = $props();
function select(kind: string): void {
if (kind === value) return;
value = kind;
onchange();
}
// Radiogroup keyboard semantics: arrows move selection (and focus) to
// the adjacent card, wrapping at the ends.
function onKeydown(e: KeyboardEvent, index: number): void {
const k = e.key;
if (k !== 'ArrowRight' && k !== 'ArrowDown' && k !== 'ArrowLeft' && k !== 'ArrowUp') return;
e.preventDefault();
const dir = k === 'ArrowRight' || k === 'ArrowDown' ? 1 : -1;
const next = (index + dir + kinds.length) % kinds.length;
select(kinds[next]);
const grid = (e.currentTarget as HTMLElement).closest('.kind-grid');
grid?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
}
</script>
<div class="kind-grid" role="radiogroup" aria-label={ariaLabel}>
{#each kinds as kind, i (kind)}
<button
type="button"
role="radio"
aria-checked={value === kind}
tabindex={value === kind ? 0 : -1}
class="kind-card"
class:active={value === kind}
onclick={() => select(kind)}
onkeydown={(e) => onKeydown(e, i)}
>
<span class="kind-tag mono">{kind.toUpperCase()}</span>
<span class="kind-name">{kind}</span>
{#if descriptions[kind]}
<span class="kind-hint">{descriptions[kind]}</span>
{/if}
</button>
{/each}
</div>
<style>
.kind-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: var(--space-3);
}
.kind-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
padding: var(--space-4);
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
cursor: pointer;
text-align: left;
transition:
border-color var(--transition-fast),
background var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
}
.kind-card:hover {
border-color: var(--border-input);
transform: translateY(-1px);
}
.kind-card.active {
border-color: var(--forge-ember);
background: color-mix(in srgb, var(--forge-ember) 6%, var(--surface-card));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--forge-ember) 16%, transparent);
}
.kind-tag {
font-size: 0.625rem;
letter-spacing: 0.08em;
font-weight: var(--weight-semibold);
color: var(--text-tertiary);
padding: 2px var(--space-2);
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
}
.kind-card.active .kind-tag {
color: var(--forge-ember-deep);
border-color: color-mix(in srgb, var(--forge-ember) 40%, transparent);
}
.kind-name {
font-size: var(--text-base);
font-weight: var(--weight-semibold);
color: var(--text-primary);
text-transform: capitalize;
}
.kind-hint {
font-size: var(--text-xs);
color: var(--text-tertiary);
line-height: var(--leading-normal);
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,240 @@
<!--
Static source form — Gitea Pages-alike with optional Deno runtime mode.
Delegates the git-discovery block (provider/repo/branch/token + folder
tree) to StaticDiscoveryWizard, then adds the static-only mode radio and
the render-markdown toggle.
The parent owns the `StaticFormState` (from `$lib/workload/sourceForms`)
and binds it here; serialization to `source_config` is done by the parent
via `staticToConfig` so the shape (incl. preserved storage_* keys) stays
byte-identical. `StaticFormState extends GitSourceState`, so the same
object is bound straight into the wizard's `git` slice.
-->
<script lang="ts">
import type { StaticFormState } from '$lib/workload/sourceForms';
import type { FolderEntry } from '$lib/api';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import StaticDiscoveryWizard from '$lib/components/workload/StaticDiscoveryWizard.svelte';
import { t } from '$lib/i18n';
interface Props {
form: StaticFormState;
/** Flip to the raw-JSON editor (owned by the parent). */
onAdvanced: () => void;
/**
* Transient discovery status — OPTIONAL pass-through to
* StaticDiscoveryWizard. Each defaults to this component's own
* internal `$state`, so a parent that doesn't bind them gets the
* original "resets on remount" behaviour (this is what the detail/
* edit page `apps/[id]` relies on — it binds none of these). The
* create wizard `apps/new` binds them up to the PAGE so the loaded
* tree + detect/test pills + mode override survive the form
* unmounting under the Advanced-JSON / source-kind toggles.
*/
modeUserOverride?: boolean;
treeLoaded?: boolean;
tree?: FolderEntry[];
detectStatus?: 'idle' | 'pending' | 'ok' | 'error';
detectError?: string;
testStatus?: 'idle' | 'pending' | 'ok' | 'error';
testError?: string;
}
let {
form = $bindable(),
onAdvanced,
// Sentinel: once the user manually toggles the static/deno radio,
// auto-detection stops overwriting their choice on subsequent tree loads.
modeUserOverride = $bindable(false),
// Reflects whether the discovery wizard has loaded a folder tree — gates
// the "auto-detected Deno" hint exactly like the legacy
// `staticTree.length > 0` guard did.
treeLoaded = $bindable(false),
tree = $bindable([]),
detectStatus = $bindable('idle'),
detectError = $bindable(''),
testStatus = $bindable('idle'),
testError = $bindable('')
}: Props = $props();
function onModeChange() {
modeUserOverride = true;
}
</script>
<div class="image-form">
<div class="image-form-head">
<span class="editor-title">{$t('apps.new.staticHeader')}</span>
<button type="button" class="json-escape" onclick={onAdvanced} title={$t('apps.new.switchToJsonTitle')}>
{$t('apps.new.advancedJson')}
</button>
</div>
<StaticDiscoveryWizard
bind:git={form}
variant="static"
showFolderTree={true}
bind:folderPath={form.folderPath}
bind:mode={form.mode}
bind:modeUserOverride
bind:treeLoaded
bind:tree
bind:detectStatus
bind:detectError
bind:testStatus
bind:testError
idPrefix="app-static"
/>
<fieldset class="static-mode">
<legend class="sub-label">{$t('apps.new.staticMode')}</legend>
<label class="radio">
<input type="radio" name="static-mode" value="static" bind:group={form.mode} onchange={onModeChange} />
<span>
<strong>static</strong> {$t('apps.new.staticModeStaticDesc')}
</span>
</label>
<label class="radio">
<input type="radio" name="static-mode" value="deno" bind:group={form.mode} onchange={onModeChange} />
<span>
<strong>deno</strong> {$t('apps.new.staticModeDenoDesc')}
</span>
</label>
{#if !modeUserOverride && form.mode === 'deno' && treeLoaded}
<p class="hint static-deno-auto">{$t('apps.new.staticDenoAutoDetected')}</p>
{/if}
</fieldset>
<label class="toggle-row">
<ToggleSwitch bind:checked={form.renderMarkdown} label={$t('apps.new.staticRenderMarkdown')} />
<span>
<strong>{$t('apps.new.staticRenderMarkdown')}</strong> {@html $t('apps.new.staticRenderMarkdownDesc')}
</span>
</label>
<p class="hint image-form-foot">{$t('apps.new.staticFoot')}</p>
</div>
<style>
.hint {
font-size: 0.78rem;
color: var(--text-tertiary);
margin: 0;
line-height: 1.45;
}
.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);
}
.editor-title {
margin-left: 0.4rem;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: 0.02em;
}
/* ── "Edit as JSON" escape hatch ─────────────────────────
Quiet secondary text-link rather than a prominent chip, so it doesn't
compete with the form title. */
.json-escape {
background: none;
border: 0;
padding: 0;
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
cursor: pointer;
text-decoration: none;
text-underline-offset: 3px;
transition: color 120ms ease;
}
.json-escape:hover {
color: var(--forge-accent);
text-decoration: underline;
}
.json-escape:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* ── Image source form shell (shared visual vocabulary) ── */
.image-form {
display: flex;
flex-direction: column;
gap: 0.9rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--surface-input);
padding: 0.85rem 1rem;
}
.image-form-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--border-primary);
margin-bottom: 0.2rem;
}
.image-form-foot {
margin-top: 0.2rem;
padding-top: 0.55rem;
border-top: 1px dashed var(--border-primary);
}
/* ── Static source extras ────────────────────────────── */
.static-mode {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin: 0;
padding: 0.7rem 0.9rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
background: var(--surface-card);
}
.static-mode legend {
padding: 0 0.3rem;
}
.radio {
display: flex;
align-items: flex-start;
gap: 0.55rem;
padding: 0.35rem 0;
font-size: 0.88rem;
color: var(--text-secondary);
cursor: pointer;
}
.radio input {
margin-top: 0.18rem;
accent-color: var(--color-brand-500);
}
.radio strong,
.toggle-row strong {
color: var(--text-primary);
}
.toggle-row {
display: flex;
align-items: flex-start;
gap: 0.55rem;
padding: 0.35rem 0;
font-size: 0.88rem;
color: var(--text-secondary);
cursor: pointer;
}
.toggle-row :global(.toggle-switch) {
margin-top: 0.1rem;
}
.static-deno-auto {
margin-top: 0.35rem;
padding-top: 0.35rem;
border-top: 1px dashed var(--border-primary);
color: var(--color-success-dark);
}
:global([data-theme='dark']) .static-deno-auto {
color: #86efac;
}
</style>