Compare commits

...
Author SHA1 Message Date
8b03291327 HEL-652: B 批导航壳层——60px 图标轨、顶栏与手机底栏
- 216px 文字侧栏改为 60px 图标轨:金色渐变 Logo、当前页金色指示条滑动、350ms 延迟悬停提示,底部连接/模型/设置/语言
- 顶栏:标题+副标题、项目范围胶囊(全部项目列表/最近项目)、Ctrl/⌘K 搜索、减少动态开关、任务托盘胶囊、本次连接胶囊(名称+延迟)
- ≤760px:隐藏图标轨,底部 项目/创作/生成/更多 四个标签,更多为底部抽屉(其余页面、搜索、减少动态、语言);删除「手机端暂不适配」拦截
- 新增 ShellPop(Esc/↑↓/Tab 循环/外部点击关闭、焦点归还)与 CommandPalette(复用 Modal)
- 减少动态:跟随系统设置,另可手动开启并持久化(data-reduce)
- .a-darkroom 背景接入 --page-glow;新增壳层尺寸 token 与 .a-kbd/.a-menu-* 公共类
- 生图/生视频顶栏去掉与全局胶囊重复的连接状态(参数栏仍显示所选连接与队列)
- 路由 URL 不变(/projects 仍重定向到项目总览 /)

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 09:26:36 +08:00
总工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
43 changed files with 2426 additions and 933 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,10 +141,12 @@
animation: none;
}
}
@media (max-width: 640px) {
:global(:root[data-reduce='1']) .toast {
animation: none;
}
@media (max-width: 760px) {
.toast-host {
top: auto;
bottom: 20px;
bottom: calc(var(--tabbar-h) + var(--sp-5) + env(safe-area-inset-bottom));
right: 16px;
left: 16px;
max-width: none;
@@ -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;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,218 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { projects } from '$lib/api';
import { t } from '$lib/i18n.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import type { IconName } from '$lib/components/ui/icons';
import { NAV } from './nav';
/** Ctrl/⌘K 搜索:跳到任一页面或项目(↑↓ 选择,Enter 打开,Esc 关闭). */
let { open = $bindable(false) }: { open?: boolean } = $props();
interface Entry {
key: string;
group: 'pages' | 'projects';
label: string;
icon: IconName;
href: string;
}
const listId = `palette-${Math.random().toString(36).slice(2, 9)}`;
let query = $state('');
let activeIndex = $state(0);
let list = $state<HTMLDivElement | null>(null);
const projectsQuery = createQuery(
toStore(() => ({
queryKey: ['projects'],
queryFn: projects.list,
enabled: open,
staleTime: 30_000,
})),
);
const entries = $derived.by<Entry[]>(() => {
const pages: Entry[] = NAV.map((item) => ({
key: `page-${item.id}`,
group: 'pages',
label: t(item.labelKey),
icon: item.icon,
href: item.href,
}));
const projectEntries: Entry[] = ($projectsQuery.data ?? []).map((p) => ({
key: `project-${p.id}`,
group: 'projects',
label: p.title,
icon: 'story',
href: `/workbench/${p.id}`,
}));
const q = query.trim().toLowerCase();
const all = [...pages, ...projectEntries];
return q ? all.filter((e) => e.label.toLowerCase().includes(q)) : all;
});
$effect(() => {
void query;
activeIndex = 0;
});
$effect(() => {
if (!open) query = '';
});
function optionId(index: number) {
return `${listId}-${index}`;
}
function go(entry: Entry) {
open = false;
void goto(entry.href);
}
function move(step: number) {
if (entries.length === 0) return;
activeIndex = (activeIndex + step + entries.length) % entries.length;
list?.querySelector(`#${optionId(activeIndex)}`)?.scrollIntoView({ block: 'nearest' });
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault();
move(1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
move(-1);
} else if (event.key === 'Enter') {
const entry = entries[activeIndex];
if (entry) {
event.preventDefault();
go(entry);
}
}
}
function focusOnMount(node: HTMLInputElement) {
requestAnimationFrame(() => node.focus());
}
</script>
<Modal bind:open title={t('shell.searchTitle')}>
<div class="palette">
<div class="palette-search">
<Icon name="search" size={16} />
<input
use:focusOnMount
class="palette-input"
type="text"
bind:value={query}
placeholder={t('shell.searchPlaceholder')}
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={entries.length ? optionId(activeIndex) : undefined}
aria-label={t('shell.searchTitle')}
onkeydown={handleKeydown}
/>
</div>
<div class="palette-list" id={listId} role="listbox" bind:this={list} aria-label={t('shell.searchTitle')}>
{#if entries.length === 0}
<p class="palette-empty">{t('shell.searchEmpty', { q: query.trim() })}</p>
{/if}
{#each entries as entry, i (entry.key)}
{#if i === 0 || entries[i - 1].group !== entry.group}
<div class="a-menu-group" role="presentation">
{entry.group === 'pages' ? t('shell.searchGroupPages') : t('shell.searchGroupProjects')}
</div>
{/if}
<div
id={optionId(i)}
class="a-menu-item"
class:active={i === activeIndex}
role="option"
tabindex="-1"
aria-selected={i === activeIndex}
onpointermove={() => (activeIndex = i)}
onmousedown={(e) => e.preventDefault()}
onclick={() => go(entry)}
onkeydown={handleKeydown}
>
<Icon name={entry.icon} size={15} />
<span class="a-menu-label">{entry.label}</span>
<span class="a-menu-meta mono">{entry.href}</span>
</div>
{/each}
</div>
<p class="palette-hint">
<span class="a-kbd">↑↓</span>{t('shell.searchHintMove')}
<span class="a-kbd">Enter</span>{t('shell.searchHintOpen')}
<span class="a-kbd">Esc</span>{t('shell.searchHintClose')}
</p>
</div>
</Modal>
<style>
.palette {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.palette-search {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 0 var(--sp-5);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-3);
color: var(--text-3);
transition: border-color var(--d-state) ease;
}
.palette-search:focus-within {
border-color: var(--accent-line);
}
.palette-input {
flex: 1;
min-width: 0;
height: 40px;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font-family: inherit;
font-size: var(--fs-section);
}
.palette-input::placeholder {
color: var(--text-3);
}
.palette-list {
max-height: min(360px, 50vh);
overflow-y: auto;
}
.palette-empty {
margin: 0;
padding: var(--sp-6) var(--sp-4);
font-size: var(--fs-aux);
color: var(--text-3);
text-align: center;
}
.palette-hint {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0;
font-size: var(--fs-mini);
color: var(--text-3);
}
.palette-hint .a-kbd:not(:first-child) {
margin-left: var(--sp-3);
}
@media (hover: none) {
.palette-hint {
display: none;
}
}
</style>
@@ -0,0 +1,127 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { projects } from '$lib/api';
import { parseDbTs } from '$lib/dbtime';
import { t } from '$lib/i18n.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import ShellPop from './ShellPop.svelte';
/**
* 顶栏范围胶囊 — the project picker that replaced the old sidebar project
* entry: shows the project the page belongs to (or 全部项目) and lists
* recent projects, each opening its five-stage workbench.
*/
interface Props {
projectId: number | null;
onProjectsList: boolean;
}
let { projectId, onProjectsList }: Props = $props();
const RECENT_LIMIT = 8;
let open = $state(false);
const projectsQuery = createQuery(
toStore(() => ({
queryKey: ['projects'],
queryFn: projects.list,
enabled: open || projectId != null,
staleTime: 30_000,
})),
);
const all = $derived($projectsQuery.data ?? []);
const current = $derived(projectId == null ? null : (all.find((p) => p.id === projectId) ?? null));
const recent = $derived(
[...all]
.sort((a, b) => (parseDbTs(b.updated_at) || 0) - (parseDbTs(a.updated_at) || 0))
.slice(0, RECENT_LIMIT),
);
</script>
<div class="scope">
<button
type="button"
class="status-chip scope-btn"
aria-haspopup="dialog"
aria-expanded={open}
title={t('shell.scopeHint')}
onclick={() => (open = !open)}
>
<span class="scope-label">{current?.title ?? t('shell.scopeAll')}</span>
<Icon name="chevron-down" size={12} />
</button>
<ShellPop bind:open label={t('shell.scopeHint')} width="300px">
<a
class="a-menu-item"
href="/"
aria-current={onProjectsList ? 'page' : undefined}
onclick={() => (open = false)}
>
<Icon name="folder" size={15} />
<span class="a-menu-label">{t('shell.scopeAllList')}</span>
{#if all.length > 0}<span class="a-menu-meta mono">{all.length}</span>{/if}
</a>
<div class="a-menu-group">{t('shell.scopeRecent')}</div>
{#if $projectsQuery.isPending}
<p class="scope-note">{t('common.loading')}…</p>
{:else if $projectsQuery.isError}
<p class="scope-note err">{t('shell.scopeLoadFailed')}</p>
{:else if recent.length === 0}
<a class="a-menu-item" href="/" onclick={() => (open = false)}>
<Icon name="plus" size={15} />
<span class="a-menu-label">{t('shell.scopeEmpty')}</span>
</a>
{:else}
{#each recent as project (project.id)}
<a
class="a-menu-item"
href="/workbench/{project.id}"
aria-current={project.id === projectId ? 'true' : undefined}
onclick={() => (open = false)}
>
<Icon name="story" size={15} />
<span class="a-menu-label">{project.title}</span>
{#if project.stats}
<span class="a-menu-meta">
{t('shell.scopeStage', { n: project.stats.stage_confirmed })}
</span>
{/if}
</a>
{/each}
{/if}
</ShellPop>
</div>
<style>
.scope {
position: relative;
display: flex;
flex-shrink: 0;
}
.scope-btn {
max-width: 220px;
gap: var(--sp-1);
}
.scope-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.scope-note {
margin: 0;
padding: var(--sp-3) var(--sp-4);
font-size: var(--fs-aux);
color: var(--text-3);
}
.scope-note.err {
color: var(--err);
}
@media (max-width: 760px) {
.scope-btn {
max-width: 132px;
}
}
</style>
@@ -0,0 +1,164 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { workflows, type Job } from '$lib/api';
import { parseDbTs } from '$lib/dbtime';
import { t } from '$lib/i18n.svelte';
import { jobSourceLabel } from '$lib/jobLabels';
import { jobStatusInfo, type JobStatusTone } from '$lib/jobStatus';
import ShellPop from './ShellPop.svelte';
/**
* 顶栏队列胶囊 + 任务托盘 (§二). The dot is cyan only while something is
* actually generating — queued work stays gray, as on the task page.
*/
let { jobs }: { jobs: Job[] } = $props();
const TRAY_LIMIT = 6;
const TAG_CLASS: Record<JobStatusTone, string> = { ok: 'ok', run: 'run', queue: '', fail: 'err' };
let open = $state(false);
let capsule = $state<HTMLButtonElement | null>(null);
const workflowsQuery = createQuery(
toStore(() => ({
queryKey: ['workflows'],
queryFn: workflows.list,
enabled: open,
staleTime: 60_000,
})),
);
const active = $derived(jobs.filter((j) => j.status === 'pending' || j.status === 'running'));
const running = $derived(active.filter((j) => jobStatusInfo(j).tone === 'run').length);
const queued = $derived(active.length - running);
const recent = $derived([...jobs].sort((a, b) => b.id - a.id).slice(0, TRAY_LIMIT));
const label = $derived.by(() => {
const parts: string[] = [];
if (running > 0) parts.push(t('shell.queueRunning', { n: running }));
if (queued > 0) parts.push(t('shell.queueQueued', { n: queued }));
return parts.length ? parts.join(' · ') : t('shell.queueIdle');
});
// 任务数变化时胶囊轻弹一次(320ms,只播一次).
let lastCount: number | null = null;
$effect(() => {
const count = active.length;
if (lastCount !== null && count !== lastCount && capsule) {
capsule.classList.remove('bump');
void capsule.offsetWidth;
capsule.classList.add('bump');
}
lastCount = count;
});
function workflowName(job: Job): string {
return (
($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)?.name ??
t('studio.jobName', { id: job.id })
);
}
function timeOf(job: Job): string {
const ts = parseDbTs(job.created_at);
if (!Number.isFinite(ts)) return '—';
const d = new Date(ts);
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
</script>
<div class="tray">
<button
bind:this={capsule}
type="button"
class="status-chip queue-btn"
aria-haspopup="dialog"
aria-expanded={open}
aria-label={label}
title={label}
onclick={() => (open = !open)}
onanimationend={() => capsule?.classList.remove('bump')}
>
<span class="status-chip-dot {running > 0 ? 'run' : 'idle'}" aria-hidden="true"></span>
<span class="queue-label">{label}</span>
<span class="queue-count mono">{active.length}</span>
</button>
<ShellPop bind:open label={t('shell.trayTitle')} align="end" width="340px">
<div class="a-menu-group">{t('shell.trayRecent')}</div>
{#if recent.length === 0}
<p class="tray-note">{t('shell.trayEmpty')}</p>
{:else}
{#each recent as job (job.id)}
{@const info = jobStatusInfo(job)}
<a class="a-menu-item tray-row" href="/tasks" title={info.detail} onclick={() => (open = false)}>
<span class="tray-copy">
<span class="a-menu-label">{workflowName(job)}</span>
<span class="tray-meta">{jobSourceLabel(job)} · <span class="mono">{timeOf(job)}</span></span>
</span>
<span class="a-tag {TAG_CLASS[info.tone]}">{info.label}</span>
</a>
{/each}
{/if}
<a class="a-menu-item tray-all" href="/tasks" onclick={() => (open = false)}>
<span class="a-menu-label">{t('shell.trayAll')}</span>
</a>
</ShellPop>
</div>
<style>
.tray {
position: relative;
flex-shrink: 0;
}
.queue-btn:global(.bump) {
animation: queue-bump 320ms var(--ease-out);
}
@keyframes queue-bump {
40% {
transform: scale(1.06);
}
}
.queue-count {
display: none;
}
@media (max-width: 1179px) {
.queue-label {
display: none;
}
.queue-count {
display: inline;
}
}
.tray-row {
align-items: center;
}
.tray-copy {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
line-height: 1.4;
}
.tray-meta {
font-size: var(--fs-mini);
color: var(--text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tray-note {
margin: 0;
padding: var(--sp-3) var(--sp-4) var(--sp-5);
font-size: var(--fs-aux);
color: var(--text-3);
}
.tray-all {
margin-top: var(--sp-1);
border-top: 1px solid var(--line);
border-radius: 0 0 var(--r-md) var(--r-md);
justify-content: center;
}
</style>
@@ -0,0 +1,184 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cubicOut, quintOut } from 'svelte/easing';
/**
* Anchored pop for the shell (§四.3 pop): opens 180ms from its trigger,
* closes on Esc / outside press, Tab cycles inside, ↑↓ move between rows.
* At ≤760px it becomes a bottom sheet. Render it next to its trigger
* inside a `position: relative` wrapper — that wrapper counts as inside.
*/
interface Props {
open?: boolean;
label: string;
align?: 'start' | 'end';
width?: string;
onclose?: () => void;
children: Snippet;
}
let {
open = $bindable(false),
label,
align = 'start',
width = '300px',
onclose,
children,
}: Props = $props();
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const SHEET_QUERY = '(max-width: 760px)';
let panel = $state<HTMLDivElement | null>(null);
let scrim = $state<HTMLDivElement | null>(null);
function focusables(): HTMLElement[] {
if (!panel) return [];
return Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.getClientRects().length > 0,
);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
open = false;
return;
}
const list = focusables();
if (list.length === 0) return;
const current = document.activeElement as HTMLElement | null;
const index = current ? list.indexOf(current) : -1;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
if (current?.tagName === 'SELECT') return;
event.preventDefault();
const step = event.key === 'ArrowDown' ? 1 : -1;
const next = index < 0 ? (step > 0 ? 0 : list.length - 1) : index + step;
list[(next + list.length) % list.length].focus();
} else if (event.key === 'Tab') {
if (event.shiftKey && index <= 0) {
event.preventDefault();
list[list.length - 1].focus();
} else if (!event.shiftKey && (index < 0 || index === list.length - 1)) {
event.preventDefault();
list[0].focus();
}
}
}
function handlePointerDown(event: PointerEvent) {
const host = panel?.parentElement;
const target = event.target as Node;
if (!host) return;
if (!host.contains(target) || target === scrim) open = false;
}
$effect(() => {
if (!open) return;
const invoker = document.activeElement as HTMLElement | null;
document.addEventListener('keydown', handleKeydown, true);
document.addEventListener('pointerdown', handlePointerDown, true);
requestAnimationFrame(() => focusables()[0]?.focus());
return () => {
document.removeEventListener('keydown', handleKeydown, true);
document.removeEventListener('pointerdown', handlePointerDown, true);
const focusWasInside =
document.activeElement === document.body || !!panel?.contains(document.activeElement);
if (focusWasInside && invoker?.isConnected) invoker.focus();
onclose?.();
};
});
function cssMs(name: string, fallback: number): number {
const raw = getComputedStyle(document.documentElement).getPropertyValue(name);
const ms = parseFloat(raw);
return Number.isFinite(ms) ? ms : fallback;
}
function popIn(_node: Element) {
const reduced = document.documentElement.dataset.reduce === '1';
const sheet = window.matchMedia(SHEET_QUERY).matches;
if (reduced) {
return { duration: cssMs('--d-state', 120), css: (t: number) => `opacity: ${t}` };
}
if (sheet) {
return {
duration: cssMs('--d-drawer', 280),
easing: quintOut,
css: (t: number) => `transform: translateY(${(1 - t) * 100}%)`,
};
}
return {
duration: cssMs('--d-pop', 180),
easing: cubicOut,
css: (t: number) => `opacity: ${t}; transform: scale(${0.96 + 0.04 * t})`,
};
}
function fadeScrim(_node: Element) {
return { duration: cssMs('--d-pop', 180), css: (t: number) => `opacity: ${t}` };
}
</script>
{#if open}
<div class="pop-scrim" bind:this={scrim} aria-hidden="true" transition:fadeScrim></div>
<div
bind:this={panel}
class="shell-pop a-float align-{align}"
style:--pop-w={width}
role="dialog"
aria-label={label}
transition:popIn
>
{@render children()}
</div>
{/if}
<style>
.shell-pop {
position: absolute;
top: calc(100% + var(--sp-2));
z-index: 60;
width: var(--pop-w);
max-width: calc(100vw - 2 * var(--sp-5));
max-height: min(520px, 70vh);
overflow-y: auto;
padding: var(--sp-2);
box-sizing: border-box;
transform-origin: top left;
}
.shell-pop.align-start {
left: 0;
}
.shell-pop.align-end {
right: 0;
transform-origin: top right;
}
.pop-scrim {
display: none;
}
@media (max-width: 760px) {
.pop-scrim {
display: block;
position: fixed;
inset: 0;
z-index: 59;
background: var(--overlay);
}
.shell-pop {
position: fixed;
top: auto;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
max-width: none;
max-height: 78vh;
border-radius: var(--r-lg) var(--r-lg) 0 0;
padding: var(--sp-3) var(--sp-3) calc(var(--sp-3) + env(safe-area-inset-bottom));
}
}
</style>
@@ -0,0 +1,70 @@
import type { IconName } from '$lib/components/ui/icons';
export type NavId =
| 'projects'
| 'workbench'
| 'image'
| 'video'
| 'tasks'
| 'agents'
| 'connections'
| 'models'
| 'settings';
export interface NavItem {
id: NavId;
labelKey: string;
icon: IconName;
href: string;
}
// Order is fixed by spec-v2 §3: 项目 / 创作工作台 / 生图 / 生视频 / 生成任务 /
// 智能体 / ComfyUI 连接 / 模型设置 / 设置. HEL-649 §二 puts the connection
// and settings entries at the foot of the 60px rail.
export const NAV_MAIN: NavItem[] = [
{ id: 'projects', labelKey: 'shell.navProjects', icon: 'home', href: '/' },
{ id: 'workbench', labelKey: 'shell.navWorkbench', icon: 'story', href: '/workbench' },
{ id: 'image', labelKey: 'shell.navImage', icon: 'image', href: '/image' },
{ id: 'video', labelKey: 'shell.navVideo', icon: 'film', href: '/video' },
{ id: 'tasks', labelKey: 'shell.navTasks', icon: 'queue', href: '/tasks' },
{ id: 'agents', labelKey: 'shell.navAgents', icon: 'sparkle', href: '/agents' },
];
export const NAV_FOOT: NavItem[] = [
{ id: 'connections', labelKey: 'shell.navConnections', icon: 'link', href: '/connections' },
{ id: 'models', labelKey: 'shell.navModels', icon: 'assets', href: '/models' },
{ id: 'settings', labelKey: 'shell.navSettings', icon: 'settings', href: '/settings' },
];
export const NAV: NavItem[] = [...NAV_MAIN, ...NAV_FOOT];
export interface TabItem {
id: 'projects' | 'workbench' | 'generate';
labelKey: string;
icon: IconName;
href: string;
/** Pages that light this tab up. */
covers: NavId[];
}
/** ≤760px bottom tab bar (§二): 项目 / 创作 / 生成 / 更多. */
export const TABS: TabItem[] = [
{ id: 'projects', labelKey: 'shell.tabProjects', icon: 'home', href: '/', covers: ['projects'] },
{ id: 'workbench', labelKey: 'shell.tabCreate', icon: 'story', href: '/workbench', covers: ['workbench'] },
{
id: 'generate',
labelKey: 'shell.tabGenerate',
icon: 'image',
href: '/image',
covers: ['image', 'video'],
},
];
/**
* Every page a tab does not link to directly lives in the 更多 sheet. 生视频
* is still its own route, so it is listed there too until the two
* generation pages merge.
*/
export const MORE_ITEMS: NavItem[] = NAV.filter(
(item) => !TABS.some((tab) => tab.href === item.href),
);
@@ -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;
@@ -1012,22 +1012,6 @@
title={kind === 'image' ? t('studio.image.title') : t('studio.video.title')}
subtitle={kind === 'image' ? t('studio.image.subtitle') : t('studio.video.subtitle')}
>
{#snippet actions()}
{#if endpointId}
<span class="status-chip">
<span
class="status-chip-dot"
class:ok={connRec?.ok === true}
class:fail={connRec?.ok === false}
class:idle={!connRec}
aria-hidden="true"
></span>
{queueCount > 0
? t('studio.chipBusy', { name: selectedEndpointName, n: queueCount })
: t('studio.chipIdle', { name: selectedEndpointName })}
</span>
{/if}
{/snippet}
<div class="studio-page">
<!-- ---------------- left: parameters ---------------- -->
<section class="param-col" aria-label={kind === 'image' ? t('studio.image.title') : t('studio.video.title')}>
@@ -1897,7 +1881,7 @@
color: var(--text-3);
}
.h3-hint p.warn {
color: var(--accent);
color: var(--warn);
}
.studio-page {
display: grid;
@@ -1990,10 +1974,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;
@@ -2139,13 +2123,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;
@@ -2168,9 +2149,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;
@@ -2238,7 +2219,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);
@@ -2571,7 +2552,7 @@
color: var(--success);
}
.st.run {
color: var(--accent);
color: var(--run);
}
.st.queue {
color: var(--text-3);
@@ -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;
+34 -6
View File
@@ -1260,14 +1260,44 @@ export const de: Dict = {
'shell.navConnections': 'ComfyUI-Verbindungen',
'shell.navModels': 'Modelle',
'shell.navSettings': 'Einstellungen',
'shell.wip': 'In Entwicklung',
'shell.wipHint': 'Diese Seite ist noch im Bau und folgt später',
'shell.comfy_ok': '{name} verbunden',
'shell.comfy_fail': '{name} nicht erreichbar',
'shell.comfy_testing': '{name} wird getestet…',
'shell.comfyUnset': 'ComfyUI · nicht konfiguriert',
'shell.comfyManage': 'ComfyUI-Adresse in den Einstellungen pflegen',
'shell.mobileNotice': 'Bitte öffne diese Werkbank in einem Desktop-Browser (mindestens 768px breit); Handys werden noch nicht unterstützt.',
'shell.scopeHint': 'Projekt wechseln',
'shell.scopeAll': 'Alle Projekte',
'shell.scopeAllList': 'Liste aller Projekte',
'shell.scopeRecent': 'Letzte Projekte',
'shell.scopeLoadFailed': 'Projekte konnten nicht geladen werden',
'shell.scopeEmpty': 'Noch keine Projekte – jetzt anlegen',
'shell.scopeStage': '{n}/5 bestätigt',
'shell.queueRunning': '{n} in Arbeit',
'shell.queueQueued': '{n} in der Warteschlange',
'shell.queueIdle': 'Warteschlange leer',
'shell.queueActive': '{n} aktiv',
'shell.trayTitle': 'Auftragsablage',
'shell.trayRecent': 'Letzte Aufträge · klicken für alle Aufträge',
'shell.trayEmpty': 'Noch keine Generierungsaufträge',
'shell.trayAll': 'Alle Aufträge anzeigen',
'shell.searchTitle': 'Seiten und Projekte suchen',
'shell.searchPlaceholder': 'Seiten, Projekte suchen',
'shell.searchShortcut': 'Suchen (Strg/⌘ + K)',
'shell.searchEmpty': 'Keine Seite und kein Projekt passt zu „{q}“',
'shell.searchGroupPages': 'Seiten',
'shell.searchGroupProjects': 'Projekte · Werkbank öffnen',
'shell.searchHintMove': 'auswählen',
'shell.searchHintOpen': 'öffnen',
'shell.searchHintClose': 'schließen',
'shell.reduceMotion': 'Weniger Bewegung',
'shell.reduceHint': 'Bewegungs-, Zoom- und Glüheffekte aus; nur kurze Überblendungen',
'shell.reduceSystem': 'Folgt der Systemeinstellung „Bewegung reduzieren“',
'shell.tabAria': 'Hauptnavigation (schmal)',
'shell.tabProjects': 'Projekte',
'shell.tabCreate': 'Erstellen',
'shell.tabGenerate': 'Generieren',
'shell.tabMore': 'Mehr',
'shell.morePages': 'Weitere Seiten',
'shell.moreTools': 'Werkzeuge',
'home.title': 'Projektübersicht',
'home.subtitle': 'Verwalte deine Projekte — starte mit einer Idee oder einem fertigen Drehbuch.',
'home.projectCount': '{n} Projekte · {m} in Arbeit',
@@ -1456,8 +1486,6 @@ export const de: Dict = {
'studio.genParams': 'Parameter',
'studio.modeTextToImage': 'Text zu Bild',
'studio.modeTextToVideo': 'Text zu Video',
'studio.chipIdle': '{name} · Warteschlange frei',
'studio.chipBusy': '{name} · {n} in Warteschlange',
'studio.video.resultsTitle': 'Vorschau',
'studio.image.historyTitle': 'Bildverlauf',
'studio.video.historyTitle': 'Videoverlauf',
+34 -6
View File
@@ -1262,14 +1262,44 @@ export const en = {
'shell.navConnections': 'ComfyUI Connections',
'shell.navModels': 'Models',
'shell.navSettings': 'Settings',
'shell.wip': 'In development',
'shell.wipHint': 'This page is under construction and will open later',
'shell.comfy_ok': '{name} connected',
'shell.comfy_fail': '{name} unreachable',
'shell.comfy_testing': 'Testing {name}…',
'shell.comfyUnset': 'ComfyUI · not configured',
'shell.comfyManage': 'Configure the ComfyUI address in Settings',
'shell.mobileNotice': 'Please open this workbench in a desktop browser (at least 768px wide). Phones are not supported yet.',
'shell.scopeHint': 'Switch project',
'shell.scopeAll': 'All projects',
'shell.scopeAllList': 'All projects list',
'shell.scopeRecent': 'Recent projects',
'shell.scopeLoadFailed': 'Could not load projects',
'shell.scopeEmpty': 'No projects yet — create one',
'shell.scopeStage': '{n}/5 confirmed',
'shell.queueRunning': '{n} generating',
'shell.queueQueued': '{n} queued',
'shell.queueIdle': 'Queue idle',
'shell.queueActive': '{n} active',
'shell.trayTitle': 'Job tray',
'shell.trayRecent': 'Recent jobs · click to see all jobs',
'shell.trayEmpty': 'No generation jobs yet',
'shell.trayAll': 'View all jobs',
'shell.searchTitle': 'Search pages and projects',
'shell.searchPlaceholder': 'Search pages, projects',
'shell.searchShortcut': 'Search (Ctrl/⌘ + K)',
'shell.searchEmpty': 'No page or project matches “{q}”',
'shell.searchGroupPages': 'Pages',
'shell.searchGroupProjects': 'Projects · open the workbench',
'shell.searchHintMove': 'select',
'shell.searchHintOpen': 'open',
'shell.searchHintClose': 'close',
'shell.reduceMotion': 'Reduce motion',
'shell.reduceHint': 'Turn off movement, scaling and glow animations; keep only short fades',
'shell.reduceSystem': 'Following the system “Reduce motion” setting',
'shell.tabAria': 'Primary (narrow screens)',
'shell.tabProjects': 'Projects',
'shell.tabCreate': 'Create',
'shell.tabGenerate': 'Generate',
'shell.tabMore': 'More',
'shell.morePages': 'More pages',
'shell.moreTools': 'Tools',
'home.title': 'Projects overview',
'home.subtitle': 'Manage your projects — start from an idea or a ready-made script.',
'home.projectCount': '{n} projects · {m} in progress',
@@ -1455,8 +1485,6 @@ export const en = {
'studio.genParams': 'Parameters',
'studio.modeTextToImage': 'Text to image',
'studio.modeTextToVideo': 'Text to video',
'studio.chipIdle': '{name} · queue idle',
'studio.chipBusy': '{name} · {n} in queue',
'studio.video.resultsTitle': 'Preview',
'studio.image.historyTitle': 'Image history',
'studio.video.historyTitle': 'Video history',
+34 -6
View File
@@ -1260,14 +1260,44 @@ export const es: Dict = {
'shell.navConnections': 'Conexiones ComfyUI',
'shell.navModels': 'Modelos',
'shell.navSettings': 'Ajustes',
'shell.wip': 'En desarrollo',
'shell.wipHint': 'Esta página está en construcción y llegará más adelante',
'shell.comfy_ok': '{name} conectado',
'shell.comfy_fail': '{name} sin conexión',
'shell.comfy_testing': 'Probando {name}…',
'shell.comfyUnset': 'ComfyUI · sin configurar',
'shell.comfyManage': 'Configura la dirección de ComfyUI en Ajustes',
'shell.mobileNotice': 'Abre este banco de trabajo en un navegador de escritorio (mínimo 768 px); los móviles aún no se admiten.',
'shell.scopeHint': 'Cambiar de proyecto',
'shell.scopeAll': 'Todos los proyectos',
'shell.scopeAllList': 'Lista de proyectos',
'shell.scopeRecent': 'Proyectos recientes',
'shell.scopeLoadFailed': 'No se pudieron cargar los proyectos',
'shell.scopeEmpty': 'Aún no hay proyectos: crea uno',
'shell.scopeStage': '{n}/5 confirmadas',
'shell.queueRunning': '{n} generando',
'shell.queueQueued': '{n} en cola',
'shell.queueIdle': 'Cola libre',
'shell.queueActive': '{n} activos',
'shell.trayTitle': 'Bandeja de tareas',
'shell.trayRecent': 'Tareas recientes · clic para ver todas',
'shell.trayEmpty': 'Aún no hay tareas de generación',
'shell.trayAll': 'Ver todas las tareas',
'shell.searchTitle': 'Buscar páginas y proyectos',
'shell.searchPlaceholder': 'Buscar páginas, proyectos',
'shell.searchShortcut': 'Buscar (Ctrl/⌘ + K)',
'shell.searchEmpty': 'Ninguna página ni proyecto coincide con «{q}»',
'shell.searchGroupPages': 'Páginas',
'shell.searchGroupProjects': 'Proyectos · abrir el banco de trabajo',
'shell.searchHintMove': 'elegir',
'shell.searchHintOpen': 'abrir',
'shell.searchHintClose': 'cerrar',
'shell.reduceMotion': 'Reducir movimiento',
'shell.reduceHint': 'Desactiva desplazamientos, escalados y brillos; solo quedan fundidos breves',
'shell.reduceSystem': 'Se sigue el ajuste del sistema «Reducir movimiento»',
'shell.tabAria': 'Navegación principal (estrecha)',
'shell.tabProjects': 'Proyectos',
'shell.tabCreate': 'Crear',
'shell.tabGenerate': 'Generar',
'shell.tabMore': 'Más',
'shell.morePages': 'Más páginas',
'shell.moreTools': 'Herramientas',
'home.title': 'Resumen de proyectos',
'home.subtitle': 'Gestiona tus proyectos: empieza con una idea o un guion terminado.',
'home.projectCount': '{n} proyectos · {m} en curso',
@@ -1456,8 +1486,6 @@ export const es: Dict = {
'studio.genParams': 'Parámetros',
'studio.modeTextToImage': 'Texto a imagen',
'studio.modeTextToVideo': 'Texto a vídeo',
'studio.chipIdle': '{name} · cola libre',
'studio.chipBusy': '{name} · {n} en cola',
'studio.video.resultsTitle': 'Vista previa',
'studio.image.historyTitle': 'Historial de imágenes',
'studio.video.historyTitle': 'Historial de vídeos',
+34 -6
View File
@@ -1260,14 +1260,44 @@ export const fr: Dict = {
'shell.navConnections': 'Connexions ComfyUI',
'shell.navModels': 'Modèles',
'shell.navSettings': 'Réglages',
'shell.wip': 'En développement',
'shell.wipHint': 'Cette page est en construction et arrivera plus tard',
'shell.comfy_ok': '{name} connecté',
'shell.comfy_fail': '{name} injoignable',
'shell.comfy_testing': 'Test de {name}…',
'shell.comfyUnset': 'ComfyUI · non configuré',
'shell.comfyManage': 'Configurez l\'adresse ComfyUI dans les Réglages',
'shell.mobileNotice': 'Ouvrez cet atelier dans un navigateur de bureau (largeur minimum 768 px) ; les téléphones ne sont pas encore pris en charge.',
'shell.scopeHint': 'Changer de projet',
'shell.scopeAll': 'Tous les projets',
'shell.scopeAllList': 'Liste des projets',
'shell.scopeRecent': 'Projets récents',
'shell.scopeLoadFailed': 'Impossible de charger les projets',
'shell.scopeEmpty': 'Aucun projet — en créer un',
'shell.scopeStage': '{n}/5 validées',
'shell.queueRunning': '{n} en cours',
'shell.queueQueued': '{n} en file',
'shell.queueIdle': 'File libre',
'shell.queueActive': '{n} actifs',
'shell.trayTitle': 'Plateau des tâches',
'shell.trayRecent': 'Tâches récentes · cliquer pour tout voir',
'shell.trayEmpty': 'Aucune tâche de génération',
'shell.trayAll': 'Voir toutes les tâches',
'shell.searchTitle': 'Rechercher pages et projets',
'shell.searchPlaceholder': 'Rechercher pages, projets',
'shell.searchShortcut': 'Rechercher (Ctrl/⌘ + K)',
'shell.searchEmpty': 'Aucune page ni projet ne correspond à « {q} »',
'shell.searchGroupPages': 'Pages',
'shell.searchGroupProjects': 'Projets · ouvrir l’atelier',
'shell.searchHintMove': 'choisir',
'shell.searchHintOpen': 'ouvrir',
'shell.searchHintClose': 'fermer',
'shell.reduceMotion': 'Réduire les animations',
'shell.reduceHint': 'Coupe déplacements, zooms et halos ; garde seulement de courts fondus',
'shell.reduceSystem': 'Réglage système « Réduire les animations » appliqué',
'shell.tabAria': 'Navigation principale (étroite)',
'shell.tabProjects': 'Projets',
'shell.tabCreate': 'Créer',
'shell.tabGenerate': 'Générer',
'shell.tabMore': 'Plus',
'shell.morePages': 'Autres pages',
'shell.moreTools': 'Outils',
'home.title': 'Aperçu des projets',
'home.subtitle': 'Gérez vos projets — commencez avec une idée ou un scénario prêt.',
'home.projectCount': '{n} projets · {m} en cours',
@@ -1456,8 +1486,6 @@ export const fr: Dict = {
'studio.genParams': 'Paramètres',
'studio.modeTextToImage': 'Texte vers image',
'studio.modeTextToVideo': 'Texte vers vidéo',
'studio.chipIdle': '{name} · file libre',
'studio.chipBusy': '{name} · {n} en file',
'studio.video.resultsTitle': 'Aperçu',
'studio.image.historyTitle': 'Historique images',
'studio.video.historyTitle': 'Historique vidéos',
+34 -6
View File
@@ -1260,14 +1260,44 @@ export const ja: Dict = {
'shell.navConnections': 'ComfyUI 接続',
'shell.navModels': 'モデル設定',
'shell.navSettings': '設定',
'shell.wip': '開発中',
'shell.wipHint': 'このページは準備中です。後ほど公開されます',
'shell.comfy_ok': '{name} 接続済み',
'shell.comfy_fail': '{name} 接続失敗',
'shell.comfy_testing': '{name} テスト中…',
'shell.comfyUnset': 'ComfyUI · 未設定',
'shell.comfyManage': '「設定」で ComfyUI のアドレスを設定できます',
'shell.mobileNotice': 'このワークベンチはデスクトップブラウザ(幅 768px 以上)で開いてください。スマートフォンには未対応です。',
'shell.scopeHint': 'プロジェクト切替',
'shell.scopeAll': 'すべてのプロジェクト',
'shell.scopeAllList': 'プロジェクト一覧',
'shell.scopeRecent': '最近のプロジェクト',
'shell.scopeLoadFailed': 'プロジェクトを読み込めませんでした',
'shell.scopeEmpty': 'プロジェクトはまだありません — 作成する',
'shell.scopeStage': '{n}/5 確定',
'shell.queueRunning': '{n} 件生成中',
'shell.queueQueued': '{n} 件待機',
'shell.queueIdle': 'キューは空です',
'shell.queueActive': '{n} 件進行中',
'shell.trayTitle': 'タスクトレイ',
'shell.trayRecent': '最近のタスク · クリックで全タスク',
'shell.trayEmpty': '生成タスクはまだありません',
'shell.trayAll': 'すべてのタスクを見る',
'shell.searchTitle': 'ページとプロジェクトを検索',
'shell.searchPlaceholder': 'ページ・プロジェクトを検索',
'shell.searchShortcut': '検索(Ctrl/⌘ + K)',
'shell.searchEmpty': '「{q}」に一致するページやプロジェクトはありません',
'shell.searchGroupPages': 'ページ',
'shell.searchGroupProjects': 'プロジェクト · ワークベンチを開く',
'shell.searchHintMove': '選択',
'shell.searchHintOpen': '開く',
'shell.searchHintClose': '閉じる',
'shell.reduceMotion': '動きを減らす',
'shell.reduceHint': '移動・拡大・発光アニメーションを止め、短いフェードのみ残します',
'shell.reduceSystem': 'システムの「視差効果を減らす」設定に従っています',
'shell.tabAria': 'メインナビ(狭い画面)',
'shell.tabProjects': 'プロジェクト',
'shell.tabCreate': '創作',
'shell.tabGenerate': '生成',
'shell.tabMore': 'その他',
'shell.morePages': 'その他のページ',
'shell.moreTools': 'ツール',
'home.title': 'プロジェクト概要',
'home.subtitle': '創作プロジェクトを管理 — アイデアでも完成台本でも始められます。',
'home.projectCount': 'プロジェクト {n} 件 · 進行中 {m} 件',
@@ -1456,8 +1486,6 @@ export const ja: Dict = {
'studio.genParams': '生成パラメータ',
'studio.modeTextToImage': 'テキストから画像',
'studio.modeTextToVideo': 'テキストから動画',
'studio.chipIdle': '{name} · キュー空き',
'studio.chipBusy': '{name} · キュー {n} 件',
'studio.video.resultsTitle': 'プレビュー',
'studio.image.historyTitle': '画像生成履歴',
'studio.video.historyTitle': '動画生成履歴',
+34 -6
View File
@@ -1260,14 +1260,44 @@ export const ko: Dict = {
'shell.navConnections': 'ComfyUI 연결',
'shell.navModels': '모델 설정',
'shell.navSettings': '설정',
'shell.wip': '개발 중',
'shell.wipHint': '이 페이지는 준비 중이며 곧 열립니다',
'shell.comfy_ok': '{name} 연결됨',
'shell.comfy_fail': '{name} 연결 실패',
'shell.comfy_testing': '{name} 테스트 중…',
'shell.comfyUnset': 'ComfyUI · 미설정',
'shell.comfyManage': '「설정」에서 ComfyUI 주소를 설정하세요',
'shell.mobileNotice': '이 워크벤치는 데스크톱 브라우저(폭 768px 이상)에서 열어 주세요. 휴대폰은 아직 지원되지 않습니다.',
'shell.scopeHint': '프로젝트 전환',
'shell.scopeAll': '전체 프로젝트',
'shell.scopeAllList': '프로젝트 목록',
'shell.scopeRecent': '최근 프로젝트',
'shell.scopeLoadFailed': '프로젝트를 불러오지 못했습니다',
'shell.scopeEmpty': '아직 프로젝트가 없습니다 — 만들기',
'shell.scopeStage': '{n}/5 확정',
'shell.queueRunning': '{n}개 생성 중',
'shell.queueQueued': '{n}개 대기',
'shell.queueIdle': '대기열 비어 있음',
'shell.queueActive': '{n}개 진행 중',
'shell.trayTitle': '작업 트레이',
'shell.trayRecent': '최근 작업 · 클릭하면 전체 작업',
'shell.trayEmpty': '아직 생성 작업이 없습니다',
'shell.trayAll': '전체 작업 보기',
'shell.searchTitle': '페이지와 프로젝트 검색',
'shell.searchPlaceholder': '페이지, 프로젝트 검색',
'shell.searchShortcut': '검색 (Ctrl/⌘ + K)',
'shell.searchEmpty': '‘{q}’와 일치하는 페이지나 프로젝트가 없습니다',
'shell.searchGroupPages': '페이지',
'shell.searchGroupProjects': '프로젝트 · 워크벤치 열기',
'shell.searchHintMove': '선택',
'shell.searchHintOpen': '열기',
'shell.searchHintClose': '닫기',
'shell.reduceMotion': '동작 줄이기',
'shell.reduceHint': '이동·확대·발광 애니메이션을 끄고 짧은 페이드만 남깁니다',
'shell.reduceSystem': '시스템의 ‘동작 줄이기’ 설정을 따르는 중',
'shell.tabAria': '주 탐색 (좁은 화면)',
'shell.tabProjects': '프로젝트',
'shell.tabCreate': '창작',
'shell.tabGenerate': '생성',
'shell.tabMore': '더보기',
'shell.morePages': '더 많은 페이지',
'shell.moreTools': '도구',
'home.title': '프로젝트 개요',
'home.subtitle': '창작 프로젝트를 관리하세요 — 아이디어나 완성된 대본으로 시작할 수 있습니다.',
'home.projectCount': '프로젝트 {n}개 · 진행 중 {m}개',
@@ -1456,8 +1486,6 @@ export const ko: Dict = {
'studio.genParams': '생성 매개변수',
'studio.modeTextToImage': '텍스트→이미지',
'studio.modeTextToVideo': '텍스트→영상',
'studio.chipIdle': '{name} · 대기열 비어 있음',
'studio.chipBusy': '{name} · 대기열 {n}건',
'studio.video.resultsTitle': '미리보기',
'studio.image.historyTitle': '이미지 생성 기록',
'studio.video.historyTitle': '영상 생성 기록',
+34 -6
View File
@@ -1262,14 +1262,44 @@ export const zh: Dict = {
'shell.navConnections': 'ComfyUI 连接',
'shell.navModels': '模型设置',
'shell.navSettings': '设置',
'shell.wip': '开发中',
'shell.wipHint': '该页面正在开发中,稍后开放',
'shell.comfy_ok': '{name} 已连接',
'shell.comfy_fail': '{name} 连接失败',
'shell.comfy_testing': '{name} 测试中…',
'shell.comfyUnset': 'ComfyUI · 未配置',
'shell.comfyManage': '管理 ComfyUI 连接',
'shell.mobileNotice': '请用桌面浏览器(宽 768px 以上)打开影工坊,手机端暂不适配。',
'shell.scopeHint': '切换项目',
'shell.scopeAll': '全部项目',
'shell.scopeAllList': '全部项目列表',
'shell.scopeRecent': '最近项目',
'shell.scopeLoadFailed': '项目列表加载失败',
'shell.scopeEmpty': '还没有项目,去创建',
'shell.scopeStage': '已确认 {n}/5',
'shell.queueRunning': '{n} 个生成中',
'shell.queueQueued': '{n} 个排队',
'shell.queueIdle': '队列空闲',
'shell.queueActive': '{n} 个进行中',
'shell.trayTitle': '任务托盘',
'shell.trayRecent': '最近任务 · 点击查看全部任务',
'shell.trayEmpty': '还没有生成任务',
'shell.trayAll': '查看全部任务',
'shell.searchTitle': '搜索页面和项目',
'shell.searchPlaceholder': '搜索页面、项目',
'shell.searchShortcut': '搜索(Ctrl/⌘ + K)',
'shell.searchEmpty': '没有找到与「{q}」相关的页面或项目',
'shell.searchGroupPages': '页面',
'shell.searchGroupProjects': '项目 · 打开创作工作台',
'shell.searchHintMove': '选择',
'shell.searchHintOpen': '打开',
'shell.searchHintClose': '关闭',
'shell.reduceMotion': '减少动态',
'shell.reduceHint': '关闭位移、缩放和光晕动画,只保留短暂淡入淡出',
'shell.reduceSystem': '已跟随系统的「减少动态效果」设置',
'shell.tabAria': '主导航(窄屏)',
'shell.tabProjects': '项目',
'shell.tabCreate': '创作',
'shell.tabGenerate': '生成',
'shell.tabMore': '更多',
'shell.morePages': '更多页面',
'shell.moreTools': '工具',
'home.title': '项目总览',
'home.subtitle': '管理创作项目,从一个创意或一份现成剧本开始。',
'home.projectCount': '{n} 个项目 · {m} 个进行中',
@@ -1455,8 +1485,6 @@ export const zh: Dict = {
'studio.genParams': '生成参数',
'studio.modeTextToImage': '文生图',
'studio.modeTextToVideo': '文生视频',
'studio.chipIdle': '{name} · 队列空闲',
'studio.chipBusy': '{name} · 队列 {n} 个任务',
'studio.video.resultsTitle': '成片预览',
'studio.image.historyTitle': '生图历史',
'studio.video.historyTitle': '生视频历史',
@@ -0,0 +1,45 @@
import { storageGet, storageSet } from './storage';
/**
* 减少动态效果 (HEL-649 §五). The OS setting always wins (app.css also reacts
* to `prefers-reduced-motion` directly, so it cannot be switched off here);
* otherwise the top-bar switch decides. The effective value is mirrored to
* `<html data-reduce="1">`, which is what every reduced-motion rule keys on.
*/
const STORAGE_KEY = 'aigc-reduce-motion';
const QUERY = '(prefers-reduced-motion: reduce)';
export const reduceMotion = $state({ system: false, manual: false });
let stop: (() => void) | null = null;
function apply() {
document.documentElement.dataset.reduce =
reduceMotion.system || reduceMotion.manual ? '1' : '0';
}
/** Call once on the client (root layout). Returns the teardown. */
export function initReduceMotion(): () => void {
if (typeof window === 'undefined') return () => {};
stop?.();
const mq = window.matchMedia(QUERY);
reduceMotion.system = mq.matches;
reduceMotion.manual = storageGet(STORAGE_KEY) === '1';
apply();
const onChange = (e: MediaQueryListEvent) => {
reduceMotion.system = e.matches;
apply();
};
mq.addEventListener('change', onChange);
stop = () => mq.removeEventListener('change', onChange);
return () => {
stop?.();
stop = null;
};
}
export function setReduceMotion(on: boolean) {
reduceMotion.manual = on;
storageSet(STORAGE_KEY, on ? '1' : '0');
apply();
}
+4
View File
@@ -1,13 +1,17 @@
<script lang="ts">
import './app.css';
import { onMount } from 'svelte';
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
import ToastHost from '$lib/components/ToastHost.svelte';
import { initReduceMotion } from '$lib/reduceMotion.svelte';
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: 1 },
},
});
onMount(() => initReduceMotion());
</script>
<QueryClientProvider client={queryClient}>
+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;
+496 -150
View File
@@ -4,105 +4,227 @@
: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);
/* 壳层尺寸 (§二):60px 图标轨、顶栏、≤760px 底部标签栏 */
--rail-w: 60px;
--topbar-h: 52px;
--tabbar-h: 56px;
/* 专属符号 (§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;
}
.a-darkroom {
background: var(--bg);
background: var(--page-glow), 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,93 +233,154 @@ 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);
}
a.status-chip,
button.status-chip {
font-family: inherit;
text-decoration: none;
cursor: pointer;
transition:
border-color var(--d-state) ease,
color var(--d-state) ease,
transform var(--d-press) var(--ease-out);
}
a.status-chip:active,
button.status-chip:active {
transform: scale(var(--press-scale));
}
@media (hover: hover) and (pointer: fine) {
a.status-chip:hover,
button.status-chip:hover {
border-color: var(--text-3);
color: var(--text);
}
}
/* Keyboard shortcut hint, e.g. the Ctrl K badge on the search capsule. */
.a-kbd {
font: var(--fs-mini) var(--mono);
color: var(--text-3);
border: 1px solid var(--line-2);
border-bottom-width: 2px;
border-radius: var(--r-sm);
padding: 0 var(--sp-2);
line-height: 16px;
white-space: nowrap;
}
.status-chip-dot {
width: 6px;
height: 6px;
@@ -206,33 +389,179 @@ 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);
}
/* Rows inside pops, sheets and the Ctrl+K palette (§四.3 pop). Gold marks
only the current page / project; keyboard and hover highlight is neutral. */
.a-menu-group {
padding: var(--sp-4) var(--sp-4) var(--sp-1);
font-size: var(--fs-mini);
color: var(--text-3);
}
.a-menu-item {
display: flex;
align-items: center;
gap: var(--sp-3);
width: 100%;
min-height: 40px;
padding: var(--sp-3) var(--sp-4);
border: 0;
border-radius: var(--r-md);
background: transparent;
color: var(--text-2);
font-family: inherit;
font-size: var(--fs-body);
text-align: left;
text-decoration: none;
cursor: pointer;
box-sizing: border-box;
transition:
background-color var(--d-state) ease,
color var(--d-state) ease;
}
.a-menu-item.active,
.a-menu-item:focus-visible {
background: var(--bg-4);
color: var(--text);
}
@media (hover: hover) and (pointer: fine) {
.a-menu-item:hover {
background: var(--bg-4);
color: var(--text);
}
}
.a-menu-item[aria-current='page'],
.a-menu-item[aria-current='true'] {
color: var(--accent);
}
.a-menu-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.a-menu-meta {
flex-shrink: 0;
font-size: var(--fs-mini);
color: var(--text-3);
white-space: nowrap;
}
/* 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 +572,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 +621,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 +634,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 +670,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 +686,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 +708,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)',
},
},
},