39e1e36510
Build / build (push) Successful in 10m42s
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.
860 lines
24 KiB
Svelte
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>
|