Files
tiny-forge/web/src/routes/triggers/new/+page.svelte
T
alexei.dolgolyov 39e1e36510
Build / build (push) Successful in 10m42s
feat(triggers): add schedule trigger kind + internal scheduler
Fourth trigger kind alongside registry/git/manual. Recurring time-interval
fires driven by a new internal/scheduler tick loop (default 30s, clamped
to 5m). Goes through the same webhook.Handler.FanOutForTrigger seam as
inbound HTTP webhooks, so per-binding concurrency, outcome accounting,
and config-merge semantics are identical.

Schema: triggers.last_fired_at TEXT column (additive ALTER for existing
DBs). Scheduler persists last_fired_at BEFORE dispatch so a panicking
Match cannot wedge a tight loop; failed deploys wait one full interval
before retry — correct trade-off for a periodic refresh trigger.

Frontend: TriggerKindForm + /triggers/new + /triggers/[id] gain the
schedule kind (4-col card grid, preset chips Hourly/Daily/Weekly,
custom interval input matched to Go time.ParseDuration syntax, optional
pinned reference). /triggers/[id] surfaces "last fired" on schedule rows.
EN+RU i18n in parity.

Review fixes from go-reviewer / security-reviewer / typescript-reviewer:
- Scheduler Start/Stop wrapped in sync.Once (no goroutine leak / double-
  cancel panic on shutdown re-entry).
- shouldFire rejects sub-MinInterval as defense-in-depth against
  hand-inserted rows that bypassed Validate.
