feat(triggers): first-class triggers + bindings with fan-out webhook
Build / build (push) Successful in 10m39s
Build / build (push) Successful in 10m39s
Promote triggers from embedded workload fields to standalone records
joined to workloads via workload_trigger_bindings. One trigger (webhook,
registry watcher, git push, manual) now fans out to many workloads with
per-binding config overrides (top-level JSON merge, binding wins).
Backend
- new triggers + workload_trigger_bindings tables with ON DELETE CASCADE
- boot-time backfill of embedded trigger config inside per-workload tx
- store.ErrUnique sentinel translates SQLite UNIQUE at store boundary
- /api/triggers CRUD + /api/triggers/{id}/{webhook,bindings}
- /api/bindings/{id} update/delete; /api/workloads/{id}/triggers list+bind
- bindTriggerToWorkload accepts trigger_id or inline {kind,name,config}
- inline-create uses CreateTriggerWithBindingTx (no orphan triggers)
- validateBindingConfig enforces 8 KiB cap + plugin Validate on merged
- ListTriggersWithBindingCount + ListBindings*WithNames remove N+1
- POST /api/webhook/triggers/{secret} resolves trigger then fans out
- bounded worker pool (4) per request; per-binding error isolation
- outcome accounting: deployed / skipped / no-match / errored
- legacy /api/webhook/workloads/{secret} route removed (clean break;
backfill keeps secrets resolvable at the new /triggers/{secret} path)
- reconciler gate dropped from (Source && Trigger) to Source only
- MergeJSONConfig returns freshly allocated slices (no fan-out aliasing)
- WithEffectiveTrigger lets existing Trigger.Match contract stay unchanged
Frontend
- /triggers list, new wizard, [id] detail (bindings, webhook rotate)
- workload create wizard: NEW / PICK / SKIP trigger modes
- workload detail: bindings panel + Add-trigger modal (inline / pick)
- per-binding override editor with merged-preview + 8 KiB guard
- "OVERRIDES n FIELDS" row badge when binding_config is non-empty
- shared TriggerKindForm component (registry / git / manual + JSON)
- 3 raw <input type=checkbox> replaced with <ToggleSwitch>
- full EN + RU i18n: redeployTriggers.*, apps.detail.bindings.*,
apps.new.triggers.*, nav.triggers; event-triggers nav disambiguated
Doc
- WORKLOAD_REFACTOR_TODO: trigger-split marked DONE; next focus is
the static-source inline port + hard legacy cutover (Priority 1)
This commit is contained in:
+1318
-101
File diff suppressed because it is too large
Load Diff
@@ -2,19 +2,43 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { HookKinds, PluginWorkloadInput } from '$lib/types';
|
||||
import type { RedeployTrigger } from '$lib/api';
|
||||
import * as api from '$lib/api';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import TriggerKindForm, {
|
||||
createTriggerKindFormState,
|
||||
isTriggerFormValid,
|
||||
buildTriggerInput
|
||||
} from '$lib/components/TriggerKindForm.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
// `triggers` is no longer hardcoded into the workload row — the
|
||||
// kind list is kept on `kinds` only for reference but the wizard
|
||||
// now composes a standalone Trigger record (or picks one) and
|
||||
// binds it after the workload is created.
|
||||
let kinds = $state<HookKinds>({ sources: [], triggers: [] });
|
||||
let name = $state('');
|
||||
let sourceKind = $state('image');
|
||||
let triggerKind = $state('manual');
|
||||
let sourceConfig = $state('{}');
|
||||
let triggerConfig = $state('{}');
|
||||
let publicSubdomain = $state('');
|
||||
let publicDomain = $state('');
|
||||
let publicPort = $state(0);
|
||||
|
||||
// Trigger UX modes — three branches:
|
||||
// inline → create a new trigger inline; bind after workload create.
|
||||
// pick → select an existing trigger; bind after workload create.
|
||||
// skip → create the workload without any binding.
|
||||
type TriggerMode = 'inline' | 'pick' | 'skip';
|
||||
let triggerMode = $state<TriggerMode>('inline');
|
||||
let triggerForm = $state(createTriggerKindFormState({ kind: 'registry' }));
|
||||
|
||||
// Existing-trigger picker. `existingTriggers` is loaded lazily on
|
||||
// mount; if the request fails the operator can still create one
|
||||
// inline or skip altogether — we don't block the wizard.
|
||||
let existingTriggers = $state<RedeployTrigger[]>([]);
|
||||
let pickedTriggerId = $state('');
|
||||
|
||||
// Kind-aware compose editor — the raw-JSON textarea forces users to
|
||||
// hand-escape YAML inside a JSON string, which is unusable. When the
|
||||
// source plugin is "compose" we surface a dedicated YAML textarea and
|
||||
@@ -79,7 +103,7 @@
|
||||
schemaCache.set(kind, text);
|
||||
return text;
|
||||
} catch {
|
||||
return sourceConfigSample(kind) || triggerConfigSample(kind) || '{}';
|
||||
return sourceConfigSample(kind) || '{}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,11 +298,7 @@
|
||||
if (kinds.sources.length > 0 && !kinds.sources.includes(sourceKind)) {
|
||||
sourceKind = kinds.sources[0];
|
||||
}
|
||||
if (kinds.triggers.length > 0 && !kinds.triggers.includes(triggerKind)) {
|
||||
triggerKind = kinds.triggers[0];
|
||||
}
|
||||
sourceConfig = await fetchSampleJSON(sourceKind);
|
||||
triggerConfig = await fetchSampleJSON(triggerKind);
|
||||
if (sourceKind === 'compose') {
|
||||
seedComposeFromJSON(sourceConfig);
|
||||
}
|
||||
@@ -296,6 +316,14 @@
|
||||
} catch {
|
||||
registries = [];
|
||||
}
|
||||
// Best-effort fetch of existing triggers — feeds the
|
||||
// "Pick existing" mode. Failure leaves the picker empty
|
||||
// and the operator can still create one inline.
|
||||
try {
|
||||
existingTriggers = await api.listTriggers();
|
||||
} catch {
|
||||
existingTriggers = [];
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load plugin kinds';
|
||||
} finally {
|
||||
@@ -350,26 +378,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function triggerConfigSample(kind: string): string {
|
||||
switch (kind) {
|
||||
case 'registry':
|
||||
return JSON.stringify(
|
||||
{ image: 'registry.example.com/owner/app', tag_pattern: 'v*' },
|
||||
null,
|
||||
2
|
||||
);
|
||||
case 'git':
|
||||
return JSON.stringify(
|
||||
{ repo: 'owner/repo', mode: 'push', branch: 'main', tag_pattern: '' },
|
||||
null,
|
||||
2
|
||||
);
|
||||
case 'manual':
|
||||
default:
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
|
||||
async function onSourceChange() {
|
||||
sourceConfig = await fetchSampleJSON(sourceKind);
|
||||
// Switching INTO compose / image seeds the form fields from
|
||||
@@ -389,9 +397,6 @@
|
||||
advancedJson = false;
|
||||
}
|
||||
}
|
||||
async function onTriggerChange() {
|
||||
triggerConfig = await fetchSampleJSON(triggerKind);
|
||||
}
|
||||
|
||||
// Toggle between the kind-aware form and the raw JSON editor.
|
||||
// Direction matters: going to Advanced JSON commits the form fields
|
||||
@@ -423,12 +428,17 @@
|
||||
}
|
||||
}
|
||||
const sourceValid = $derived(jsonOk(sourceConfig));
|
||||
const triggerValid = $derived(jsonOk(triggerConfig));
|
||||
|
||||
const sourceLines = $derived(sourceConfig.split('\n').length);
|
||||
const sourceBytes = $derived(new Blob([sourceConfig]).size);
|
||||
const triggerLines = $derived(triggerConfig.split('\n').length);
|
||||
const triggerBytes = $derived(new Blob([triggerConfig]).size);
|
||||
|
||||
// Trigger-step validity. Inline mode requires a complete kind+name+config;
|
||||
// pick mode requires a chosen trigger; skip mode is always valid.
|
||||
const triggerStepValid = $derived.by(() => {
|
||||
if (triggerMode === 'skip') return true;
|
||||
if (triggerMode === 'pick') return !!pickedTriggerId;
|
||||
return isTriggerFormValid(triggerForm);
|
||||
});
|
||||
|
||||
async function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
@@ -436,7 +446,6 @@
|
||||
submitting = true;
|
||||
try {
|
||||
let parsedSource: unknown;
|
||||
let parsedTrigger: unknown;
|
||||
if (useComposeForm) {
|
||||
// Form fields are typed primitives — no parse step
|
||||
// needed. compose_yaml passes through verbatim; the
|
||||
@@ -459,18 +468,17 @@
|
||||
throw new Error('source_config is not valid JSON');
|
||||
}
|
||||
}
|
||||
try {
|
||||
parsedTrigger = JSON.parse(triggerConfig);
|
||||
} catch {
|
||||
throw new Error('trigger_config is not valid JSON');
|
||||
}
|
||||
|
||||
// Triggers no longer ride on the workload row; the backend
|
||||
// still accepts the legacy fields but the new code path
|
||||
// passes a manual placeholder + empty config and binds a
|
||||
// real Trigger record after creation.
|
||||
const body: PluginWorkloadInput = {
|
||||
name: name.trim(),
|
||||
source_kind: sourceKind,
|
||||
source_config: parsedSource,
|
||||
trigger_kind: triggerKind,
|
||||
trigger_config: parsedTrigger
|
||||
trigger_kind: '',
|
||||
trigger_config: {}
|
||||
};
|
||||
if (publicSubdomain || publicDomain || publicPort > 0) {
|
||||
body.public_faces = [
|
||||
@@ -486,6 +494,35 @@
|
||||
}
|
||||
|
||||
const created = await api.createPluginWorkload(body);
|
||||
|
||||
// Bind a trigger to the freshly-created workload. Keep going
|
||||
// to the detail page even on bind failure — the operator can
|
||||
// retry from the workload's Triggers panel without losing
|
||||
// their work.
|
||||
if (triggerMode === 'inline' || triggerMode === 'pick') {
|
||||
try {
|
||||
if (triggerMode === 'inline') {
|
||||
const inline = buildTriggerInput(triggerForm);
|
||||
await api.bindTriggerToWorkload(created.id, { inline });
|
||||
} else if (pickedTriggerId) {
|
||||
await api.bindTriggerToWorkload(created.id, {
|
||||
trigger_id: pickedTriggerId
|
||||
});
|
||||
}
|
||||
} catch (be) {
|
||||
const msg = be instanceof Error ? be.message : 'unknown';
|
||||
// Surface the bind failure to the user, then still
|
||||
// route to the detail page where they can retry.
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
`tinyforge.bindError.${created.id}`,
|
||||
$t('apps.new.triggers.bindError', { error: msg })
|
||||
);
|
||||
} catch {
|
||||
// session storage may be disabled — ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
goto(`/apps/${created.id}`);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Create failed';
|
||||
@@ -502,8 +539,7 @@
|
||||
<div class="forge">
|
||||
{#snippet newLede()}
|
||||
Create a plugin-native workload. <em>Source</em> = how it deploys (image, compose, static).
|
||||
<em>Trigger</em> = when it redeploys (registry push, git push, manual). Both axes are
|
||||
independently extensible.
|
||||
Pick or create a <em>trigger</em> below — when one fires, the source plugin redeploys.
|
||||
{/snippet}
|
||||
|
||||
<ForgeHero
|
||||
@@ -553,39 +589,25 @@
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<span class="num">02</span>
|
||||
<span class="lbl">Plugins</span>
|
||||
<span class="opt">SOURCE × TRIGGER</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub" for="app-source">
|
||||
<span class="sub-label">Source</span>
|
||||
<select
|
||||
id="app-source"
|
||||
class="input"
|
||||
bind:value={sourceKind}
|
||||
onchange={onSourceChange}
|
||||
>
|
||||
{#each kinds.sources as k}
|
||||
<option value={k}>{k}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="sub" for="app-trigger">
|
||||
<span class="sub-label">Trigger</span>
|
||||
<select
|
||||
id="app-trigger"
|
||||
class="input"
|
||||
bind:value={triggerKind}
|
||||
onchange={onTriggerChange}
|
||||
>
|
||||
{#each kinds.triggers as k}
|
||||
<option value={k}>{k}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<span class="lbl">Source plugin</span>
|
||||
<span class="opt">REQUIRED</span>
|
||||
</div>
|
||||
<label class="sub" for="app-source">
|
||||
<span class="sub-label">Source</span>
|
||||
<select
|
||||
id="app-source"
|
||||
class="input"
|
||||
bind:value={sourceKind}
|
||||
onchange={onSourceChange}
|
||||
>
|
||||
{#each kinds.sources as k}
|
||||
<option value={k}>{k}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<p class="hint">
|
||||
Both pickers are populated from the running daemon — only plugins compiled in show up.
|
||||
Populated from the running daemon — only plugins compiled in show up. Triggers
|
||||
(registry / git / manual) are configured below as standalone records.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -921,10 +943,10 @@
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<label class="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
<label class="toggle-row">
|
||||
<ToggleSwitch
|
||||
bind:checked={staticRenderMarkdown}
|
||||
label="Render markdown"
|
||||
/>
|
||||
<span>
|
||||
<strong>Render markdown</strong> — auto-render <code>.md</code>
|
||||
@@ -982,45 +1004,108 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<fieldset class="field group">
|
||||
<legend class="field-label as-legend">
|
||||
<span class="num">04</span>
|
||||
<span class="lbl">Trigger config</span>
|
||||
<span class="req">JSON</span>
|
||||
<span class="lbl">{$t('apps.new.triggers.section')}</span>
|
||||
<span class="opt">OPTIONAL</span>
|
||||
</legend>
|
||||
<p class="hint">{$t('apps.new.triggers.sectionSub')}</p>
|
||||
|
||||
<!-- Mode selector — three short cards. The "active" card
|
||||
reveals its sub-form below. Skipping is explicit so
|
||||
users can ship the workload now and wire triggers
|
||||
later from the detail page. -->
|
||||
<div
|
||||
class="trig-mode-row"
|
||||
role="radiogroup"
|
||||
aria-label={$t('apps.new.triggers.section')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={triggerMode === 'inline'}
|
||||
class="trig-mode-card"
|
||||
class:active={triggerMode === 'inline'}
|
||||
onclick={() => (triggerMode = 'inline')}
|
||||
>
|
||||
<span class="trig-mode-tag mono">NEW</span>
|
||||
<span class="trig-mode-name">{$t('apps.new.triggers.modeInline')}</span>
|
||||
<span class="trig-mode-hint">{$t('apps.new.triggers.modeInlineHint')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={triggerMode === 'pick'}
|
||||
class="trig-mode-card"
|
||||
class:active={triggerMode === 'pick'}
|
||||
onclick={() => (triggerMode = 'pick')}
|
||||
>
|
||||
<span class="trig-mode-tag mono">PICK</span>
|
||||
<span class="trig-mode-name">{$t('apps.new.triggers.modePick')}</span>
|
||||
<span class="trig-mode-hint">{$t('apps.new.triggers.modePickHint')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={triggerMode === 'skip'}
|
||||
class="trig-mode-card"
|
||||
class:active={triggerMode === 'skip'}
|
||||
onclick={() => (triggerMode = 'skip')}
|
||||
>
|
||||
<span class="trig-mode-tag mono">SKIP</span>
|
||||
<span class="trig-mode-name">{$t('apps.new.triggers.modeSkip')}</span>
|
||||
<span class="trig-mode-hint">{$t('apps.new.triggers.modeSkipHint')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="editor">
|
||||
<div class="editor-head">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="editor-title">trigger_config.json · {triggerKind}</span>
|
||||
<span class="spacer"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-chip"
|
||||
onclick={() => (triggerConfig = triggerConfigSample(triggerKind))}
|
||||
>
|
||||
Reset sample
|
||||
</button>
|
||||
|
||||
{#if triggerMode === 'inline'}
|
||||
<div class="trig-sub">
|
||||
<TriggerKindForm
|
||||
bind:state={triggerForm}
|
||||
idPrefix="app-trig"
|
||||
showName={true}
|
||||
showWebhook={true}
|
||||
showKindPicker={true}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
id="app-trigger-config"
|
||||
bind:value={triggerConfig}
|
||||
rows="7"
|
||||
spellcheck="false"
|
||||
class="code-area"
|
||||
aria-label="Trigger plugin configuration (JSON)"
|
||||
></textarea>
|
||||
<div class="editor-foot">
|
||||
<span class="foot-status" class:bad={!triggerValid}>
|
||||
<span class="foot-dot" aria-hidden="true"></span>
|
||||
{triggerValid ? 'JSON OK' : 'JSON INVALID'}
|
||||
</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{triggerLines} lines</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{triggerBytes} B</span>
|
||||
{:else if triggerMode === 'pick'}
|
||||
<div class="trig-sub">
|
||||
{#if existingTriggers.length === 0}
|
||||
<div class="note muted-note">
|
||||
<span class="note-tag">∅</span>
|
||||
<p>{$t('apps.new.triggers.pickEmpty')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<label class="sub" for="app-trig-pick">
|
||||
<span class="sub-label">{$t('apps.new.triggers.pickLabel')}</span>
|
||||
<select
|
||||
id="app-trig-pick"
|
||||
class="input"
|
||||
bind:value={pickedTriggerId}
|
||||
>
|
||||
<option value="">{$t('apps.new.triggers.pickPlaceholder')}</option>
|
||||
{#each existingTriggers as tr (tr.id)}
|
||||
<option value={tr.id}>
|
||||
{tr.name} · {tr.kind}{tr.webhook_enabled
|
||||
? ` · ${$t('apps.new.triggers.pickWebhookOn')}`
|
||||
: ''}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="hint">{$t('apps.new.triggers.pickHint')}</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="trig-sub">
|
||||
<div class="note muted-note">
|
||||
<span class="note-tag">SKIP</span>
|
||||
<p>{$t('apps.new.triggers.skippedNote')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="field group">
|
||||
<legend class="field-label as-legend">
|
||||
@@ -1074,7 +1159,7 @@
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="submit"
|
||||
disabled={submitting || !name.trim() || (!useComposeForm && !useImageForm && !useStaticForm && !sourceValid) || (useImageForm && !imageRef.trim()) || (useStaticForm && (!staticRepoOwner.trim() || !staticRepoName.trim())) || !triggerValid}
|
||||
disabled={submitting || !name.trim() || (!useComposeForm && !useImageForm && !useStaticForm && !sourceValid) || (useImageForm && !imageRef.trim()) || (useStaticForm && (!staticRepoOwner.trim() || !staticRepoName.trim())) || !triggerStepValid}
|
||||
>
|
||||
<span>{submitting ? 'Forging…' : 'Forge app'}</span>
|
||||
<span class="arrow" aria-hidden="true">→</span>
|
||||
@@ -1553,16 +1638,15 @@
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.radio input,
|
||||
.checkbox-row input {
|
||||
.radio input {
|
||||
margin-top: 0.18rem;
|
||||
accent-color: var(--color-brand-500);
|
||||
}
|
||||
.radio strong,
|
||||
.checkbox-row strong {
|
||||
.toggle-row strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.checkbox-row {
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.55rem;
|
||||
@@ -1571,4 +1655,108 @@
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle-row :global(.toggle-switch) {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* ── Trigger mode picker ──────────────────────────
|
||||
Three short cards (NEW / PICK / SKIP). The active
|
||||
card lights up its tag in brand colour and reveals
|
||||
the matching sub-form below in a soft inset panel. */
|
||||
.trig-mode-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.trig-mode-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.trig-mode-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
text-align: left;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms ease, background 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
.trig-mode-card:hover {
|
||||
border-color: color-mix(in srgb, var(--forge-accent) 40%, var(--border-primary));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.trig-mode-card.active {
|
||||
border-color: var(--forge-accent);
|
||||
background: var(--forge-accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--forge-accent);
|
||||
}
|
||||
.trig-mode-tag {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
padding: 0.18rem 0.5rem;
|
||||
background: var(--text-primary);
|
||||
color: var(--surface-card);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
}
|
||||
.trig-mode-card.active .trig-mode-tag {
|
||||
background: var(--forge-accent);
|
||||
}
|
||||
.trig-mode-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.trig-mode-hint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.trig-sub {
|
||||
margin-top: 0.2rem;
|
||||
padding: 0.95rem 1rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-input);
|
||||
}
|
||||
.note {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: var(--surface-card-hover);
|
||||
border: 1px dashed var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.muted-note {
|
||||
background: transparent;
|
||||
}
|
||||
.note-tag {
|
||||
padding: 0.16rem 0.4rem;
|
||||
background: var(--text-primary);
|
||||
color: var(--surface-card);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.56rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
border-radius: var(--radius-sm);
|
||||
flex: 0 0 auto;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.note p {
|
||||
margin: 0;
|
||||
font-size: 0.83rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user