Compare commits

...
Author SHA1 Message Date
总工andmultica-agent 225fcb49ea fix(ui): edited-draft marker uses amber warn per HEL-658 ruling (HEL-657)
Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 08:49:53 +08:00
施工员andmultica-agent 70a860ce2a fix(ui): split running/awaiting-confirm colors and neutral kind tags (HEL-651 A batch)
- project card stage segments: running -> --run, awaiting confirm -> --warn
- running counters/dots and 'testing' states -> --run; warn-named notes -> --warn
- image/video/usage kind tags and plain entry/card icons -> neutral tokens

Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 08:38:11 +08:00
施工员andmultica-agent e42212a16f feat(ui): fusion design tokens and shared component reskin (HEL-651 A batch)
- app.css: HEL-649 §一 tokens (bg/line/accent/status/text/type/space/radius/shadow/motion), legacy names aliased, reduced-motion via prefers-reduced-motion and data-reduce
- shared .a-btn/.status-chip/.a-panel/inputs reskinned; new .a-tag/.a-progress/.a-sprocket/.a-stage/.a-float
- tailwind colors/radii reference the same CSS variables
- ui primitives (StatusChip/ProgressBar/Modal/Button/ConfirmDialog/Skeleton/Switch/Card/Toast) on tokens
- running state -> --run, awaiting confirm -> --warn, --cloud purple removed, old palette literals -> tokens

Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 08:29:37 +08:00
总工 f5f19f0366 merge: HEL-643 import preflight, real test runs and media groups 2026-09-29 19:12:18 +08:00
总工 bfe7b20b73 merge: HEL-641 image deletion and video history click-to-play 2026-09-29 19:12:08 +08:00
总工andmultica-agent 48e16ae443 feat(studio): image result deletion and video history click-to-play (HEL-641)
Co-authored-by: multica-agent <github@multica.ai>
2026-09-29 17:48:00 +08:00
36 changed files with 919 additions and 538 deletions
@@ -52,15 +52,16 @@
{/if}
<style>
/* §四.5 Toast:右下角堆叠,220ms 上移入场;成功绿点 / 失败红点。 */
.toast-host {
position: fixed;
top: 72px;
right: 20px;
right: 24px;
bottom: 24px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(360px, calc(100vw - 32px));
max-width: min(400px, calc(100vw - 32px));
pointer-events: none;
}
.toast {
@@ -69,23 +70,10 @@
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--bg-surface);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
animation: toast-in 0.22s ease-out;
}
.toast.success {
border-color: rgba(34, 197, 94, 0.45);
box-shadow:
0 12px 32px rgba(0, 0, 0, 0.45),
0 0 0 1px rgba(34, 197, 94, 0.12);
}
.toast.error {
border-color: rgba(239, 68, 68, 0.5);
}
.toast.info {
border-color: rgba(59, 130, 246, 0.45);
border-radius: var(--r-lg);
background: var(--float-bg);
box-shadow: var(--shadow-float);
animation: toast-in var(--d-enter) var(--ease-out);
}
.badge {
display: inline-flex;
@@ -101,17 +89,17 @@
background: currentColor;
}
.toast.success .badge {
color: var(--success);
color: var(--ok);
}
.toast.error .badge {
color: var(--error);
color: var(--err);
}
.toast.info .badge {
color: var(--info);
color: var(--text-2);
}
.msg {
flex: 1;
font-size: 13px;
font-size: var(--fs-body);
line-height: 1.4;
color: var(--text-primary);
font-weight: 500;
@@ -127,10 +115,12 @@
padding: 0 2px;
min-width: 28px;
min-height: 28px;
border-radius: 4px;
border-radius: var(--r-sm);
}
.dismiss:hover {
color: var(--text-primary);
@media (hover: hover) and (pointer: fine) {
.dismiss:hover {
color: var(--text-primary);
}
}
.dismiss:focus-visible {
outline: 2px solid var(--accent);
@@ -139,7 +129,7 @@
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(-8px);
transform: translateY(8px);
}
to {
opacity: 1;
@@ -151,9 +141,11 @@
animation: none;
}
}
@media (max-width: 640px) {
:global(:root[data-reduce='1']) .toast {
animation: none;
}
@media (max-width: 760px) {
.toast-host {
top: auto;
bottom: 20px;
right: 16px;
left: 16px;
@@ -39,7 +39,8 @@
function segClass(key: string): string {
const state = stats.stage_status?.[key];
if (state === 'confirmed') return 'seg-confirmed';
if (state === 'running' || state === 'awaiting_confirm') return 'seg-draft';
if (state === 'running') return 'seg-running';
if (state === 'awaiting_confirm') return 'seg-draft';
return '';
}
@@ -70,7 +71,7 @@
return { cls: 'st-progress', label: `${stageName} · ${t('workbench.state.running')}` };
}
if (state === 'awaiting_confirm') {
return { cls: 'st-progress', label: `${stageName} · ${t('workbench.state.draft')}` };
return { cls: 'st-pending', label: `${stageName} · ${t('workbench.state.draft')}` };
}
if (stats.stage_confirmed > 0) {
return { cls: 'st-progress', label: `${stageName} · ${t('home.statusProgress')}` };
@@ -329,7 +330,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
color: rgba(22, 19, 10, 0.82);
color: color-mix(in srgb, var(--accent-ink) 82%, transparent);
opacity: 0.9;
}
.status-tag {
@@ -340,7 +341,7 @@
border-radius: 999px;
font-size: 10.5px;
font-weight: 600;
background: rgba(13, 14, 17, 0.78);
background: var(--media-scrim);
border: 1px solid var(--border-strong);
backdrop-filter: blur(4px);
}
@@ -350,7 +351,10 @@
color: var(--text-2);
}
.st-progress {
color: var(--accent);
color: var(--run);
}
.st-pending {
color: var(--warn);
}
.st-done {
color: var(--success);
@@ -409,8 +413,11 @@
border-radius: 2px;
background: var(--surface-3);
}
.seg-running {
background: var(--run);
}
.seg-draft {
background: var(--accent);
background: var(--warn);
}
.seg-confirmed {
background: var(--success);
@@ -425,8 +432,8 @@
margin-left: auto;
padding: 1px 8px;
border-radius: 999px;
background: var(--accent-dim);
color: var(--accent);
background: var(--run-soft);
color: var(--run);
font-size: 10px;
font-weight: 600;
font-variant-numeric: tabular-nums;
@@ -462,7 +469,7 @@
height: 28px;
border: 1px solid transparent;
border-radius: 7px;
background: rgba(13, 14, 17, 0.6);
background: var(--media-scrim);
color: var(--text-2);
font-size: 15px;
line-height: 1;
@@ -341,14 +341,14 @@
white-space: nowrap;
}
/* 进行数徽章 (spec §3): accent-dim capsule with amber count */
/* 进行数徽章:进行中计数用信号青 */
.nav-badge {
margin-left: auto;
min-width: 20px;
padding: 1px 7px;
border-radius: 999px;
background: var(--accent-dim);
color: var(--accent);
background: var(--run-soft);
color: var(--run);
font-size: 10.5px;
font-weight: 600;
font-variant-numeric: tabular-nums;
@@ -384,7 +384,7 @@
color: var(--text);
}
/* §5 连接状态点: green = probe passed, amber breathing = testing,
/* 连接状态点: cyan = online, cyan breathing = testing,
red = failed, gray = no connection configured. */
.dot {
width: 7px;
@@ -396,13 +396,13 @@
margin-top: 5px;
}
.dot.ok {
background: var(--success);
background: var(--run);
}
.dot.fail {
background: var(--error);
}
.dot.testing {
background: var(--accent);
background: var(--run);
animation: shell-breathe 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
@keyframes shell-breathe {
@@ -258,7 +258,7 @@
color: var(--accent);
}
.slot-empty.invalid {
border-color: rgba(229, 72, 77, 0.65);
border-color: var(--err-line);
}
.slot-empty:disabled {
cursor: default;
@@ -749,6 +749,28 @@
const selectedEntry = $derived(
doneEntries.find((e) => e.key === selectedKey) ?? doneEntries[0] ?? null,
);
const selectedJobId = $derived(selectedEntry?.job.id ?? null);
/**
* Clicking a history row: a finished job loads into the preview
* (player / image), anything else just reports its live status —
* never a silent jump to the newest finished clip.
*/
function selectFromHistory(job: Job) {
const st = jobStatusInfo(job);
if (st.tone === 'ok' && job.output_paths.length) {
selectedKey = `${job.id}:${job.output_paths[0]}`;
return;
}
toast.info(st.detail && st.detail !== st.label ? `${st.label} · ${st.detail}` : st.label);
}
function histRowKey(e: KeyboardEvent, job: Job) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectFromHistory(job);
}
}
function summaryOf(job: Job): string {
const parts: string[] = [];
@@ -801,6 +823,26 @@
let curSec = $state(0);
let durSec = $state(0);
// Switching the selected video must stop the old clip and rewind it,
// never keep playing the previous audio/progress into the new one.
// Runs BEFORE the DOM swaps the src so the reset cannot clobber the
// duration/metadata events of the clip being loaded in.
$effect.pre(() => {
void selectedEntry?.key;
const v = videoEl;
if (v) {
try {
v.pause();
v.currentTime = 0;
} catch {
/* no loaded metadata yet — src change already reset it */
}
}
playing = false;
curSec = 0;
durSec = 0;
});
function fmtClock(s: number): string {
if (!Number.isFinite(s)) return '0:00';
const m = Math.floor(s / 60);
@@ -828,13 +870,16 @@
else void playerBox?.requestFullscreen();
}
// First/last frame strip for the selected video job
// First/last frame + reference strip for the selected video job.
// Ref workflows come in two shapes: an autogrow image_list, or several
// plain image inputs — both are shown as reference images.
const selectedFrames = $derived.by(() => {
if (kind !== 'video' || !selectedEntry) return [];
const wf = ($workflowsQuery.data ?? []).find((w) => w.id === selectedEntry.job.workflow_id);
if (!wf) return [];
const vals = (selectedEntry.job.payload?.input_values ?? {}) as Record<string, unknown>;
const out: Array<{ label: string; path: string; ref?: boolean }> = [];
let refIdx = 0;
for (const inp of wf.input_schema ?? []) {
const h = `${inp.role ?? ''} ${inp.label}`.toLowerCase();
const v = vals[inp.nodeId];
@@ -848,6 +893,10 @@
if (typeof v !== 'string' || !v) continue;
if (/first|start|首帧/.test(h)) out.push({ label: t('studio.slotFirst'), path: v });
else if (/last|end|尾帧/.test(h)) out.push({ label: t('studio.slotLast'), path: v });
else if (isMedia(inp) && inp.kind === 'image' && !/mask|遮罩/.test(h)) {
refIdx += 1;
out.push({ label: t('studio.slotRefN', { n: refIdx }), path: v, ref: true });
}
}
return out;
});
@@ -895,12 +944,31 @@
}
const deleteTarget = $derived(kindJobs.find((j) => j.id === deleteJobId) ?? null);
// The dialog names the exact job and — when it produced several
// outputs — says plainly that ALL of them go away with it (the
// backend only deletes whole jobs, never a single output file).
const deleteTitle = $derived(
deleteTarget ? t('studio.delTitleJob', { name: wfName(deleteTarget) }) : t('studio.delTitle'),
);
const deleteMessage = $derived.by(() => {
const n = deleteTarget?.output_paths?.length ?? 0;
return n > 1 ? t('studio.delConfirmMulti', { n }) : t('studio.delConfirm');
});
function askDeleteJob(jobId: number) {
deleteJobId = jobId;
deleteOpen = true;
}
async function doDelete() {
if (!deleteJobId) return;
try {
await playgroundApi.deleteJob(deleteJobId);
submittedIds.delete(deleteJobId);
if (selectedEntry?.job.id === deleteJobId) selectedKey = null;
toast.success(t('studio.deletedToast'));
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
await client.invalidateQueries({ queryKey: ['jobs'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
@@ -1556,11 +1624,20 @@
<Icon name="upload" size={13} />
{t('studio.sendToImg2Img')}
</Button>
<a class="a-btn secondary sm" href={assetUrl(selectedEntry.path)} download={downloadName(selectedEntry.path)}>
<Icon name="download" size={13} />
{t('studio.download')}
</a>
</span>
<a class="a-btn secondary sm" href={assetUrl(selectedEntry.path)} download={downloadName(selectedEntry.path)}>
<Icon name="download" size={13} />
{t('studio.download')}
</a>
<Button
variant="danger"
size="sm"
title={t('studio.deleteBtn')}
onclick={() => askDeleteJob(selectedEntry!.job.id)}
>
<Icon name="trash" size={13} />
{t('studio.deleteBtn')}
</Button>
</span>
</div>
{/if}
{:else}
@@ -1575,7 +1652,8 @@
<h2>{t('studio.video.resultsTitle')}</h2>
{#if selectedEntry}
<span class="panel-hint">
{t('studio.lastDone', {
{wfName(selectedEntry.job)} ·
{t('studio.doneAt', {
time: fmtTime(parseDbTs(selectedEntry.job.completed_at ?? selectedEntry.job.created_at)),
})}
</span>
@@ -1648,11 +1726,20 @@
<Icon name="retry" size={13} />
{t('studio.retryLink')}
</Button>
<a class="a-btn secondary sm" href={assetUrl(selectedEntry.path)} download={downloadName(selectedEntry.path)}>
<Icon name="download" size={13} />
{t('studio.downloadMp4')}
</a>
</span>
<a class="a-btn secondary sm" href={assetUrl(selectedEntry.path)} download={downloadName(selectedEntry.path)}>
<Icon name="download" size={13} />
{t('studio.downloadMp4')}
</a>
<Button
variant="danger"
size="sm"
title={t('studio.deleteBtn')}
onclick={() => askDeleteJob(selectedEntry!.job.id)}
>
<Icon name="trash" size={13} />
{t('studio.deleteBtn')}
</Button>
</span>
</div>
{:else}
<p class="empty-line">{t('studio.historyEmptyVideo')}</p>
@@ -1673,59 +1760,73 @@
{kind === 'image' ? t('studio.historyEmptyImage') : t('studio.historyEmptyVideo')}
</p>
{:else}
<div class="history-list">
{#each kindJobs as job (job.id)}
{@const st = jobStatusInfo(job)}
<div class="hist-row" class:failed={job.status === 'failed'} class:video={kind === 'video'}>
<div class="hist-thumb" class:video={kind === 'video'}>
{#if job.output_paths?.[0]}
{#if kind === 'image'}
<img src={assetUrl(job.output_paths[0])} alt={wfName(job)} loading="lazy" />
{:else}
<video
src={assetUrl(job.output_paths[0])}
preload="metadata"
muted
onloadedmetadata={(e) => noteDuration(job.output_paths![0], e)}
></video>
{#if durations[job.output_paths[0]]}
<span class="dur mono">{fmtClock(durations[job.output_paths[0]])}</span>
{/if}
{/if}
{:else if job.status === 'failed'}
<span class="thumb-fallback failed"><Icon name="alert" size={14} /></span>
<div class="history-list">
{#each kindJobs as job (job.id)}
{@const st = jobStatusInfo(job)}
<div
class="hist-row"
class:failed={job.status === 'failed'}
class:video={kind === 'video'}
class:selected={selectedJobId === job.id}
role="button"
tabindex={0}
aria-label={`${wfName(job)} · ${st.label}`}
aria-current={selectedJobId === job.id ? 'true' : undefined}
onclick={() => selectFromHistory(job)}
onkeydown={(e) => histRowKey(e, job)}
>
<div class="hist-thumb" class:video={kind === 'video'}>
{#if job.output_paths?.[0]}
{#if kind === 'image'}
<img src={assetUrl(job.output_paths[0])} alt={wfName(job)} loading="lazy" />
{:else}
<span class="thumb-fallback"><Icon name={kind === 'image' ? 'image' : 'film'} size={14} /></span>
{/if}
</div>
<div class="hist-body">
<div class="hist-name" title={wfName(job)}>{wfName(job)}</div>
<div class="hist-meta mono">{summaryOf(job)}</div>
<div class="hist-status">
{#if job.status !== 'failed'}
<span class="st {st.tone}" title={st.detail}>{st.label}</span>
{:else}
<button
type="button"
class="st fail-link"
disabled={retryingId === job.id}
onclick={() => retryJob(job)}
>
{t('studio.retryLink')}
</button>
<video
src={assetUrl(job.output_paths[0])}
preload="metadata"
muted
onloadedmetadata={(e) => noteDuration(job.output_paths![0], e)}
></video>
{#if durations[job.output_paths[0]]}
<span class="dur mono">{fmtClock(durations[job.output_paths[0]])}</span>
{/if}
{/if}
{:else if job.status === 'failed'}
<span class="thumb-fallback failed"><Icon name="alert" size={14} /></span>
{:else}
<span class="thumb-fallback"><Icon name={kind === 'image' ? 'image' : 'film'} size={14} /></span>
{/if}
</div>
<div class="hist-body">
<div class="hist-name" title={wfName(job)}>{wfName(job)}</div>
<div class="hist-meta mono">{summaryOf(job)}</div>
<div class="hist-status">
{#if job.status !== 'failed'}
<span class="st {st.tone}" title={st.detail}>{st.label}</span>
{:else}
<button
type="button"
class="hist-del"
title={t('studio.deleteBtn')}
onclick={() => {
deleteJobId = job.id;
deleteOpen = true;
class="st fail-link"
disabled={retryingId === job.id}
onclick={(e) => {
e.stopPropagation();
retryJob(job);
}}
>
<Icon name="trash" size={13} />
{t('studio.retryLink')}
</button>
</div>
{/if}
<button
type="button"
class="hist-del"
title={t('studio.deleteBtn')}
onclick={(e) => {
e.stopPropagation();
askDeleteJob(job.id);
}}
>
<Icon name="trash" size={13} />
</button>
</div>
{#if job.status === 'failed' && job.error}
<div class="hist-error">{job.error}</div>
{:else if st.tone === 'fail' || (job.status === 'done' && job.phase === 'dry_run')}
@@ -1765,9 +1866,10 @@
<ConfirmDialog
bind:open={deleteOpen}
title={t('studio.delTitle')}
message={t('studio.delConfirm')}
title={deleteTitle}
message={deleteMessage}
confirmLabel={t('studio.deleteBtn')}
cancelLabel={t('common.cancel')}
danger
onconfirm={doDelete}
oncancel={() => {
@@ -1795,7 +1897,7 @@
color: var(--text-3);
}
.h3-hint p.warn {
color: var(--accent);
color: var(--warn);
}
.studio-page {
display: grid;
@@ -1888,10 +1990,10 @@
}
.f-input:focus {
outline: none;
border-color: rgba(232, 163, 61, 0.6);
border-color: var(--accent-line);
}
.f-input.invalid {
border-color: rgba(229, 72, 77, 0.65);
border-color: var(--err-line);
}
.f-error {
display: block;
@@ -2037,13 +2139,10 @@
font-weight: 600;
flex-shrink: 0;
}
.tag.info {
color: var(--info);
background: var(--info-dim);
}
.tag.info,
.tag.cloud {
color: var(--cloud);
background: var(--cloud-dim);
color: var(--text-2);
background: var(--bg-4);
}
.task-body {
margin-top: 12px;
@@ -2066,9 +2165,9 @@
.progress .fill {
height: 100%;
border-radius: 999px;
background: var(--accent);
box-shadow: 0 0 10px var(--accent-glow);
transition: width 300ms cubic-bezier(0.16, 1, 0.3, 1);
background: var(--run-grad);
box-shadow: 0 0 8px var(--run-soft);
transition: width var(--d-progress) linear;
}
.task-meta {
font-size: 11px;
@@ -2136,7 +2235,7 @@
display: block;
}
.thumb:hover {
border-color: rgba(255, 255, 255, 0.24);
border-color: var(--line-2);
}
.thumb.selected {
border: 2px solid var(--accent);
@@ -2369,10 +2468,23 @@
border-radius: 8px;
min-height: 44px;
align-items: center;
cursor: pointer;
}
.hist-row:hover {
background: var(--surface-2);
}
.hist-row:focus-visible {
outline: 1px solid var(--accent);
outline-offset: -1px;
}
/* The row loaded into the preview stays marked, and its delete stays visible. */
.hist-row.selected {
background: var(--surface-2);
box-shadow: inset 2px 0 0 var(--accent);
}
.hist-row.selected .hist-del {
opacity: 1;
}
.hist-row.failed {
background: var(--error-dim);
}
@@ -2456,7 +2568,7 @@
color: var(--success);
}
.st.run {
color: var(--accent);
color: var(--run);
}
.st.queue {
color: var(--text-3);
@@ -2498,6 +2610,15 @@
.hist-row:hover .hist-del {
opacity: 1;
}
.hist-del:focus-visible {
opacity: 1;
}
/* Touch devices have no hover to reveal the delete affordance. */
@media (hover: none) {
.hist-del {
opacity: 1;
}
}
.hist-del:hover {
color: var(--error);
background: var(--error-dim);
@@ -3,8 +3,9 @@
import Spinner from './Spinner.svelte';
interface Props {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md';
/** `danger` = outlined (list rows); `danger-solid` = confirm dialogs only. */
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'danger-solid';
size?: 'sm' | 'md' | 'lg';
/** Stretch to the container width (e.g. a panel's main action). */
block?: boolean;
disabled?: boolean;
@@ -32,6 +33,7 @@
{type}
class="a-btn btn {variant}"
class:sm={size === 'sm'}
class:lg={size === 'lg'}
class:block
class:loading
disabled={disabled || loading}
@@ -49,7 +51,7 @@
</button>
<style>
/* Look comes from the shared `.a-btn` rules in app.css (§5). */
/* Look comes from the shared `.a-btn` rules in app.css (§四.1). */
.btn {
position: relative;
}
@@ -72,10 +74,14 @@
justify-content: center;
}
.btn.primary .btn-spinner {
--spinner-track: rgba(22, 19, 10, 0.3);
--spinner-arc: var(--accent-contrast);
--spinner-track: color-mix(in srgb, var(--accent-ink) 30%, transparent);
--spinner-arc: var(--accent-ink);
}
.btn.danger .btn-spinner {
--spinner-arc: var(--error);
--spinner-arc: var(--err);
}
.btn.danger-solid .btn-spinner {
--spinner-track: color-mix(in srgb, var(--on-media) 30%, transparent);
--spinner-arc: var(--on-media);
}
</style>
@@ -46,16 +46,18 @@
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
border-radius: var(--r-lg);
}
.card.hoverable {
transition:
border-color 150ms ease,
transform 150ms ease;
border-color var(--d-state) ease,
transform var(--d-state) var(--ease-out);
}
.card.hoverable:hover {
border-color: #3f3f46;
transform: translateY(-1px);
@media (hover: hover) and (pointer: fine) {
.card.hoverable:hover {
border-color: var(--line-2);
transform: translateY(-1px);
}
}
.card-header {
display: flex;
@@ -47,7 +47,7 @@
{#snippet footer()}
<Button variant="ghost" onclick={() => (open = false)}>{cancelLabel}</Button
>
<Button variant={danger ? 'danger' : 'primary'} onclick={handleConfirm}
<Button variant={danger ? 'danger-solid' : 'primary'} onclick={handleConfirm}
>{confirmLabel}</Button
>
{/snippet}
+14 -15
View File
@@ -144,22 +144,19 @@
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(5, 5, 8, 0.6);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
animation: modal-fade 150ms ease-out;
background: var(--overlay);
animation: modal-fade var(--d-state) ease-out;
}
.modal-panel {
width: min(520px, 100%);
max-height: calc(100vh - 96px);
display: flex;
flex-direction: column;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
background: var(--float-bg);
border-radius: var(--r-lg);
box-shadow: var(--shadow-float);
outline: none;
animation: modal-rise 180ms ease-out;
animation: modal-rise var(--d-pop) var(--ease-out);
}
.modal-panel.size-lg {
width: min(920px, 100%);
@@ -177,7 +174,7 @@
.modal-title {
margin: 0;
font-family: var(--font-display);
font-size: 16px;
font-size: var(--fs-title);
font-weight: 600;
color: var(--text-primary);
}
@@ -194,12 +191,14 @@
color: var(--text-muted);
cursor: pointer;
transition:
background-color 150ms ease,
color 150ms ease;
background-color var(--d-state) ease,
color var(--d-state) ease;
}
.modal-close:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text-primary);
@media (hover: hover) and (pointer: fine) {
.modal-close:hover {
background: var(--bg-4);
color: var(--text-primary);
}
}
.modal-close:focus-visible {
outline: 2px solid var(--accent);
@@ -4,8 +4,8 @@
indeterminate?: boolean;
label?: string;
size?: 'sm' | 'md';
/** §5 进度条: running = amber, done = green, failed = red (static). */
tone?: 'accent' | 'success' | 'error';
/** 进度条: running = 信号青, done = green, failed = red (static). */
tone?: 'run' | 'success' | 'error';
/** Track only — no label / percentage row. */
bare?: boolean;
}
@@ -15,7 +15,7 @@
indeterminate = false,
label,
size = 'md',
tone = 'accent',
tone = 'run',
bare = false,
}: Props = $props();
@@ -45,7 +45,7 @@
{#if indeterminate}
<div class="fill indeterminate"></div>
{:else}
<div class="fill" style:width={pct + '%'}></div>
<div class="fill" style:transform="scaleX({pct / 100})"></div>
{/if}
</div>
</div>
@@ -75,8 +75,8 @@
position: relative;
width: 100%;
overflow: hidden;
background: var(--surface-3);
border-radius: 999px;
background: var(--bg-4);
border-radius: var(--r-pill);
}
.progress-sm .track {
height: 4px;
@@ -86,15 +86,18 @@
}
.fill {
height: 100%;
background: var(--accent);
border-radius: 999px;
transition: width 300ms cubic-bezier(0.16, 1, 0.3, 1);
background: var(--run-grad);
box-shadow: 0 0 8px var(--run-soft);
transform-origin: left;
transition: transform var(--d-progress) linear;
}
.tone-success .fill {
background: var(--success);
background: var(--ok);
box-shadow: none;
}
.tone-error .fill {
background: var(--error);
background: var(--err);
box-shadow: none;
}
.fill.indeterminate {
position: absolute;
@@ -104,10 +107,11 @@
background: linear-gradient(
90deg,
transparent,
var(--accent) 40%,
var(--accent) 60%,
var(--run) 40%,
var(--run) 60%,
transparent
);
box-shadow: none;
transition: none;
animation: progress-slide 1.2s ease-in-out infinite;
}
@@ -19,36 +19,26 @@
></div>
<style>
/* §四.9 骨架屏:--bg-3 圆角块,1.4s 呼吸透明度;减少动态时静态。 */
.skeleton {
position: relative;
overflow: hidden;
background: var(--bg-elevated);
border-radius: var(--radius-sm);
background: var(--bg-3);
border-radius: var(--r-sm);
animation: skeleton-breath 1.4s ease-in-out infinite;
}
.skeleton.circle {
border-radius: 50%;
}
.skeleton::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.06),
transparent
);
transform: translateX(-100%);
animation: skeleton-shimmer 1.6s ease-in-out infinite;
}
@keyframes skeleton-shimmer {
to {
transform: translateX(100%);
@keyframes skeleton-breath {
50% {
opacity: 0.5;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton::after {
display: none;
.skeleton {
animation: none;
}
}
:global(:root[data-reduce='1']) .skeleton {
animation: none;
}
</style>
@@ -6,27 +6,27 @@
let { status, label }: Props = $props();
type Tone = 'success' | 'info' | 'muted' | 'error' | 'warning' | 'accent';
type Tone = 'ok' | 'run' | 'muted' | 'err' | 'warn';
const toneByStatus: Record<string, Tone> = {
ready: 'success',
done: 'success',
completed: 'success',
running: 'info',
generating: 'info',
queued: 'info',
ready: 'ok',
done: 'ok',
completed: 'ok',
running: 'run',
generating: 'run',
in_progress: 'run',
queued: 'muted',
pending: 'muted',
idle: 'muted',
draft: 'muted',
failed: 'error',
error: 'error',
paused: 'warning',
in_progress: 'accent',
failed: 'err',
error: 'err',
paused: 'warn',
};
const key = $derived(status.trim().toLowerCase());
const tone: Tone = $derived(toneByStatus[key] ?? 'muted');
const pulsing = $derived(key === 'running');
const pulsing = $derived(tone === 'run');
</script>
<span class="chip chip-{tone}">
@@ -35,59 +35,48 @@
</span>
<style>
/* §四.2 状态标签:20px 高、--r-sm、12px;语义色文字 + 13% 软底。 */
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 9px;
border: 1px solid;
border-radius: 999px;
font-size: 10px;
gap: var(--sp-2);
height: 20px;
padding: 0 var(--sp-2);
border-radius: var(--r-sm);
font-size: var(--fs-aux);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 1.4;
white-space: nowrap;
box-sizing: border-box;
}
.dot {
width: 6px;
height: 6px;
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.chip-success {
color: var(--success);
background: color-mix(in srgb, var(--success) 12%, transparent);
border-color: color-mix(in srgb, var(--success) 35%, transparent);
.chip-ok {
color: var(--ok);
background: var(--ok-soft);
}
.chip-info {
color: var(--info);
background: color-mix(in srgb, var(--info) 12%, transparent);
border-color: color-mix(in srgb, var(--info) 35%, transparent);
.chip-run {
color: var(--run);
background: var(--run-soft);
}
.chip-error {
color: var(--error);
background: color-mix(in srgb, var(--error) 12%, transparent);
border-color: color-mix(in srgb, var(--error) 35%, transparent);
.chip-err {
color: var(--err);
background: var(--err-soft);
}
.chip-warning {
color: var(--warning);
background: color-mix(in srgb, var(--warning) 12%, transparent);
border-color: color-mix(in srgb, var(--warning) 35%, transparent);
}
.chip-accent {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
.chip-warn {
color: var(--warn);
background: var(--warn-soft);
}
.chip-muted {
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.04);
border-color: var(--border);
color: var(--text-2);
background: var(--bg-4);
}
.dot.pulsing {
animation: chip-pulse 1.2s ease-in-out infinite;
animation: chip-pulse 1.4s ease-in-out infinite;
}
@keyframes chip-pulse {
0%,
@@ -103,4 +92,7 @@
animation: none;
}
}
:global(:root[data-reduce='1']) .dot.pulsing {
animation: none;
}
</style>
@@ -9,7 +9,7 @@
let { checked, label, disabled = false, onchange }: Props = $props();
</script>
<!-- §5 开关: 34×19 capsule; on = amber track + dark knob, off = --surface-3. -->
<!-- §3.10 开关: 34×19 capsule; on = --accent-lo track + ink knob, off = --bg-4. -->
<button
type="button"
class="switch"
@@ -28,38 +28,38 @@
flex-shrink: 0;
width: 34px;
height: 19px;
border-radius: 999px;
border: 1px solid var(--border-strong);
background: var(--surface-3);
border-radius: var(--r-pill);
border: 1px solid var(--line-2);
background: var(--bg-4);
position: relative;
cursor: pointer;
padding: 0;
transition:
background-color 150ms,
border-color 150ms;
background-color var(--d-state) ease,
border-color var(--d-state) ease;
}
.switch:disabled {
opacity: 0.4;
opacity: 0.45;
cursor: not-allowed;
}
.knob {
position: absolute;
top: 2px;
left: 2px;
top: 1px;
left: 1px;
width: 15px;
height: 15px;
border-radius: 999px;
border-radius: var(--r-pill);
background: var(--text-3);
transition:
transform 150ms cubic-bezier(0.16, 1, 0.3, 1),
background-color 150ms;
transform var(--d-state) var(--ease-out),
background-color var(--d-state) ease;
}
.switch.on {
background: var(--accent);
border-color: var(--accent);
background: var(--accent-lo);
border-color: var(--accent-lo);
}
.switch.on .knob {
transform: translateX(15px);
background: var(--accent-contrast);
background: var(--accent-ink);
}
</style>
@@ -369,7 +369,7 @@
}
.wb-input:focus {
outline: none;
border-color: rgba(232, 163, 61, 0.6);
border-color: var(--accent-line);
}
.wb-input::placeholder {
color: var(--text-3);
@@ -438,7 +438,7 @@
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: rgba(255, 255, 255, 0.24);
border-color: var(--line-2);
}
.btn.gen {
color: var(--accent);
@@ -60,8 +60,8 @@
background: var(--surface-3);
}
.st-running {
color: var(--accent);
background: var(--accent-dim);
color: var(--run);
background: var(--run-soft);
}
.st-done {
color: var(--success);
@@ -258,7 +258,7 @@
}
.wb-input:focus {
outline: none;
border-color: rgba(232, 163, 61, 0.6);
border-color: var(--accent-line);
}
.wb-input::placeholder {
color: var(--text-3);
@@ -294,7 +294,7 @@
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: rgba(255, 255, 255, 0.24);
border-color: var(--line-2);
}
.btn.sm {
padding: 6px 12px;
@@ -341,7 +341,7 @@
}
.wb-input:focus {
outline: none;
border-color: rgba(232, 163, 61, 0.6);
border-color: var(--accent-line);
}
.wb-input::placeholder {
color: var(--text-3);
@@ -412,7 +412,7 @@
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: rgba(255, 255, 255, 0.24);
border-color: var(--line-2);
}
.btn.gen {
color: var(--accent);
@@ -3,9 +3,9 @@
import { t } from '$lib/i18n.svelte';
/**
* Five-stage stepper (a2): one step per agent with the spec §2.1 semantic
* colors — gray=待开始, amber=进行中/待确认 (+breath while running),
* green=已确认, blue=用户改过草稿. A step unlocks when itself or any
* Five-stage stepper (a2): one step per agent with the fusion status
* colors — gray=待开始, cyan=进行中 (+breath), amber=待确认,
* green=已确认, --info=用户改过草稿. A step unlocks when itself or any
* earlier stage has content; later steps stay clickable only after the
* previous one is confirmed.
*/
@@ -159,7 +159,7 @@
justify-content: center;
font-size: 14px;
font-weight: 700;
color: #edeef0;
color: var(--text);
flex-shrink: 0;
}
@@ -188,12 +188,14 @@
.st-todo {
color: var(--text-3);
}
.st-running,
.st-running {
color: var(--run);
}
.st-draft {
color: var(--accent);
color: var(--warn);
}
.st-edited {
color: var(--info);
color: var(--warn);
}
.st-confirmed {
color: var(--success);
@@ -201,14 +203,14 @@
.step-btn.active[data-state='running'] .avatar,
.step-btn[data-state='running'] .avatar {
box-shadow: 0 0 10px rgba(232, 163, 61, 0.35);
box-shadow: 0 0 10px var(--run-soft);
}
.breath {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
background: var(--run);
animation: wb-breath 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
@@ -297,7 +297,7 @@
}
.wb-input:focus {
outline: none;
border-color: rgba(232, 163, 61, 0.6);
border-color: var(--accent-line);
}
.wb-input::placeholder {
color: var(--text-3);
@@ -386,7 +386,7 @@
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: rgba(255, 255, 255, 0.24);
border-color: var(--line-2);
}
.btn:disabled {
opacity: 0.4;
@@ -214,7 +214,7 @@
}
.dir-card.chosen {
border-color: var(--accent);
box-shadow: 0 0 10px rgba(232, 163, 61, 0.35);
box-shadow: 0 0 10px var(--accent-glow);
}
.dir-card input[type='radio'] {
position: absolute;
@@ -310,7 +310,7 @@
}
.wb-input:focus {
outline: none;
border-color: rgba(232, 163, 61, 0.6);
border-color: var(--accent-line);
}
.wb-input::placeholder {
color: var(--text-3);
@@ -375,7 +375,7 @@
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: rgba(255, 255, 255, 0.24);
border-color: var(--line-2);
}
.btn:disabled {
opacity: 0.4;
+5 -2
View File
@@ -1462,7 +1462,7 @@ export const de: Dict = {
'studio.image.historyTitle': 'Bildverlauf',
'studio.video.historyTitle': 'Videoverlauf',
'studio.historyCount': '{n} gesamt',
'studio.lastDone': 'Zuletzt fertig · {time}',
'studio.doneAt': 'Fertiggestellt um {time}',
'studio.kindImageText': 'Bild',
'studio.kindVideoText': 'Video',
'studio.loading': 'Lädt…',
@@ -1578,7 +1578,10 @@ export const de: Dict = {
'studio.cancelToast': 'Abbruch angefordert',
'studio.deleteBtn': 'Löschen',
'studio.delTitle': 'Aufgabeneintrag löschen',
'studio.delConfirm': 'Die Ergebnisdateien werden mitgelöscht. Löschen?',
'studio.delConfirm': 'Beim Löschen werden auch die Ergebnisdateien dieses Auftrags entfernt; der Verlauf ist nicht wiederherstellbar. Löschen?',
'studio.delTitleJob': 'Auftrag „{name}“ löschen',
'studio.delConfirmMulti': 'Dieser Auftrag hat {n} Ergebnisse. Beim Löschen werden der gesamte Auftragsdatensatz und alle {n} Ergebnisdateien entfernt; dies ist unwiderruflich. Löschen?',
'studio.deletedToast': 'Auftrag und zugehörige Ergebnisdateien gelöscht',
'studio.wfEmptyTitle': 'Noch keine {kind}-Workflows',
'studio.wfEmptyBody': 'Entwicklermodus in ComfyUI aktivieren, Workflow als API-JSON exportieren und auf der Seite ComfyUI-Verbindungen als {kind} importieren.',
'studio.wfEmptyGo': 'In ComfyUI-Verbindungen importieren',
+5 -2
View File
@@ -1461,7 +1461,7 @@ export const en = {
'studio.image.historyTitle': 'Image history',
'studio.video.historyTitle': 'Video history',
'studio.historyCount': '{n} total',
'studio.lastDone': 'Last done · {time}',
'studio.doneAt': 'Done at {time}',
'studio.kindImageText': 'image',
'studio.kindVideoText': 'video',
'studio.loading': 'Loading…',
@@ -1577,7 +1577,10 @@ export const en = {
'studio.cancelToast': 'Cancel requested',
'studio.deleteBtn': 'Delete',
'studio.delTitle': 'Delete task record',
'studio.delConfirm': 'The result files will be removed together with the record. Delete it?',
'studio.delConfirm': 'Deleting also removes this job’s result files; the history cannot be restored. Delete?',
'studio.delTitleJob': 'Delete job “{name}”',
'studio.delConfirmMulti': 'This job produced {n} results. Deleting removes the whole job record and all {n} result files; this cannot be undone. Delete?',
'studio.deletedToast': 'Job deleted together with its result files',
'studio.wfEmptyTitle': 'No {kind} workflows yet',
'studio.wfEmptyBody': "Enable ComfyUI's developer mode, export the workflow as API JSON, then import it on the ComfyUI Connections page as {kind}.",
'studio.wfEmptyGo': 'Import in ComfyUI Connections',
+5 -2
View File
@@ -1462,7 +1462,7 @@ export const es: Dict = {
'studio.image.historyTitle': 'Historial de imágenes',
'studio.video.historyTitle': 'Historial de vídeos',
'studio.historyCount': '{n} en total',
'studio.lastDone': 'Último · {time}',
'studio.doneAt': 'Completado a las {time}',
'studio.kindImageText': 'imagen',
'studio.kindVideoText': 'vídeo',
'studio.loading': 'Cargando…',
@@ -1578,7 +1578,10 @@ export const es: Dict = {
'studio.cancelToast': 'Cancelación solicitada',
'studio.deleteBtn': 'Eliminar',
'studio.delTitle': 'Eliminar registro de tarea',
'studio.delConfirm': 'Los archivos de resultados se eliminarán junto con el registro. ¿Eliminar?',
'studio.delConfirm': 'Al eliminar también se quitan los archivos de resultados de esta tarea; el historial no se puede restaurar. ¿Eliminar?',
'studio.delTitleJob': 'Eliminar tarea «{name}»',
'studio.delConfirmMulti': 'Esta tarea produjo {n} resultados. Al eliminarla se quita el registro completo y los {n} archivos de resultados; no se puede deshacer. ¿Eliminar?',
'studio.deletedToast': 'Tarea eliminada junto con sus archivos de resultados',
'studio.wfEmptyTitle': 'Aún no hay flujos de {kind}',
'studio.wfEmptyBody': 'Activa el modo desarrollador de ComfyUI, exporta el flujo como JSON API e impórtalo en Conexiones ComfyUI como {kind}.',
'studio.wfEmptyGo': 'Importar en Conexiones ComfyUI',
+5 -2
View File
@@ -1462,7 +1462,7 @@ export const fr: Dict = {
'studio.image.historyTitle': 'Historique images',
'studio.video.historyTitle': 'Historique vidéos',
'studio.historyCount': '{n} au total',
'studio.lastDone': 'Dernier · {time}',
'studio.doneAt': 'Terminé à {time}',
'studio.kindImageText': 'image',
'studio.kindVideoText': 'vidéo',
'studio.loading': 'Chargement…',
@@ -1578,7 +1578,10 @@ export const fr: Dict = {
'studio.cancelToast': 'Annulation demandée',
'studio.deleteBtn': 'Supprimer',
'studio.delTitle': 'Supprimer la tâche',
'studio.delConfirm': 'Les fichiers de résultats seront supprimés avec la tâche. Supprimer ?',
'studio.delConfirm': 'La suppression retire aussi les fichiers de résultats de cette tâche ; l’historique est irrécupérable. Supprimer ?',
'studio.delTitleJob': 'Supprimer la tâche « {name} »',
'studio.delConfirmMulti': 'Cette tâche a produit {n} résultats. La suppression retire la tâche entière et ses {n} fichiers de résultats ; action irréversible. Supprimer ?',
'studio.deletedToast': 'Tâche supprimée avec ses fichiers de résultats',
'studio.wfEmptyTitle': 'Pas encore de workflows {kind}',
'studio.wfEmptyBody': 'Activez le mode développeur de ComfyUI, exportez le workflow en JSON API, puis importez-le dans Connexions ComfyUI comme {kind}.',
'studio.wfEmptyGo': 'Importer dans Connexions ComfyUI',
+5 -2
View File
@@ -1462,7 +1462,7 @@ export const ja: Dict = {
'studio.image.historyTitle': '画像生成履歴',
'studio.video.historyTitle': '動画生成履歴',
'studio.historyCount': '計 {n} 回',
'studio.lastDone': '最終完了 · {time}',
'studio.doneAt': '完了 {time}',
'studio.kindImageText': '画像',
'studio.kindVideoText': '動画',
'studio.loading': '読み込み中…',
@@ -1578,7 +1578,10 @@ export const ja: Dict = {
'studio.cancelToast': 'キャンセルを要求しました',
'studio.deleteBtn': '削除',
'studio.delTitle': 'タスク記録を削除',
'studio.delConfirm': '結果ファイルも一緒に削除され、履歴は復元できません。削除しますか?',
'studio.delConfirm': '削除するとこのタスクの結果ファイルも削除され、履歴は元に戻せません。削除しますか?',
'studio.delTitleJob': 'タスク「{name}」を削除',
'studio.delConfirmMulti': 'このタスクには結果が {n} 件あります。削除するとタスク記録と {n} 件すべての結果ファイルが失われ、元に戻せません。削除しますか?',
'studio.deletedToast': 'タスクと結果ファイルを削除しました',
'studio.wfEmptyTitle': '{kind}ワークフローがまだありません',
'studio.wfEmptyBody': 'ComfyUI の開発者モードで API 形式 JSON を書き出し、「ComfyUI 接続」ページで{kind}として取り込んでください。',
'studio.wfEmptyGo': '「ComfyUI 接続」で取り込む',
+5 -2
View File
@@ -1462,7 +1462,7 @@ export const ko: Dict = {
'studio.image.historyTitle': '이미지 생성 기록',
'studio.video.historyTitle': '영상 생성 기록',
'studio.historyCount': '총 {n}회',
'studio.lastDone': '최근 완료 · {time}',
'studio.doneAt': '완료 {time}',
'studio.kindImageText': '이미지',
'studio.kindVideoText': '비디오',
'studio.loading': '불러오는 중…',
@@ -1578,7 +1578,10 @@ export const ko: Dict = {
'studio.cancelToast': '취소를 요청했습니다',
'studio.deleteBtn': '삭제',
'studio.delTitle': '작업 기록 삭제',
'studio.delConfirm': '결과 파일도 함께 삭제되며 기록은 복구할 수 없습니다. 삭제할까요?',
'studio.delConfirm': '삭제 시 이 작업의 결과 파일도 함께 제거되며 기록은 복구할 수 없습니다. 삭제할까요?',
'studio.delTitleJob': '작업 «{name}» 삭제',
'studio.delConfirmMulti': '이 작업에는 결과가 {n}개 있습니다. 삭제하면 작업 기록과 결과 파일 {n}개 모두 제거되며 복구할 수 없습니다. 삭제할까요?',
'studio.deletedToast': '작업과 결과 파일을 삭제했습니다',
'studio.wfEmptyTitle': '{kind} 워크플로가 아직 없습니다',
'studio.wfEmptyBody': 'ComfyUI 개발자 모드를 켜고 API 형식 JSON으로 내보낸 뒤, ComfyUI 연결 페이지에서 {kind}(으)로 가져오세요.',
'studio.wfEmptyGo': 'ComfyUI 연결에서 가져오기',
+4 -1
View File
@@ -1461,7 +1461,7 @@ export const zh: Dict = {
'studio.image.historyTitle': '生图历史',
'studio.video.historyTitle': '生视频历史',
'studio.historyCount': '共 {n} 次',
'studio.lastDone': '最近完成 · {time}',
'studio.doneAt': '完成于 {time}',
'studio.kindImageText': '图片',
'studio.kindVideoText': '视频',
'studio.loading': '正在加载…',
@@ -1579,6 +1579,9 @@ export const zh: Dict = {
'studio.deleteBtn': '删除',
'studio.delTitle': '删除任务记录',
'studio.delConfirm': '删除后该任务的结果文件会一并移除,历史不可恢复。确定删除?',
'studio.delTitleJob': '删除任务「{name}」',
'studio.delConfirmMulti': '该任务共生成 {n} 个结果,删除将移除整条任务记录及其全部 {n} 个结果文件,历史不可恢复。确定删除?',
'studio.deletedToast': '已删除该任务及其结果文件',
'studio.wfEmptyTitle': '还没有{kind}工作流',
'studio.wfEmptyBody': '在 ComfyUI 里启用开发者模式,用「导出(API 格式)」得到 JSON,再到「ComfyUI 连接」页导入并分类为{kind}。',
'studio.wfEmptyGo': '去「ComfyUI 连接」导入',
+17 -20
View File
@@ -245,11 +245,11 @@
return parts.filter(Boolean).join(' · ');
}
function jobBar(job: Job): { value: number; tone: 'accent' | 'success' | 'error' } {
function jobBar(job: Job): { value: number; tone: 'run' | 'success' | 'error' } {
if (job.status === 'done') return { value: 100, tone: 'success' };
if (job.status === 'failed') return { value: 100, tone: 'error' };
if (job.status === 'running') return { value: progressFor(job.id)?.progress ?? 3, tone: 'accent' };
return { value: 0, tone: 'accent' };
if (job.status === 'running') return { value: progressFor(job.id)?.progress ?? 3, tone: 'run' };
return { value: 0, tone: 'run' };
}
function jobStatusLabel(status: string): string {
@@ -493,7 +493,7 @@
<aside class="tasks">
<div class="tasks-head">
<h2>{t('home.tasksTitle')}</h2>
<span class="chip" class:amber={activeJobCount > 0}>
<span class="chip" class:run={activeJobCount > 0}>
{t('home.activeJobs', { n: activeJobCount })}
</span>
</div>
@@ -580,9 +580,9 @@
font-size: 11.5px;
white-space: nowrap;
}
.chip.amber {
background: var(--accent-dim);
color: var(--accent);
.chip.run {
background: var(--run-soft);
color: var(--run);
}
.entry-btn {
@@ -616,7 +616,7 @@
color: var(--text);
}
.entry-btn.secondary:hover {
border-color: rgba(255, 255, 255, 0.24);
border-color: var(--line-2);
}
.entry-btn:active {
transform: scale(0.98);
@@ -651,7 +651,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent);
color: var(--text-2);
}
.entry-card h2 {
margin: 4px 0 0;
@@ -736,10 +736,10 @@
}
.f-input:focus {
outline: none;
border-color: rgba(232, 163, 61, 0.6);
border-color: var(--accent-line);
}
.f-input.invalid {
border-color: rgba(229, 72, 77, 0.65);
border-color: var(--err-line);
}
.f-hint {
display: block;
@@ -908,13 +908,10 @@
flex-shrink: 0;
white-space: nowrap;
}
.tag-local {
background: var(--info-dim);
color: var(--info);
}
.tag-local,
.tag-cloud {
background: var(--cloud-dim);
color: var(--cloud);
background: var(--bg-4);
color: var(--text-2);
}
.job-name {
flex: 1;
@@ -950,7 +947,7 @@
color: var(--success);
}
.running-text {
color: var(--accent);
color: var(--run);
}
.queued-text {
color: var(--text-3);
@@ -966,8 +963,8 @@
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 10px var(--accent-glow);
background: var(--run);
box-shadow: 0 0 10px var(--run-soft);
}
.dot.breathe {
animation: breathe 2s ease-in-out infinite;
+5 -5
View File
@@ -1133,11 +1133,11 @@
justify-content: center;
font-size: 14px;
font-weight: 700;
color: #edeef0;
color: var(--text);
flex-shrink: 0;
}
.avatar.dark {
color: #16130a;
color: var(--accent-ink);
}
.avatar.lg {
width: 42px;
@@ -1294,8 +1294,8 @@
white-space: nowrap;
}
.status-tag.busy {
background: var(--accent-dim);
color: var(--accent);
background: var(--run-soft);
color: var(--run);
}
.chat-head-actions {
display: flex;
@@ -1618,7 +1618,7 @@
background: var(--success);
}
.dot.amber {
background: var(--accent);
background: var(--run);
}
.dot.amber.breathing {
animation: breathe 2s ease-in-out infinite;
+401 -149
View File
@@ -4,84 +4,201 @@
:root {
/* ------------------------------------------------------------------
* A「片场暗房」design tokens — spec-v2 §2 (HEL-603 confirmed).
* Single source of truth for the whole app: do not hardcode colors in
* pages or components, and do not override these with ad-hoc CSS.
* 融合视觉 design tokens — HEL-649 整站施工规范 §一(流水台结构 ×
* 放映厅配色)。Single source of truth for the whole app: pages and
* components reference these variables only — no hardcoded colors,
* no ad-hoc overrides. Gold is for key actions and the current
* selection only; status colors each carry exactly one meaning.
* ------------------------------------------------------------------ */
--bg: #0d0e11;
--surface: #14161b;
--surface-2: #1b1e25;
--surface-3: #22262f;
--border: rgba(255, 255, 255, 0.07);
--border-strong: rgba(255, 255, 255, 0.14);
--border-hover: rgba(255, 255, 255, 0.24);
--text: #edeef0;
--text-2: #9ba1ab;
--text-3: #5c626c;
--accent: #e8a33d;
--accent-hover: #f1b155; /* +8% brightness (§5 主按钮) */
--accent-contrast: #16130a;
--accent-dim: rgba(232, 163, 61, 0.14);
--accent-glow: rgba(232, 163, 61, 0.35);
--accent-focus: rgba(232, 163, 61, 0.6);
--success: #3fb97f;
--success-dim: rgba(63, 185, 127, 0.14);
--info: #5b8def;
--info-dim: rgba(91, 141, 239, 0.14);
--error: #e5484d;
--error-dim: rgba(229, 72, 77, 0.14);
--error-border: rgba(229, 72, 77, 0.4);
--cloud: #b98be0;
--cloud-dim: rgba(185, 139, 224, 0.14);
/* Avatar five-color set (§2.1) — five agents only */
/* 底色:蓝黑四阶,逐层浮起 (§1.1) */
--bg: #07090e;
--bg-2: #0c0f16;
--bg-3: #121724;
--bg-4: #1a2133;
--line: #1b2233;
--line-2: #2a3450;
--page-glow: radial-gradient(1200px 500px at 70% -10%, rgba(38, 52, 92, 0.25), transparent 60%);
--float-bg: linear-gradient(180deg, #10152a, var(--bg-2));
--overlay: rgba(4, 6, 10, 0.6);
--media-scrim: rgba(7, 9, 14, 0.65);
--on-media: #fff;
--on-media-line: rgba(255, 255, 255, 0.05);
/* 放映金:主按钮、当前选中、改过的参数、聚焦框、Toast 动作 (§1.2) */
--accent: #f2c784;
--accent-ink: #23170a;
--accent-soft: rgba(242, 199, 132, 0.13);
--accent-hi: #f7d9a2;
--accent-lo: #ecaf63;
--accent-glow: rgba(236, 175, 99, 0.28);
--accent-line: rgba(242, 199, 132, 0.55);
--accent-grad: linear-gradient(180deg, var(--accent-hi), var(--accent-lo));
--accent-grad-hover: linear-gradient(180deg, #fbe3b6, #f0b968);
--accent-ring: 0 0 0 1px rgba(242, 199, 132, 0.7), 0 0 22px var(--accent-glow);
--btn-glow: 0 2px 14px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.35);
--btn-glow-hover: 0 4px 18px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.4);
/* 信号色:绿=完成/已确认/通过,青=进行中/在线,红=失败/危险,
琥珀=待确认/文件缺失/限额 (§1.3) */
--ok: #49cb90;
--ok-soft: rgba(73, 203, 144, 0.13);
--run: #54dfd2;
--run-soft: rgba(84, 223, 210, 0.13);
--run-grad: linear-gradient(90deg, #3bbfb4, var(--run));
--err: #f0645a;
--err-soft: rgba(240, 100, 90, 0.13);
--err-line: rgba(240, 100, 90, 0.6);
--warn: #f0b45c;
--warn-soft: rgba(240, 180, 92, 0.13);
/* 文字与字体 (§1.4);--text-2 是深底可读性下限,不得再暗 */
--text: #f4f6fa;
--text-2: #aab2c5;
--text-3: #6d7690;
--font: 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', system-ui, sans-serif;
--mono: 'JetBrains Mono', ui-monospace, monospace;
--fs-title: 15px;
--fs-section: 14px;
--fs-body: 13px;
--fs-body-sm: 12.5px;
--fs-aux: 12px;
--fs-mini: 11px;
--lh-body: 1.55;
/* 间距阶梯 4/6/8/10/12/16/20/28,不得出现阶梯外数值 (§1.5) */
--sp-1: 4px;
--sp-2: 6px;
--sp-3: 8px;
--sp-4: 10px;
--sp-5: 12px;
--sp-6: 16px;
--sp-7: 20px;
--sp-8: 28px;
/* 圆角与唯一一级阴影 (§1.5);卡片不投影,用底色差分层 */
--r-sm: 6px;
--r-md: 10px;
--r-lg: 14px;
--r-pill: 999px;
--shadow-float: 0 14px 44px rgba(0, 0, 0, 0.6), 0 0 0 1px var(--line-2);
/* 专属符号 (§1.7):齿孔只在记录流/输入台分隔带,光晕只在播放舞台 */
--sprocket: radial-gradient(circle 2px, var(--line-2) 98%, transparent);
--stage-bg:
radial-gradient(60% 45% at 50% 38%, rgba(242, 199, 132, 0.07), transparent 70%),
radial-gradient(80% 70% at 50% 50%, #0d1220, #05070c);
/* 动效 (§1.6,取 HEL-648 实测值);只动 transform / opacity */
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
--ease-spring: cubic-bezier(0.34, 1.45, 0.44, 1);
--d-press: 120ms;
--d-state: 150ms;
--d-pop: 180ms;
--d-leave: 200ms;
--d-enter: 220ms;
--d-drawer: 280ms;
--d-flash: 900ms;
--d-progress: 400ms;
--press-scale: 0.97;
/* Avatar five-color set — five agents only; the one colorful gradient
family the spec keeps (§3.6). */
--avatar-1: linear-gradient(135deg, #e8a33d, #b96a1b);
--avatar-2: linear-gradient(135deg, #5b8def, #3a5fa9);
--avatar-3: linear-gradient(135deg, #3fb97f, #2a7a54);
--avatar-4: linear-gradient(135deg, #b98be0, #7a4fa9);
--avatar-5: linear-gradient(135deg, #d98e6a, #a95535);
/* Typography (§2.2) */
--font-ui: 'Noto Sans SC', 'Noto Sans CJK SC', sans-serif;
--font-a-mono: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, monospace;
/* Radius / spacing (§2.3) */
--radius-sm: 7px;
--radius-md: 10px;
--radius-lg: 10px;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
/* Legacy token names used by shared primitives — aliases of the A set,
so every component renders in the same darkroom palette. */
/* ------------------------------------------------------------------
* Legacy token names (§六 映射表) — aliases only, never new values,
* so every existing component renders in the fusion palette.
* ------------------------------------------------------------------ */
--surface: var(--bg-2);
--surface-2: var(--bg-3);
--surface-3: var(--bg-4);
--border: var(--line);
--border-strong: var(--line-2);
--border-hover: var(--line-2);
--accent-hover: var(--accent-hi);
--accent-contrast: var(--accent-ink);
--accent-dim: var(--accent-soft);
--accent-focus: var(--accent-line);
--success: var(--ok);
--success-dim: var(--ok-soft);
--info: var(--run);
--info-dim: var(--run-soft);
--error: var(--err);
--error-dim: var(--err-soft);
--error-border: var(--err-line);
--warning: var(--warn);
--bg-primary: var(--bg);
--bg-surface: var(--surface);
--bg-elevated: var(--surface-2);
--bg-surface: var(--bg-2);
--bg-elevated: var(--bg-3);
--text-primary: var(--text);
--text-secondary: var(--text-2);
--text-muted: var(--text-3);
--warning: var(--accent);
--font-display: var(--font-ui);
--font-body: var(--font-ui);
--font-mono: var(--font-a-mono);
--font-ui: var(--font);
--font-a-mono: var(--mono);
--font-display: var(--font);
--font-body: var(--font);
--font-mono: var(--mono);
--radius-sm: var(--r-sm);
--radius-md: var(--r-md);
--radius-lg: var(--r-lg);
--space-xs: var(--sp-1);
--space-sm: var(--sp-3);
--space-md: var(--sp-6);
--space-lg: var(--sp-7);
--space-xl: var(--sp-8);
/* Omni Composer pill tokens */
--pill-bg: var(--surface-2);
--pill-border: var(--border-strong);
--pill-radius: 9999px;
--pill-bg: var(--bg-3);
--pill-border: var(--line-2);
--pill-radius: var(--r-pill);
--pill-height: 36px;
--pill-padding: 0 14px;
--pill-font-size: 13px;
--pill-font-size: var(--fs-body);
}
/* 减少动态效果 (§五):系统设置自动生效,顶栏开关写 data-reduce="1"。
位移/缩放/弹入/光晕全部取消,只留 ≤120ms 透明度,进度条直接跳变。 */
:root[data-reduce='1'] {
--d-press: 0ms;
--d-state: 120ms;
--d-pop: 120ms;
--d-leave: 120ms;
--d-enter: 120ms;
--d-drawer: 120ms;
--d-flash: 0ms;
--d-progress: 0ms;
--press-scale: 1;
--ease-spring: ease;
}
@media (prefers-reduced-motion: reduce) {
:root {
--d-press: 0ms;
--d-state: 120ms;
--d-pop: 120ms;
--d-leave: 120ms;
--d-enter: 120ms;
--d-drawer: 120ms;
--d-flash: 0ms;
--d-progress: 0ms;
--press-scale: 1;
--ease-spring: ease;
}
}
body {
background: var(--bg);
background: var(--page-glow), var(--bg);
color: var(--text);
font-family: var(--font-ui);
font-family: var(--font);
font-size: var(--fs-body);
line-height: var(--lh-body);
-webkit-font-smoothing: antialiased;
margin: 0;
min-height: 100vh;
}
@@ -89,20 +206,20 @@ body {
.a-darkroom {
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
font-size: 13.5px;
line-height: 1.6;
font-family: var(--font);
font-size: var(--fs-body);
line-height: var(--lh-body);
}
code,
.mono,
.kh {
font-family: var(--font-a-mono);
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
/* ------------------------------------------------------------------
* Shared A controls (§5 组件状态全表). The only definitions of these
* Shared controls (§四 组件状态通用规范). The only definitions of these
* looks — `ui/Button.svelte` renders `.a-btn`, pages use the classes
* directly for links / selects. Page styles may only tweak layout
* (width / margin), never re-declare colors.
@@ -111,91 +228,120 @@ code,
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
gap: var(--sp-2);
min-height: 34px;
padding: 0 16px;
border-radius: 8px;
padding: 0 var(--sp-6);
border-radius: var(--r-md);
border: 1px solid transparent;
font-family: inherit;
font-size: 13px;
font-size: var(--fs-body);
font-weight: 600;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
box-sizing: border-box;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms,
color 150ms,
transform 100ms;
transform var(--d-press) var(--ease-out),
background-color var(--d-state) ease,
border-color var(--d-state) ease,
box-shadow var(--d-state) ease,
color var(--d-state) ease,
opacity var(--d-state) ease;
}
.a-btn.lg {
min-height: 38px;
padding: 0 var(--sp-7);
}
.a-btn.sm {
min-height: 30px;
padding: 0 12px;
font-size: 12px;
min-height: 28px;
padding: 0 var(--sp-4);
font-size: var(--fs-aux);
border-radius: var(--r-sm);
}
.a-btn.xs {
min-height: 24px;
padding: 0 9px;
font-size: 11px;
padding: 0 var(--sp-3);
font-size: var(--fs-mini);
border-radius: var(--r-sm);
}
.a-btn.block {
width: 100%;
}
.a-btn.primary {
background: var(--accent);
color: var(--accent-contrast);
}
.a-btn.primary:hover:not(:disabled) {
background: var(--accent-hover);
background: var(--accent-grad);
color: var(--accent-ink);
box-shadow: var(--btn-glow);
}
.a-btn.secondary {
background: var(--surface-2);
border-color: var(--border-strong);
background: var(--bg-3);
border-color: var(--line);
color: var(--text);
}
.a-btn.secondary:hover:not(:disabled) {
border-color: var(--border-hover);
}
.a-btn.ghost {
background: transparent;
color: var(--text-2);
}
.a-btn.ghost:hover:not(:disabled) {
background: var(--surface-3);
color: var(--text);
}
/* 危险两级制:列表行内只用描边红 danger;实心红 danger-solid 只在确认弹层内。 */
.a-btn.danger {
background: transparent;
border-color: var(--error-border);
color: var(--error);
border-color: var(--err-line);
color: var(--err);
}
.a-btn.danger:hover:not(:disabled) {
background: var(--error-dim);
.a-btn.danger-solid {
background: var(--err);
color: var(--on-media);
}
@media (hover: hover) and (pointer: fine) {
.a-btn.primary:hover:not(:disabled) {
background: var(--accent-grad-hover);
box-shadow: var(--btn-glow-hover);
}
.a-btn.secondary:hover:not(:disabled) {
border-color: var(--line-2);
background: var(--bg-4);
}
.a-btn.ghost:hover:not(:disabled) {
background: var(--bg-4);
color: var(--text);
}
.a-btn.danger:hover:not(:disabled) {
background: var(--err-soft);
}
.a-btn.danger-solid:hover:not(:disabled) {
box-shadow:
0 0 0 1px var(--err-line),
0 0 18px var(--err-soft);
}
}
.a-btn:active:not(:disabled) {
transform: scale(0.98);
transform: scale(var(--press-scale));
}
/* 禁用必须在旁边给出中文原因(§1.2),由调用方渲染。 */
.a-btn:disabled,
.a-btn[aria-disabled='true'] {
opacity: 0.4;
opacity: 0.45;
box-shadow: none;
cursor: not-allowed;
}
/* Top-bar status capsule (counts / save state / connection). */
/* Top-bar status capsule (queue / connection / save state) — §二 顶栏胶囊. */
.status-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border-radius: 999px;
background: var(--surface-3);
gap: var(--sp-2);
height: 30px;
padding: 0 var(--sp-5);
border-radius: var(--r-pill);
border: 1px solid var(--line);
background: var(--bg-2);
color: var(--text-2);
font-size: 11.5px;
font-size: var(--fs-aux);
white-space: nowrap;
box-sizing: border-box;
}
.status-chip.accent {
background: var(--accent-dim);
background: var(--accent-soft);
border-color: var(--accent-line);
color: var(--accent);
}
.status-chip-dot {
@@ -206,33 +352,122 @@ code,
background: currentColor;
}
.status-chip-dot.ok {
background: var(--success);
background: var(--ok);
}
.status-chip-dot.run {
background: var(--run);
}
.status-chip-dot.fail {
background: var(--error);
background: var(--err);
}
.status-chip-dot.idle {
background: var(--text-3);
}
/* Card / panel surface (§2.3: 10px radius, 1px border, no shadow). */
/* Status label (§四.2):语义色文字 + 13% 软底;默认为中性(排队 / 未开始)。 */
.a-tag {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
height: 20px;
padding: 0 var(--sp-2);
border-radius: var(--r-sm);
background: var(--bg-4);
color: var(--text-2);
font-size: var(--fs-aux);
white-space: nowrap;
box-sizing: border-box;
transition:
color var(--d-state) ease,
background-color var(--d-state) ease;
}
.a-tag.accent {
color: var(--accent);
background: var(--accent-soft);
}
.a-tag.ok {
color: var(--ok);
background: var(--ok-soft);
}
.a-tag.run {
color: var(--run);
background: var(--run-soft);
}
.a-tag.err {
color: var(--err);
background: var(--err-soft);
}
.a-tag.warn {
color: var(--warn);
background: var(--warn-soft);
}
/* Real-progress bar (§1.3):<div class="a-progress" style="--p:.42"><i></i></div>.
Only for true progress values — never fake progress. */
.a-progress {
position: relative;
height: 4px;
border-radius: var(--r-pill);
background: var(--bg-4);
overflow: hidden;
}
.a-progress > i {
position: absolute;
inset: 0;
background: var(--run-grad);
box-shadow: 0 0 8px var(--run-soft);
transform-origin: left;
transform: scaleX(var(--p, 0));
transition: transform var(--d-progress) linear;
}
/* Film sprocket divider — only between the take feed and the composer (§1.7). */
.a-sprocket {
height: 6px;
background-image: var(--sprocket);
background-size: 18px 6px;
background-repeat: repeat-x;
background-position: center;
opacity: 0.55;
}
:root[data-reduce='1'] .a-sprocket {
opacity: 0.3;
}
/* Projection stage — only behind media in the inspector (§1.7). */
.a-stage {
position: relative;
border-radius: var(--r-lg);
overflow: hidden;
background: var(--stage-bg);
box-shadow: 0 0 0 1px var(--line);
}
/* Floating surface for pop / drawer / confirm / toast / lightbox (§四.3). */
.a-float {
background: var(--float-bg);
border-radius: var(--r-lg);
box-shadow: var(--shadow-float);
}
/* Card / panel surface (§1.5:卡片不投影,用底色差分层). */
.a-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px 22px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: var(--sp-7);
box-sizing: border-box;
}
.a-panel-title {
margin: 0;
font-size: 14.5px;
font-size: var(--fs-section);
font-weight: 600;
color: var(--text);
}
.a-panel-lead {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.6;
margin: var(--sp-1) 0 0;
font-size: var(--fs-aux);
line-height: var(--lh-body);
color: var(--text-3);
}
@@ -243,39 +478,47 @@ code,
.field-textarea,
.field-select {
box-sizing: border-box;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 7px;
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: var(--r-md);
color: var(--text);
font-family: inherit;
font-size: 12.5px;
transition: border-color 150ms cubic-bezier(0.16, 1, 0.3, 1);
font-size: var(--fs-body-sm);
transition: border-color var(--d-state) ease;
}
.a-select {
padding: 7px 9px;
padding: var(--sp-2) var(--sp-4);
cursor: pointer;
}
.a-input,
.field-input,
.field-select {
width: 100%;
padding: 8px 11px;
padding: var(--sp-3) var(--sp-5);
}
.a-textarea,
.field-textarea {
width: 100%;
padding: 9px 11px;
padding: var(--sp-4) var(--sp-5);
resize: vertical;
min-height: 96px;
line-height: 1.6;
line-height: var(--lh-body);
}
.a-select:hover,
.a-input:hover,
.a-textarea:hover,
.field-input:hover,
.field-textarea:hover,
.field-select:hover {
border-color: var(--border-hover);
.a-input::placeholder,
.a-textarea::placeholder,
.field-input::placeholder,
.field-textarea::placeholder {
color: var(--text-3);
}
@media (hover: hover) and (pointer: fine) {
.a-select:hover,
.a-input:hover,
.a-textarea:hover,
.field-input:hover,
.field-textarea:hover,
.field-select:hover {
border-color: var(--line-2);
}
}
.a-select:focus,
.a-input:focus,
@@ -284,12 +527,12 @@ code,
.field-textarea:focus,
.field-select:focus {
outline: none;
border-color: var(--accent-focus);
border-color: var(--accent-line);
}
.a-input.invalid,
.a-textarea.invalid,
.field-input.invalid {
border-color: var(--error);
border-color: var(--err);
}
.a-select option.unavailable {
color: var(--text-3);
@@ -297,30 +540,30 @@ code,
.field {
display: block;
margin-bottom: 14px;
margin-bottom: var(--sp-5);
}
.field-label {
display: block;
font-size: 12px;
font-size: var(--fs-aux);
font-weight: 500;
color: var(--text-2);
margin-bottom: 6px;
margin-bottom: var(--sp-2);
}
.field-hint {
margin: 6px 0 0;
font-size: 11.5px;
margin: var(--sp-2) 0 0;
font-size: var(--fs-aux);
color: var(--text-3);
}
.field-error {
margin: 6px 0 0;
font-size: 11.5px;
color: var(--error);
margin: var(--sp-2) 0 0;
font-size: var(--fs-aux);
color: var(--err);
}
/* Dark theme scrollbars (Firefox + Chromium) */
* {
scrollbar-width: thin;
scrollbar-color: var(--surface-3) transparent;
scrollbar-color: var(--bg-4) transparent;
}
*::-webkit-scrollbar {
@@ -333,8 +576,8 @@ code,
}
*::-webkit-scrollbar-thumb {
background: var(--surface-3);
border-radius: 999px;
background: var(--bg-4);
border-radius: var(--r-pill);
border: 2px solid transparent;
background-clip: padding-box;
}
@@ -349,17 +592,19 @@ code,
background: transparent;
}
/* Global keyboard focus fallback: every natively focusable element gets a ring,
even before the ui/ primitives are adopted. Component-scoped :focus-visible
rules (e.g. .field-input) override this via higher specificity. */
/* Global keyboard focus (§四.8): every natively focusable element gets the
gold ring. Component-scoped :focus-visible rules override this via higher
specificity. */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--r-sm);
}
/* Global reduced-motion baseline. Components that need a styled static fallback
(Skeleton shimmer, ProgressBar indeterminate, StatusChip pulse) also handle
this in their own scoped styles. */
/* Reduced-motion baseline for components that still carry their own
durations. Components that need a styled static fallback (Skeleton,
ProgressBar indeterminate, StatusChip pulse) also handle this in their
scoped styles. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
@@ -369,3 +614,10 @@ code,
transition-duration: 0.01ms !important;
}
}
:root[data-reduce='1'] *,
:root[data-reduce='1'] *::before,
:root[data-reduce='1'] *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
@@ -1654,7 +1654,7 @@
transition: border-color 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
.card.failed {
border-color: rgba(229, 72, 77, 0.35);
border-color: color-mix(in srgb, var(--err) 35%, transparent);
}
.card-row {
display: flex;
@@ -1671,7 +1671,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent);
color: var(--text-2);
flex-shrink: 0;
}
.card-name {
@@ -1688,13 +1688,10 @@
font-weight: 600;
}
.tag-local,
.tag-lan {
color: var(--info);
background: var(--info-dim);
}
.tag-lan,
.tag-cloud {
color: var(--cloud);
background: var(--cloud-dim);
color: var(--text-2);
background: var(--bg-4);
}
.tag-auth {
color: var(--text-2);
@@ -1743,8 +1740,8 @@
background: var(--text-3);
}
.dot.amber {
background: var(--accent);
box-shadow: 0 0 10px var(--accent-glow);
background: var(--run);
box-shadow: 0 0 10px var(--run-soft);
}
.dot.breathing {
animation: breathe 2s ease-in-out infinite;
@@ -1769,7 +1766,7 @@
color: var(--error);
}
.status-text.amber {
color: var(--accent);
color: var(--run);
}
.status-text.gray {
color: var(--text-3);
@@ -1959,7 +1956,7 @@
border-color: var(--error);
}
.wf-conn-check.warn {
border-color: var(--accent);
border-color: var(--warn);
}
.wf-conn-head {
display: flex;
@@ -1977,7 +1974,7 @@
color: var(--error);
}
.wf-conn-check.warn .wf-conn-head :global(.icon) {
color: var(--accent);
color: var(--warn);
}
.wf-conn-title {
font-size: 12px;
@@ -2067,7 +2064,7 @@
cursor: help;
}
.wf-meta-item.recheck.warn .wf-recheck-text {
color: var(--accent);
color: var(--warn);
}
.wf-evidence {
margin: 0 0 12px;
@@ -2198,15 +2195,15 @@
color: var(--error);
}
.tag-usage {
color: var(--accent);
background: var(--accent-dim);
color: var(--text-2);
background: var(--bg-4);
}
.tag-disabled {
color: var(--text-3);
background: var(--surface-3);
}
.wf-pending-note {
color: var(--accent);
color: var(--warn);
}
.wf-row.pending .wf-name {
color: var(--text-3);
@@ -2252,7 +2249,7 @@
line-height: 1.5;
}
.wf-json.invalid {
border-color: rgba(229, 72, 77, 0.65);
border-color: var(--err-line);
}
.file-btn {
display: inline-flex;
@@ -2271,7 +2268,7 @@
}
.file-btn:hover {
color: var(--text);
border-color: rgba(255, 255, 255, 0.24);
border-color: var(--line-2);
}
.file-btn input {
display: none;
@@ -2302,13 +2299,10 @@
justify-content: center;
flex-shrink: 0;
}
.wf-kind.image {
color: var(--info);
background: var(--info-dim);
}
.wf-kind.image,
.wf-kind.video {
color: var(--accent);
background: var(--accent-dim);
color: var(--text-2);
background: var(--bg-4);
}
.wf-name {
flex: 1;
@@ -2460,14 +2454,14 @@
}
.f-input:focus {
outline: none;
border-color: rgba(232, 163, 61, 0.6);
border-color: var(--accent-line);
}
.f-input:disabled {
color: var(--text-3);
cursor: not-allowed;
}
.f-input.invalid {
border-color: rgba(229, 72, 77, 0.65);
border-color: var(--err-line);
}
.f-hint {
display: block;
@@ -2610,7 +2604,7 @@
position: fixed;
inset: 0;
z-index: 790;
background: rgba(5, 5, 8, 0.5);
background: var(--overlay);
}
.wf-import-grid {
grid-template-columns: 1fr;
+12 -15
View File
@@ -929,7 +929,7 @@
transition: border-color 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
.card.failed {
border-color: rgba(229, 72, 77, 0.35);
border-color: color-mix(in srgb, var(--err) 35%, transparent);
}
.card-row {
display: flex;
@@ -946,7 +946,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent);
color: var(--text-2);
flex-shrink: 0;
}
.card-name {
@@ -963,13 +963,10 @@
font-weight: 600;
}
.tag-local,
.tag-lan {
color: var(--info);
background: var(--info-dim);
}
.tag-lan,
.tag-cloud {
color: var(--cloud);
background: var(--cloud-dim);
color: var(--text-2);
background: var(--bg-4);
}
.tag-default {
color: var(--accent);
@@ -1010,8 +1007,8 @@
background: var(--text-3);
}
.dot.amber {
background: var(--accent);
box-shadow: 0 0 10px var(--accent-glow);
background: var(--run);
box-shadow: 0 0 10px var(--run-soft);
}
.dot.breathing {
animation: breathe 2s ease-in-out infinite;
@@ -1036,7 +1033,7 @@
color: var(--error);
}
.status-text.amber {
color: var(--accent);
color: var(--run);
}
.status-text.gray {
color: var(--text-3);
@@ -1179,7 +1176,7 @@
color: var(--text);
}
.avatar.dark {
color: #16130a;
color: var(--accent-ink);
}
.agent-name {
font-size: 12.5px;
@@ -1282,14 +1279,14 @@
}
.f-input:focus {
outline: none;
border-color: rgba(232, 163, 61, 0.6);
border-color: var(--accent-line);
}
.f-input:disabled {
color: var(--text-3);
cursor: not-allowed;
}
.f-input.invalid {
border-color: rgba(229, 72, 77, 0.65);
border-color: var(--err-line);
}
.f-hint {
display: block;
@@ -1433,7 +1430,7 @@
position: fixed;
inset: 0;
z-index: 790;
background: rgba(5, 5, 8, 0.5);
background: var(--overlay);
}
.agents-grid {
grid-template-columns: repeat(3, 1fr);
@@ -669,7 +669,7 @@
color: var(--text-2);
}
.note.warn :global(.icon) {
color: var(--accent);
color: var(--warn);
flex-shrink: 0;
margin-top: 2px;
}
+5 -8
View File
@@ -312,7 +312,7 @@
min-height: 56px;
}
.trow.failed {
border-color: rgba(229, 72, 77, 0.35);
border-color: color-mix(in srgb, var(--err) 35%, transparent);
}
.tkind {
@@ -324,13 +324,10 @@
justify-content: center;
flex-shrink: 0;
}
.tkind.image {
color: var(--info);
background: var(--info-dim);
}
.tkind.image,
.tkind.video {
color: var(--accent);
background: var(--accent-dim);
color: var(--text-2);
background: var(--bg-4);
}
.tthumb {
@@ -412,7 +409,7 @@
color: var(--success);
}
.tst.run {
color: var(--accent);
color: var(--run);
}
.tst.queue {
color: var(--text-3);
@@ -670,10 +670,13 @@
color: var(--text-2);
background: var(--surface-3);
}
.st-running,
.st-running {
color: var(--run);
background: var(--run-soft);
}
.st-draft {
color: var(--accent);
background: var(--accent-dim);
color: var(--warn);
background: var(--warn-soft);
}
.st-confirmed {
color: var(--success);
@@ -684,8 +687,8 @@
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: var(--info);
background: var(--info-dim);
color: var(--warn);
background: var(--warn-soft);
}
.error-banner {
@@ -695,7 +698,7 @@
padding: 10px 14px;
border-radius: 8px;
background: var(--error-dim);
border: 1px solid rgba(229, 72, 77, 0.35);
border: 1px solid color-mix(in srgb, var(--err) 35%, transparent);
color: var(--error);
font-size: 12.5px;
line-height: 1.6;
@@ -706,9 +709,9 @@
gap: 8px;
padding: 10px 14px;
border-radius: 8px;
background: var(--accent-dim);
border: 1px solid rgba(232, 163, 61, 0.3);
color: var(--accent);
background: var(--warn-soft);
border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent);
color: var(--warn);
font-size: 12.5px;
}
.running-banner {
@@ -766,7 +769,7 @@
justify-content: center;
font-size: 16px;
font-weight: 700;
color: #edeef0;
color: var(--text);
flex-shrink: 0;
}
.agent-copy {
@@ -833,13 +836,10 @@
justify-content: center;
flex-shrink: 0;
}
.kind-image {
background: var(--info-dim);
color: var(--info);
}
.kind-image,
.kind-video {
background: var(--cloud-dim);
color: var(--cloud);
background: var(--bg-4);
color: var(--text-2);
}
.job-body {
display: flex;
@@ -872,7 +872,7 @@
color: var(--success);
}
.job-state.st-running {
color: var(--accent);
color: var(--run);
}
.job-state.st-pending {
color: var(--text-2);
@@ -904,9 +904,11 @@
.flow-dot.st-confirmed {
background: var(--success);
}
.flow-dot.st-running,
.flow-dot.st-running {
background: var(--run);
}
.flow-dot.st-awaiting_confirm {
background: var(--accent);
background: var(--warn);
}
.flow-name {
font-size: 12px;
@@ -953,7 +955,7 @@
}
.wb-input:focus {
outline: none;
border-color: rgba(232, 163, 61, 0.6);
border-color: var(--accent-line);
}
.wb-input::placeholder {
color: var(--text-3);
@@ -990,7 +992,7 @@
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: rgba(255, 255, 255, 0.24);
border-color: var(--line-2);
}
.btn.sm {
padding: 6px 11px;
@@ -1024,7 +1026,7 @@
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
background: var(--run);
animation: wb-page-breath 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
.breath.big {
+16 -10
View File
@@ -1,20 +1,26 @@
// Colors and radii resolve to the design tokens in src/routes/app.css so the
// Tailwind palette can never drift from the single source of truth.
module.exports = {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
colors: {
primary: '#0a0a0c',
surface: '#141418',
elevated: '#1e1e24',
border: '#2a2a32',
muted: '#71717a',
accent: '#8b5cf6',
'accent-hover': '#7c3aed',
primary: 'var(--bg)',
surface: 'var(--bg-2)',
elevated: 'var(--bg-3)',
border: 'var(--line)',
muted: 'var(--text-3)',
accent: 'var(--accent)',
'accent-hover': 'var(--accent-hi)',
ok: 'var(--ok)',
run: 'var(--run)',
err: 'var(--err)',
warn: 'var(--warn)',
},
borderRadius: {
sm: '6px',
md: '10px',
lg: '16px',
sm: 'var(--r-sm)',
md: 'var(--r-md)',
lg: 'var(--r-lg)',
},
},
},