- fire() asserts trigger Kind=="schedule" before dispatching.
- Aligned isValidInterval regex across all three frontend sites; reject
  the unsupported "d" unit (Go time.ParseDuration doesn't accept it).
- formatLastFired falls back to lastFiredNever on malformed timestamps
  rather than leaking raw bytes into the UI.
- main.go scheduler closure logs per-fire deployed/errored counts.
2026-05-16 11:24:05 +03:00

860 lines
24 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import * as api from '$lib/api';
import type { TriggerInput } from '$lib/api';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import { t } from '$lib/i18n';
// Four kinds have hand-rolled forms today; anything else falls
// back to the JSON editor. KNOWN_KINDS gates the structured form
// switch — see formNote() for the manual/unknown explainer text.
const KNOWN_KINDS = ['registry', 'git', 'manual', 'schedule'] as const;
type KnownKind = (typeof KNOWN_KINDS)[number];
const ALL_PICKABLE: ReadonlyArray<KnownKind> = KNOWN_KINDS;
// Suggested intervals for schedule triggers. Operators can always
// type a custom Go duration ("90m", "1h30m", "168h") into the input.
const SCHEDULE_PRESETS = [
{ key: 'hourly', value: '1h' },
{ key: 'daily', value: '24h' },
{ key: 'weekly', value: '168h' }
] as const;
function isValidInterval(s: string): boolean {
const trimmed = s.trim();
if (!trimmed) return false;
const single = trimmed.match(/^(\d+)\s*(s|m|h)$/i);
if (single) {
const n = parseInt(single[1], 10);
const unit = single[2].toLowerCase();
if (!Number.isFinite(n) || n <= 0) return false;
if (unit === 's' && n < 60) return false;
return true;
}
return /^([0-9]+(\.[0-9]+)?(h|m|s))+$/i.test(trimmed);
}
// Kind is always one of KNOWN_KINDS — the picker only emits those.
// Keeping the literal union (no `| string`) preserves discriminated
// narrowing inside buildConfig/canSubmit.
let kind = $state<KnownKind>('registry');
let name = $state('');
let webhookEnabled = $state(false);
let webhookRequireSig = $state(true);
let useAdvancedJson = $state(false);
let submitting = $state(false);
let error = $state('');
// Per-kind structured fields. They mirror the Go config shapes
// documented in the parent task description — see TriggerInput
// in $lib/api. Keeping them as separate $state slots lets the
// kind switch persist values across kind flips (operator typo
// recovery) without juggling a discriminated union.
let regImage = $state('');
let regTagPattern = $state('*');
let gitRepo = $state('');
let gitMode = $state<'push' | 'tag'>('push');
let gitBranch = $state('main');
let gitTagPattern = $state('v*');
let schInterval = $state('24h');
let schReference = $state('');
// Advanced JSON editor — primed with the sample shape for the
// current kind on first toggle so the operator has something to
// edit. We only auto-prime when the field is blank to avoid
// nuking deliberate edits on re-toggle.
let jsonText = $state('');
let jsonLoading = $state(false);
const jsonValid = $derived.by(() => {
if (!useAdvancedJson) return true;
if (!jsonText.trim()) return true; // blank treated as empty object server-side
try {
const parsed = JSON.parse(jsonText);
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
} catch {
return false;
}
});
function buildConfig(): unknown {
if (useAdvancedJson) {
if (!jsonText.trim()) return {};
return JSON.parse(jsonText);
}
switch (kind) {
case 'registry':
return {
image: regImage.trim(),
tag_pattern: regTagPattern.trim() || '*'
};
case 'git':
return gitMode === 'push'
? { repo: gitRepo.trim(), mode: 'push', branch: gitBranch.trim() || 'main' }
: { repo: gitRepo.trim(), mode: 'tag', tag_pattern: gitTagPattern.trim() || '*' };
case 'manual':
return {};
case 'schedule': {
const ref = schReference.trim();
return ref
? { interval: schInterval.trim(), reference: ref }
: { interval: schInterval.trim() };
}
default:
// Unknown kind reached the structured path — fall back
// to an empty object; advanced JSON would normally be
// on by this point.
return {};
}
}
function canSubmit(): boolean {
if (submitting) return false;
if (!name.trim()) return false;
if (useAdvancedJson) return jsonValid;
switch (kind) {
case 'registry':
return !!regImage.trim();
case 'git':
return !!gitRepo.trim();
case 'manual':
return true;
case 'schedule':
return isValidInterval(schInterval);
default:
return false; // unknown kinds force advanced JSON
}
}
async function loadSampleIntoJson(): Promise<void> {
jsonLoading = true;
try {
const schema = await api.getHookKindSchema(kind);
jsonText = JSON.stringify(schema.sample ?? {}, null, 2);
} catch {
// Best-effort prime — operator can paste their own.
jsonText = '{\n \n}';
} finally {
jsonLoading = false;
}
}
function toggleAdvanced(): void {
useAdvancedJson = !useAdvancedJson;
if (useAdvancedJson && !jsonText.trim()) {
// Seed with current structured values (or schema sample
// as fallback) so the operator can refine instead of
// retyping.
try {
jsonText = JSON.stringify(buildConfig(), null, 2);
} catch {
void loadSampleIntoJson();
}
}
}
async function submit(e: Event): Promise<void> {
e.preventDefault();
if (!canSubmit()) return;
error = '';
submitting = true;
try {
const body: TriggerInput = {
kind,
name: name.trim(),
config: buildConfig(),
webhook_enabled: webhookEnabled,
webhook_require_signature: webhookRequireSig
};
const created = await api.createTrigger(body);
goto(`/triggers/${created.id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Create failed';
} finally {
submitting = false;
}
}
function kindHint(k: string): string {
const key = `redeployTriggers.kindHint.${k}`;
const v = $t(key);
return v === key ? '' : v;
}
</script>
<svelte:head>
<title>{$t('redeployTriggers.titleNew')} · Tinyforge</title>
</svelte:head>
<div class="forge">
{#snippet lede()}
{$t('redeployTriggers.ledeNew')}
{/snippet}
<ForgeHero
backHref="/triggers"
backLabel={$t('redeployTriggers.toolbar.backToList')}
eyebrowSuffix={$t('redeployTriggers.toolbar.newButton').toUpperCase()}
title={$t('redeployTriggers.titleNew')}
size="lg"
lede_html={lede}
/>
<form onsubmit={submit} class="form" novalidate aria-busy={submitting}>
{#if error}
<div class="alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
<!-- Step 01 · Kind picker. Renders as a grid of square cards
so the kind is the first visual commitment of the wizard. -->
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num" aria-hidden="true">01</span>
<span class="lbl">{$t('redeployTriggers.form.kindLabel')}</span>
<span class="req">{$t('redeployTriggers.form.required')}</span>
</legend>
<p class="hint">{$t('redeployTriggers.form.kindHint')}</p>
<div class="kind-grid" role="radiogroup" aria-label={$t('redeployTriggers.form.kindLabel')}>
{#each ALL_PICKABLE as k}
<button
type="button"
role="radio"
aria-checked={kind === k}
class="kind-card"
class:active={kind === k}
onclick={() => (kind = k)}
>
<span class="kind-card-tag mono">{$t(`redeployTriggers.kindShort.${k}`)}</span>
<span class="kind-card-name">{$t(`redeployTriggers.kind.${k}`)}</span>
<span class="kind-card-hint">{kindHint(k)}</span>
</button>
{/each}
</div>
</fieldset>
<!-- Step 02 · Name. -->
<div class="field">
<label for="trig-name" class="field-label">
<span class="num" aria-hidden="true">02</span>
<span class="lbl">{$t('redeployTriggers.form.name')}</span>
<span class="req">{$t('redeployTriggers.form.required')}</span>
</label>
<input
id="trig-name"
type="text"
bind:value={name}
class="input"
placeholder={$t('redeployTriggers.form.namePlaceholder')}
autocomplete="off"
required
/>
</div>
<!-- Step 03 · Config — kind-aware switch. -->
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num" aria-hidden="true">03</span>
<span class="lbl">{$t('redeployTriggers.form.configLabel')}</span>
<span class="opt">{$t(`redeployTriggers.kindShort.${kind}`)}</span>
<button
type="button"
class="adv-toggle"
class:on={useAdvancedJson}
onclick={toggleAdvanced}
>
{$t('redeployTriggers.form.advancedToggle')}
</button>
</legend>
{#if useAdvancedJson}
<p class="hint">{$t('redeployTriggers.form.advancedHint')}</p>
<label class="sub" for="trig-json">
<span class="sub-label">{$t('redeployTriggers.form.configJson')}</span>
<textarea
id="trig-json"
class="input mono code"
class:bad={!jsonValid}
bind:value={jsonText}
rows="8"
spellcheck="false"
placeholder={'{ }'}
aria-invalid={!jsonValid}
aria-describedby={!jsonValid ? 'trig-json-err' : 'trig-json-hint'}
></textarea>
<span id="trig-json-hint" class="hint">
{$t('redeployTriggers.form.configJsonHint')}
{#if jsonLoading} <em>· loading sample…</em>{/if}
</span>
{#if !jsonValid}
<span id="trig-json-err" class="hint danger" role="alert">
{$t('redeployTriggers.form.invalidJson')}
</span>
{/if}
</label>
{:else if kind === 'registry'}
<label class="sub" for="trig-image">
<span class="sub-label">{$t('redeployTriggers.form.image')}</span>
<input
id="trig-image"
type="text"
class="input mono"
bind:value={regImage}
placeholder={$t('redeployTriggers.form.imagePlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<span class="hint">{$t('redeployTriggers.form.imageHint')}</span>
</label>
<label class="sub" for="trig-tag">
<span class="sub-label">{$t('redeployTriggers.form.tagPattern')}</span>
<input
id="trig-tag"
type="text"
class="input mono"
bind:value={regTagPattern}
placeholder={$t('redeployTriggers.form.tagPatternPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.tagPatternHint')}</span>
</label>
{:else if kind === 'git'}
<label class="sub" for="trig-repo">
<span class="sub-label">{$t('redeployTriggers.form.repo')}</span>
<input
id="trig-repo"
type="text"
class="input mono"
bind:value={gitRepo}
placeholder={$t('redeployTriggers.form.repoPlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<span class="hint">{$t('redeployTriggers.form.repoHint')}</span>
</label>
<div class="sub">
<span class="sub-label">{$t('redeployTriggers.form.mode')}</span>
<div class="mode-row" role="radiogroup" aria-label={$t('redeployTriggers.form.mode')}>
<button
type="button"
role="radio"
aria-checked={gitMode === 'push'}
class="mode-chip"
class:active={gitMode === 'push'}
onclick={() => (gitMode = 'push')}
>
{$t('redeployTriggers.form.modePush')}
</button>
<button
type="button"
role="radio"
aria-checked={gitMode === 'tag'}
class="mode-chip"
class:active={gitMode === 'tag'}
onclick={() => (gitMode = 'tag')}
>
{$t('redeployTriggers.form.modeTag')}
</button>
</div>
</div>
{#if gitMode === 'push'}
<label class="sub" for="trig-branch">
<span class="sub-label">{$t('redeployTriggers.form.branch')}</span>
<input
id="trig-branch"
type="text"
class="input mono"
bind:value={gitBranch}
placeholder={$t('redeployTriggers.form.branchPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.branchHint')}</span>
</label>
{:else}
<label class="sub" for="trig-gtag">
<span class="sub-label">{$t('redeployTriggers.form.tagPattern')}</span>
<input
id="trig-gtag"
type="text"
class="input mono"
bind:value={gitTagPattern}
placeholder={$t('redeployTriggers.form.tagPatternPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.tagPatternHint')}</span>
</label>
{/if}
{:else if kind === 'manual'}
<div class="note">
<span class="note-tag">MANUAL</span>
<p>{$t('redeployTriggers.form.manualNote')}</p>
</div>
{:else if kind === 'schedule'}
<div class="note">
<span class="note-tag">CRN</span>
<p>{$t('redeployTriggers.form.scheduleNote')}</p>
</div>
<div class="sub">
<span class="sub-label">{$t('redeployTriggers.form.intervalPresets')}</span>
<div
class="mode-row"
role="radiogroup"
aria-label={$t('redeployTriggers.form.intervalPresets')}
>
{#each SCHEDULE_PRESETS as p (p.key)}
<button
type="button"
role="radio"
aria-checked={schInterval === p.value}
class="mode-chip"
class:active={schInterval === p.value}
onclick={() => (schInterval = p.value)}
>
{$t(`redeployTriggers.form.intervalPreset.${p.key}`)}
</button>
{/each}
</div>
</div>
<label class="sub" for="trig-interval">
<span class="sub-label">{$t('redeployTriggers.form.interval')}</span>
<input
id="trig-interval"
type="text"
class="input mono"
class:bad={!isValidInterval(schInterval)}
bind:value={schInterval}
placeholder="24h"
autocomplete="off"
spellcheck="false"
required
/>
<span class="hint">{$t('redeployTriggers.form.intervalHint')}</span>
</label>
<label class="sub" for="trig-schref">
<span class="sub-label">{$t('redeployTriggers.form.scheduleReference')}</span>
<input
id="trig-schref"
type="text"
class="input mono"
bind:value={schReference}
placeholder={$t('redeployTriggers.form.scheduleReferencePlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.scheduleReferenceHint')}</span>
</label>
{:else}
<div class="note">
<span class="note-tag">?</span>
<p>{$t('redeployTriggers.form.unknownNote')}</p>
</div>
{/if}
</fieldset>
<!-- Step 04 · Webhook ingress. -->
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num" aria-hidden="true">04</span>
<span class="lbl">{$t('redeployTriggers.detail.webhook')}</span>
<span class="opt">OPTIONAL</span>
</legend>
<div class="row-toggle">
<div class="toggle-copy">
<span class="lbl small">{$t('redeployTriggers.form.webhookEnabled')}</span>
<p class="hint">{$t('redeployTriggers.form.webhookEnabledHint')}</p>
</div>
<ToggleSwitch
bind:checked={webhookEnabled}
label={$t('redeployTriggers.form.webhookEnabled')}
/>
</div>
{#if webhookEnabled}
<div class="row-toggle indent">
<div class="toggle-copy">
<span class="lbl small">{$t('redeployTriggers.form.webhookRequireSig')}</span>
<p class="hint">{$t('redeployTriggers.form.webhookRequireSigHint')}</p>
</div>
<ToggleSwitch
bind:checked={webhookRequireSig}
label={$t('redeployTriggers.form.webhookRequireSig')}
/>
</div>
{/if}
</fieldset>
<div class="actions">
<a href="/triggers" class="forge-btn-ghost">{$t('redeployTriggers.form.cancel')}</a>
<button
type="submit"
class="forge-btn"
disabled={!canSubmit()}
aria-busy={submitting}
>
{submitting
? $t('redeployTriggers.form.submitting')
: $t('redeployTriggers.form.submit')}
</button>
</div>
</form>
</div>
<style>
.forge {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 760px;
margin: 0 auto;
}
.form {
display: flex;
flex-direction: column;
gap: 1.5rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-2xl);
padding: 1.75rem;
}
@media (max-width: 600px) {
.form {
padding: 1.1rem;
gap: 1.25rem;
}
}
/* ── Alert ─────────────────────────────────────── */
.alert {
display: flex;
gap: 0.7rem;
align-items: center;
padding: 0.7rem 0.9rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.alert-tag {
font-family: var(--forge-mono);
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 0.16em;
padding: 0.15rem 0.4rem;
background: var(--color-danger);
color: var(--surface-card);
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .alert {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
}
/* ── Field structure ────────────────────────────── */
.field {
display: flex;
flex-direction: column;
gap: 0.55rem;
margin: 0;
padding: 0;
border: 0;
}
.field.group { gap: 0.75rem; }
.field-label {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem;
margin: 0;
}
.field-label.as-legend { float: none; width: 100%; }
.num {
display: inline-flex;
width: 26px; height: 26px;
justify-content: center; align-items: center;
background: var(--text-primary);
color: var(--surface-card);
border-radius: var(--radius-sm);
font-family: var(--forge-mono);
font-size: 0.7rem; font-weight: 700;
flex: 0 0 auto;
}
.lbl {
font-weight: 600;
font-size: 1.1rem;
letter-spacing: -0.01em;
line-height: 1.2;
color: var(--text-primary);
}
.lbl.small { font-size: 0.95rem; }
.req, .opt {
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.req { color: var(--color-danger); }
.opt { color: var(--text-tertiary); }
/* Advanced JSON pill-toggle lives in the same legend row as
the section number. Visually it's a quiet outlined button
that fills in when active. */
.adv-toggle {
margin-left: auto;
padding: 0.25rem 0.6rem;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease, color 150ms ease;
}
.adv-toggle:hover {
border-color: var(--forge-accent);
color: var(--forge-accent);
}
.adv-toggle.on {
background: var(--forge-accent);
border-color: var(--forge-accent);
color: var(--surface-card);
}
/* ── Inputs ─────────────────────────────────────── */
.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(--forge-accent-soft);
}
.input.mono { font-family: var(--forge-mono); font-size: 0.85rem; }
.input.code {
resize: vertical;
min-height: 140px;
line-height: 1.5;
}
.input.bad { border-color: var(--color-danger); }
.input.bad:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 22%, transparent);
}
.sub {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.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);
}
/* ── Hints ──────────────────────────────────────── */
.hint {
font-size: 0.78rem;
color: var(--text-tertiary);
line-height: 1.5;
margin: 0;
}
.hint.danger { color: var(--color-danger); }
.hint em {
font-style: italic;
color: var(--forge-accent);
}
/* ── Note banner (manual/unknown) ─────────────────── */
.note {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.75rem 0.9rem;
background: var(--surface-card-hover);
border: 1px dashed var(--border-primary);
border-radius: var(--radius-lg);
}
.note-tag {
padding: 0.18rem 0.45rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
flex: 0 0 auto;
line-height: 1.4;
}
.note p {
margin: 0;
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.5;
}
/* ── Kind picker grid ─────────────────────────────
Each card has a monospace tag and a soft name. The
active card lights up the tag in brand colour and
adds a subtle inner glow so the choice is obvious. */
.kind-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.6rem;
}
@media (max-width: 900px) {
.kind-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 480px) {
.kind-grid { grid-template-columns: 1fr; }
}
.kind-card {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.85rem 0.9rem;
text-align: left;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease, transform 150ms ease,
box-shadow 150ms ease;
position: relative;
overflow: hidden;
}
.kind-card:hover {
border-color: color-mix(in srgb, var(--forge-accent) 40%, var(--border-primary));
transform: translateY(-1px);
}
.kind-card.active {
border-color: var(--forge-accent);
background: var(--forge-accent-soft);
box-shadow: inset 0 0 0 1px var(--forge-accent);
}
.kind-card-tag {
display: inline-flex;
align-items: center;
align-self: flex-start;
padding: 0.2rem 0.55rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
line-height: 1;
}
.kind-card.active .kind-card-tag {
background: var(--forge-accent);
}
.kind-card-name {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.kind-card-hint {
font-size: 0.72rem;
color: var(--text-tertiary);
line-height: 1.5;
}
/* ── Mode chips (git push vs tag) ─────────────── */
.mode-row {
display: inline-flex;
gap: 0;
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
padding: 2px;
width: fit-content;
}
.mode-chip {
padding: 0.32rem 0.85rem;
background: transparent;
border: 0;
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.mode-chip:hover { color: var(--text-primary); }
.mode-chip.active {
background: var(--text-primary);
color: var(--surface-card);
}
/* ── Toggle row ─────────────────────────────────── */
.row-toggle {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding-top: 0.6rem;
border-top: 1px dashed var(--border-primary);
}
.row-toggle.indent {
border-top: 0;
padding-top: 0.1rem;
padding-left: 1rem;
border-left: 2px solid var(--forge-accent-soft);
margin-left: 0.4rem;
}
.toggle-copy {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* ── Actions ────────────────────────────────────── */
.actions {
display: flex;
justify-content: flex-end;
gap: 0.55rem;
flex-wrap: wrap;
}
@media (max-width: 480px) {
.actions {
flex-direction: column-reverse;
align-items: stretch;
}
.actions :global(.forge-btn),
.actions :global(.forge-btn-ghost) {
justify-content: center;
}
}
</style>