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:
@@ -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>
|
||||
Reference in New Issue
Block a user