Compare commits

...
Author SHA1 Message Date
总工 4c87c6dea0 HEL-664: merge F-batch remaining pages & site-wide polish (feat/hel-656-f-pages) 2026-09-30 17:08:05 +08:00
总工andmultica-agent d882fd62d8 feat(ui): F批——其余页面融合收口与整站规范统一 (HEL-656)
- 移除 Calliope 旧壳死代码:/project/[id] 旧四段编辑器页面与 9 个遗留组件
  (NavRail/StoryStage/AssetsStage/ScriptStage/QueueStage/ActivityPanel/
  JobRow/AssetThumb/ProjectCard),/projects 死页面(308→/ 已有)
- 素材库 /library 迁入 AppShell 新壳:a-chip 筛选、ui/Modal、ConfirmDialog
  实心红两级删除、窄屏单列;移除 A 时期 308→/ 重定向;导航轨/更多菜单新增入口
- 智能体子系统去杂色:agentPalette 默认紫→中性、回退色板收敛为 avatar 五色族;
  AgentChat 气泡按 §3.2/3.6(用户 bg-3/Agent bg-2+line)、进行中指示改青、
  紫色空态图标清除;AgentComposer 天蓝/金芯片收敛
- 首页/工作台/五面板/StageStepper/PromptBlock/JobChip 类名收敛到全局 a-btn/
  a-input/a-tag/field;Stepper 补金色当前下划+已确认✓;镜头序号按规范用
  accent-lo+mono;分页/工作台选择页金色越界改中性或青
- 金色纪律修正:次级文字操作(重试/测试/链接)与必填星标、任务「测试」标签
  去金色;「默认」金标签按 §3.9 保留
- 断点收敛:1023/1279/900/780/700/640 → 1180/760(1440 增强保留)
- canvas/build-scene 可用级 ≤760 纵向堆叠适配 + 硬编码色/旧 fallback 清理;
  AppHeader 去 Calliope 品牌与死链
- favicon 收口:static/favicon.png 金色品牌图标,移除 204 stub 与构建豁免
- i18n 七语种 +3 key(shell.navLibrary/library.loadError/library.subtitle)全对齐
- 新增 e2e/fpages.mjs:F 页面三档走查/重定向/五段导入编辑/规范色断言 38 项

Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 16:29:47 +08:00
53 changed files with 867 additions and 8404 deletions
@@ -1,614 +0,0 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { jobsApi, type Job } from '$lib/api';
import type { CalliopeEvent, EventConnectionState } from '$lib/events';
import { t } from '$lib/i18n.svelte';
import JobRow from './JobRow.svelte';
import Button from './ui/Button.svelte';
import Icon from './ui/Icon.svelte';
interface Props {
logs: CalliopeEvent[];
projectId: number;
/** Real SSE connection state from events.ts — drives the live pip. */
connState: EventConnectionState;
collapsed?: boolean;
onToggle?: () => void;
}
let { logs, projectId, connState, collapsed = false, onToggle }: Props = $props();
type Tone = 'info' | 'ok' | 'warn' | 'err' | 'work';
type LogEntry = {
key: string;
ts: string;
title: string;
detail: string;
tone: Tone;
type: string;
};
function str(v: unknown): string {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
return '';
}
function formatEvent(ev: CalliopeEvent, index: number): LogEntry | null {
// Poll spam — progress lives in the jobProgress store, not the log
if (ev.type === 'job.progress') return null;
const d = ev.data ?? {};
const msg = str(d.message).trim();
const kind = str(d.kind);
const jobId = d.job_id != null ? `#${d.job_id}` : '';
const err = str(d.error).trim();
const paths = Array.isArray(d.paths) ? d.paths.length : 0;
const outputs = Array.isArray(d.outputs) ? d.outputs.length : 0;
const time = ev.ts?.includes('T') ? ev.ts.slice(11, 19) : ev.ts?.slice(0, 8) || '';
let title = ev.type;
let detail = msg;
let tone: Tone = 'info';
switch (ev.type) {
case 'agent.thinking':
title = t('activity.agentWorking');
detail = msg || t('activity.working');
tone = 'work';
break;
case 'story.ready':
title = t('activity.storyReady');
detail = msg || t('activity.storylineDrafted');
tone = 'ok';
break;
case 'job.created':
title = t('activity.queued');
detail = msg || [kind, jobId].filter(Boolean).join(' ');
tone = 'info';
break;
case 'job.started':
title = t('activity.running');
detail = msg || [kind, jobId].filter(Boolean).join(' ');
tone = 'work';
break;
case 'job.completed':
title = t('activity.finished');
detail =
msg ||
[kind, jobId, outputs ? t('activity.files', { count: outputs }) : '']
.filter(Boolean)
.join(' · ');
tone = 'ok';
break;
case 'job.failed':
title = t('activity.failed');
detail = err || msg || [kind, jobId].filter(Boolean).join(' ');
tone = 'err';
break;
case 'asset.ready':
title = t('activity.assetReady');
detail = msg || (paths ? t('activity.filesSaved', { count: paths }) : t('activity.outputSaved'));
tone = 'ok';
break;
case 'job.deleted':
title = t('activity.removed');
detail = msg || jobId || t('activity.jobDeleted');
tone = 'warn';
break;
default:
title = ev.type.replace(/\./g, ' · ');
detail = msg || err || kind;
tone = 'info';
}
return {
key: `${ev.ts}-${ev.type}-${index}`,
ts: time,
title,
detail,
tone,
type: ev.type,
};
}
const entries = $derived.by(() => {
const raw = logs
.slice(-120)
.map((ev, i) => formatEvent(ev, i))
.filter((e): e is LogEntry => e != null);
// Collapse consecutive identical agent lines
const collapsed: LogEntry[] = [];
for (const e of raw) {
const prev = collapsed[collapsed.length - 1];
if (prev && prev.type === e.type && prev.detail === e.detail) {
continue;
}
collapsed.push(e);
}
return collapsed.slice(-60).reverse();
});
// Same query key as QueueStage — refreshed by SSE invalidation from the
// project page (job.* events), not by polling.
const jobsQuery = createQuery(
toStore(() => ({
queryKey: ['jobs', projectId],
queryFn: () => jobsApi.list(projectId),
})),
);
const allJobs = $derived(($jobsQuery.data ?? []) as Job[]);
function activeWeight(job: Job): number {
if (job.status === 'running') return 0;
if (job.status === 'pending') return 1;
return 2;
}
// Active jobs first, then most recent — capped so the log keeps room.
const queueRows = $derived(
[...allJobs].sort((a, b) => activeWeight(a) - activeWeight(b) || b.id - a.id).slice(0, 6),
);
const stats = $derived({
running: allJobs.filter((j) => j.status === 'running').length,
queued: allJobs.filter((j) => j.status === 'pending').length,
done: allJobs.filter((j) => j.status === 'done').length,
failed: allJobs.filter((j) => j.status === 'failed').length,
});
const busyCount = $derived(stats.running + stats.queued);
const connected = $derived(connState === 'open');
</script>
<aside class="activity" class:collapsed aria-label={t('activity.title')}>
{#if collapsed}
<button
type="button"
class="rail-toggle"
onclick={onToggle}
aria-expanded="false"
title={t('activity.show')}
>
<span class="pip rail-pip" class:live={connected} class:reconnecting={connState === 'reconnecting'} aria-hidden="true"></span>
{#if busyCount > 0}
<span class="rail-count" title={t('activity.busyCount', { count: busyCount })}>{busyCount}</span>
{/if}
<span class="rail-label">{t('activity.title')}</span>
<span class="rail-chevron" aria-hidden="true">‹</span>
</button>
{:else}
<header class="head">
<div class="head-title">
<span class="pip" class:live={connected} class:reconnecting={connState === 'reconnecting'} aria-hidden="true"></span>
<strong>{t('activity.title')}</strong>
{#if connState === 'reconnecting'}
<span class="conn-state warn">{t('activity.reconnecting')}</span>
{:else if connState === 'connecting'}
<span class="conn-state">{t('activity.connecting')}</span>
{/if}
</div>
<Button variant="ghost" size="sm" onclick={onToggle} title={t('activity.hide')}>
{t('activity.hide')} <Icon name="chevron-right" size={13} />
</Button>
</header>
<div class="body">
<section class="queue" aria-label={t('activity.queue')}>
<div class="section-head">
<p class="eyebrow">{t('activity.queue')}</p>
</div>
{#if queueRows.length === 0}
<p class="queue-empty">{t('activity.queueIdle')}</p>
{:else}
<div class="job-list">
{#each queueRows as job (job.id)}
<JobRow {job} />
{/each}
</div>
{/if}
<div class="stats-strip" aria-label={t('activity.queueStats')}>
<span><span class="sd sd-running" aria-hidden="true"></span>{stats.running} {t('activity.running').toLowerCase()}</span>
<span><span class="sd sd-queued" aria-hidden="true"></span>{stats.queued} {t('activity.queued').toLowerCase()}</span>
<span><span class="sd sd-done" aria-hidden="true"></span>{stats.done} {t('activity.done')}</span>
<span><span class="sd sd-failed" aria-hidden="true"></span>{stats.failed} {t('activity.failed').toLowerCase()}</span>
</div>
</section>
<section class="log-section" aria-label={t('activity.agentLog')}>
<p class="eyebrow">{t('activity.agentLog')}</p>
{#if entries.length === 0}
<div class="empty">
<p>{t('activity.quiet')}</p>
<p class="hint">{t('activity.quietHint')}</p>
</div>
{:else}
<ul class="log">
{#each entries as ev (ev.key)}
<li class="entry tone-{ev.tone}">
<span class="dot" aria-hidden="true"></span>
<div class="meta">
<div class="row">
<span class="title">{ev.title}</span>
<time class="ts" datetime={ev.ts}>{ev.ts}</time>
</div>
{#if ev.detail}
<p class="detail">{ev.detail}</p>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</section>
</div>
{/if}
</aside>
<style>
.activity {
--panel-w: 320px;
--rail-w: 44px;
width: var(--panel-w);
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
border-left: 1px solid var(--border);
background:
linear-gradient(180deg, rgba(139, 92, 246, 0.06) 0%, transparent 120px),
var(--bg-surface);
overflow: hidden;
transition: width 0.2s ease;
}
.activity.collapsed {
width: var(--rail-w);
background: var(--bg-surface);
}
.rail-toggle {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 12px;
width: 100%;
height: 100%;
margin: 0;
padding: 16px 0;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
overflow: hidden;
}
.rail-toggle:hover {
background: rgba(139, 92, 246, 0.08);
color: var(--text-primary);
}
.rail-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.rail-label {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-family: var(--font-display);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.rail-chevron {
font-size: 18px;
line-height: 1;
color: var(--text-muted);
}
.rail-count {
display: flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
}
.pip {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
.pip.live {
background: var(--accent);
box-shadow: 0 0 0 4px var(--accent-glow);
animation: live-pulse 1.6s ease-in-out infinite;
}
.pip.reconnecting {
background: var(--warning);
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.18);
}
@keyframes live-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.65;
transform: scale(0.92);
}
}
@media (prefers-reduced-motion: reduce) {
.activity {
transition: none;
}
.pip.live {
animation: none;
}
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 14px 10px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.head-title {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.head-title strong {
font-family: var(--font-display);
font-size: 15px;
font-weight: 650;
letter-spacing: -0.02em;
}
.conn-state {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
}
.conn-state.warn {
color: var(--warning);
}
.body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 12px 10px 16px;
overflow: hidden;
}
.queue {
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
margin-bottom: 14px;
}
.section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-shrink: 0;
}
.job-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 216px;
overflow-y: auto;
padding-right: 4px;
}
.queue-empty {
margin: 0 6px 4px;
padding: 12px 14px;
border: 1px dashed var(--border);
border-radius: var(--radius-md);
background: rgba(0, 0, 0, 0.2);
font-size: 12px;
line-height: 1.45;
color: var(--text-muted);
}
.stats-strip {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin: 10px 6px 0;
padding-top: 10px;
border-top: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
flex-shrink: 0;
}
.stats-strip > span {
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.sd {
width: 6px;
height: 6px;
border-radius: 50%;
}
.sd-running {
background: var(--info);
}
.sd-queued {
background: var(--text-muted);
}
.sd-done {
background: var(--success);
}
.sd-failed {
background: var(--error);
}
.log-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.eyebrow {
margin: 0 6px 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
flex-shrink: 0;
}
.empty {
margin: 8px 6px;
padding: 16px;
border: 1px dashed var(--border);
border-radius: var(--radius-md);
background: rgba(0, 0, 0, 0.2);
}
.empty p {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
}
.empty .hint {
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.45;
}
.log {
list-style: none;
margin: 0;
padding: 0 4px 8px 0;
overflow-y: auto;
overflow-x: hidden;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.entry {
display: grid;
grid-template-columns: 14px 1fr;
gap: 8px;
padding: 8px 8px 8px 4px;
border-radius: var(--radius-sm);
}
.entry:hover {
background: rgba(255, 255, 255, 0.03);
}
.dot {
width: 8px;
height: 8px;
margin-top: 5px;
border-radius: 50%;
background: var(--text-muted);
justify-self: center;
}
.tone-ok .dot {
background: var(--success);
}
.tone-err .dot {
background: var(--error);
}
.tone-warn .dot {
background: var(--warning);
}
.tone-work .dot {
background: var(--accent);
}
.tone-info .dot {
background: var(--info);
}
.meta {
min-width: 0;
}
.row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.title {
font-size: 12px;
font-weight: 650;
color: var(--text-primary);
}
.ts {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
flex-shrink: 0;
}
.detail {
margin: 3px 0 0;
font-size: 12px;
line-height: 1.4;
color: var(--text-secondary);
word-break: break-word;
}
.tone-err .detail {
color: #fca5a5;
}
</style>
@@ -15,8 +15,8 @@ interface Props {
<header class="header">
<div class="header-left">
<a class="logo" href="/canvas" title="Calliope Lab">
Calliope <span>Lab</span>
<a class="logo" href="/" title={t('shell.brand')}>
{t('shell.brand')}
</a>
{#if crumb}
<span class="crumb">{crumb}</span>
@@ -28,7 +28,7 @@ interface Props {
{/if}
<nav class="top-nav" aria-label={t('nav.primary')}>
<a class="nav-link" class:active={active === 'canvas'} href="/canvas">{t('nav.canvas')}</a>
<a class="nav-link" class:active={active === 'projects'} href="/projects">{t('nav.projects')}</a>
<a class="nav-link" class:active={active === 'projects'} href="/">{t('nav.projects')}</a>
<a class="nav-link" class:active={active === 'build-scene'} href="/build-scene">{t('nav.buildScene')}</a>
<a class="nav-link" class:active={active === 'library'} href="/library">{t('nav.library')}</a>
<a class="nav-link" class:active={active === 'agents'} href="/agents">{t('nav.agents')}</a>
@@ -69,18 +69,14 @@ interface Props {
flex-shrink: 0;
}
.logo {
font-family: var(--font-display);
font-size: 18px;
font-family: var(--font);
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
color: var(--text);
text-decoration: none;
letter-spacing: -0.02em;
letter-spacing: -0.01em;
white-space: nowrap;
}
.logo span {
color: var(--accent);
font-weight: 500;
}
.crumb {
color: var(--text-muted);
font-size: 13px;
@@ -122,7 +118,7 @@ interface Props {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@media (max-width: 640px) {
@media (max-width: 760px) {
.header {
padding: 12px 16px;
flex-direction: column;
@@ -1,133 +0,0 @@
<script lang="ts">
import { t } from '$lib/i18n.svelte';
import SafeMedia from '$lib/components/SafeMedia.svelte';
import StatusChip from '$lib/components/ui/StatusChip.svelte';
interface Props {
src: string | null;
alt: string;
placeholder: string;
wide?: boolean;
/** Taller 4:3 media-first crop (asset card grids). */
tall?: boolean;
/** Latest generation-job state for this asset, if the parent tracks jobs. */
jobState?: 'generating' | 'failed' | null;
onPreview?: (src: string) => void;
}
let {
src,
alt,
placeholder,
wide = false,
tall = false,
jobState = null,
onPreview,
}: Props = $props();
let available = $state(false);
$effect(() => {
void src;
available = false;
});
// Job state (live from the queue) wins over the media-derived state.
const chip = $derived.by((): { status: string; label: string } => {
if (jobState === 'generating') return { status: 'generating', label: t('common.generating') };
if (jobState === 'failed') return { status: 'failed', label: t('common.failed') };
if (available) return { status: 'ready', label: t('common.ready') };
if (src) return { status: 'missing', label: t('common.missing') };
return { status: 'pending', label: t('common.pending') };
});
function open() {
if (available && src && onPreview) onPreview(src);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
open();
}
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
<div
class="thumb"
class:wide
class:tall
class:clickable={available && !!onPreview}
role={available && onPreview ? 'button' : undefined}
tabindex={available && onPreview ? 0 : undefined}
onclick={open}
onkeydown={available && onPreview ? onKey : undefined}
>
<SafeMedia
class="fill"
{src}
{alt}
label={placeholder}
onAvailable={() => (available = true)}
onUnavailable={() => (available = false)}
/>
<span class="thumb-chip">
<StatusChip status={chip.status} label={chip.label} />
</span>
</div>
<style>
.thumb {
aspect-ratio: 1;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
padding: 0;
margin: 0;
width: 100%;
color: inherit;
font: inherit;
box-sizing: border-box;
}
.thumb.wide {
aspect-ratio: 16 / 10;
}
.thumb.tall {
aspect-ratio: 4 / 3;
}
.thumb.clickable {
cursor: zoom-in;
}
.thumb.clickable:hover {
border-color: var(--accent, #a78bfa);
}
.thumb.clickable:focus-visible {
outline: 2px solid var(--accent, #a78bfa);
outline-offset: 2px;
}
.thumb :global(.fill) {
width: 100%;
height: 100%;
object-fit: cover;
border: none;
border-radius: 0;
min-height: 0;
}
.thumb :global(.safe-media-ph.fill) {
position: absolute;
inset: 0;
}
.thumb-chip {
position: absolute;
top: 8px;
left: 8px;
z-index: 1;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
}
</style>
File diff suppressed because it is too large Load Diff
@@ -136,9 +136,9 @@
const projectList = $derived(($projectsQuery.data ?? []) as Project[]);
// Stage-URL contract: images land on Assets, clips on Video.
// Five-stage workbench URL contract: images land on 素材资产, clips on 镜头脚本.
const stageLink = $derived(
attached ? `/project/${attached.projectId}?stage=${isVideo ? 'video' : 'assets'}` : '',
attached ? `/workbench/${attached.projectId}?stage=${isVideo ? 'shot' : 'assets'}` : '',
);
</script>
@@ -267,13 +267,13 @@
.attached {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: rgba(34, 197, 94, 0.08);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: var(--radius-md);
font-size: 12px;
color: var(--text-secondary);
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
background: var(--ok-soft);
border: 1px solid color-mix(in srgb, var(--ok) 30%, transparent);
border-radius: var(--r-md);
font-size: var(--fs-aux);
color: var(--text-2);
}
.attached-check {
display: flex;
@@ -282,8 +282,8 @@
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--success);
color: #052e12;
background: var(--ok);
color: var(--bg);
flex-shrink: 0;
}
.attached-text {
@@ -293,23 +293,26 @@
white-space: nowrap;
}
.attached-text strong {
color: var(--text-primary);
color: var(--text);
font-weight: 600;
}
/* 普通链接走 ghost(§1.2):默认 --text-2,悬停 --text,不用金色文字链接。 */
.attached-link {
color: var(--accent);
font-weight: 600;
color: var(--text-2);
font-weight: 500;
text-decoration: none;
white-space: nowrap;
flex-shrink: 0;
}
.attached-link:hover {
color: var(--text);
text-decoration: underline;
text-underline-offset: 3px;
}
.attached-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
border-radius: var(--r-sm);
}
.attached-dismiss {
display: flex;
@@ -319,15 +322,15 @@
height: 22px;
margin-left: auto;
border: none;
border-radius: var(--radius-sm);
border-radius: var(--r-sm);
background: transparent;
color: var(--text-muted);
color: var(--text-3);
cursor: pointer;
flex-shrink: 0;
}
.attached-dismiss:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.06);
color: var(--text);
background: var(--bg-4);
}
.attached-dismiss:focus-visible {
outline: 2px solid var(--accent);
@@ -336,20 +339,20 @@
.panel {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
gap: var(--sp-1);
padding: var(--sp-5);
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.actions {
display: flex;
gap: 8px;
margin-top: 4px;
gap: var(--sp-3);
margin-top: var(--sp-1);
}
.err {
margin: 4px 0 0;
font-size: 12px;
color: var(--error);
margin: var(--sp-1) 0 0;
font-size: var(--fs-aux);
color: var(--err);
}
</style>
@@ -1,164 +0,0 @@
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { jobsApi, type Job } from '$lib/api';
import { t } from '$lib/i18n.svelte';
import { progressFor } from '$lib/jobProgress';
import { toast } from '$lib/toast';
import Button from './ui/Button.svelte';
import Icon from './ui/Icon.svelte';
import type { IconName } from './ui/icons';
import ProgressBar from './ui/ProgressBar.svelte';
import StatusChip from './ui/StatusChip.svelte';
interface Props {
job: Job;
/** Optional override for the leading label (defaults to "Kind #id"). */
label?: string;
}
let { job, label }: Props = $props();
const client = useQueryClient();
let busy = $state(false);
const kindIcon: IconName = $derived(
job.kind === 'video'
? 'video'
: job.kind === 'image'
? 'image'
: job.kind === 'export'
? 'film'
: 'queue',
);
const kindLabel = $derived(
job.kind === 'export'
? t('job.exportFilm')
: job.kind.charAt(0).toUpperCase() + job.kind.slice(1),
);
const title = $derived(label ?? t('job.title', { kind: kindLabel, id: String(job.id) }));
const active = $derived(job.status === 'pending' || job.status === 'running');
const entry = $derived(progressFor(job.id));
async function refresh() {
await client.invalidateQueries({ queryKey: ['jobs'] });
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
}
async function cancel() {
busy = true;
try {
await jobsApi.cancel(job.id);
await refresh();
toast.info(t('job.cancelled', { id: String(job.id) }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('job.cancelFailed'));
} finally {
busy = false;
}
}
async function retry() {
busy = true;
try {
await jobsApi.retry(job.id);
await refresh();
toast.success(t('job.requeued', { id: String(job.id) }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('job.retryFailed'));
} finally {
busy = false;
}
}
</script>
<div class="job-row">
<span class="kind" aria-hidden="true"><Icon name={kindIcon} size={14} /></span>
<div class="main">
<div class="top">
<span class="label" {title}>{title}</span>
<StatusChip status={job.status} />
</div>
{#if active}
<div class="bar">
<ProgressBar
size="sm"
value={entry?.progress ?? 0}
indeterminate={job.status === 'running' && entry == null}
label={entry?.message}
/>
</div>
{:else if job.status === 'failed' && job.error}
<p class="err" title={job.error}>{job.error}</p>
{/if}
</div>
<div class="actions">
{#if active}
<Button size="sm" variant="ghost" loading={busy} title={t('job.cancelJob')} onclick={cancel}>
<Icon name="close" size={13} />
</Button>
{:else if job.status === 'failed'}
<Button size="sm" variant="ghost" loading={busy} title={t('job.retryJob')} onclick={retry}>
<Icon name="retry" size={13} />
</Button>
{/if}
</div>
</div>
<style>
.job-row {
display: grid;
grid-template-columns: 20px 1fr auto;
gap: 8px;
align-items: start;
padding: 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-elevated);
}
.kind {
display: flex;
align-items: center;
justify-content: center;
height: 20px;
color: var(--text-muted);
}
.main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 20px;
}
.label {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bar {
padding-bottom: 2px;
}
.err {
margin: 0;
font-size: 11px;
line-height: 1.4;
color: var(--error);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.actions {
display: flex;
align-items: center;
}
</style>
@@ -1,137 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { t } from '$lib/i18n.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import type { IconName } from '$lib/components/ui/icons';
const items = $derived(
[
{ id: 'story', key: 'nav.story', icon: 'story' },
{ id: 'assets', key: 'nav.assets', icon: 'assets' },
{ id: 'script', key: 'nav.script', icon: 'script' },
{ id: 'video', key: 'nav.video', icon: 'video' },
] as { id: string; key: string; icon: IconName }[],
);
interface Props {
active?: string;
onSelect?: (id: string) => void;
/** Optional per-stage counts — a nonzero value shows a small pill on the icon. */
badges?: Record<string, number>;
}
let { active = 'story', onSelect, badges = {} }: Props = $props();
</script>
<nav class="nav" aria-label={t('nav.projectStages')}>
<button
class="nav-item home"
title={t('nav.projects')}
aria-label={t('nav.backProjects')}
onclick={() => goto('/projects')}
>
<span class="icon"><Icon name="home" size={20} /></span>
<span class="label">{t('nav.home')}</span>
</button>
<div class="divider" aria-hidden="true"></div>
{#each items as item (item.id)}
{@const count = badges[item.id] ?? 0}
<button
class="nav-item"
class:active={active === item.id}
title={t(item.key)}
aria-current={active === item.id ? 'page' : undefined}
onclick={() => onSelect?.(item.id)}
>
<span class="icon">
<Icon name={item.icon} size={20} />
{#if count > 0}
<span class="badge" aria-label={t('nav.countActive', { count })}>
{count > 9 ? '9+' : count}
</span>
{/if}
</span>
<span class="label">{t(item.key)}</span>
</button>
{/each}
</nav>
<style>
.nav {
width: 64px;
background: var(--bg-surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-md) 0;
gap: var(--space-sm);
flex-shrink: 0;
}
.divider {
width: 32px;
height: 1px;
background: var(--border);
margin: 2px 0 4px;
}
.nav-item {
width: 56px;
border: none;
background: transparent;
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 0;
cursor: pointer;
color: var(--text-muted);
font-size: 11px;
font-family: inherit;
transition: all 0.15s;
}
.nav-item:hover {
background: var(--bg-elevated);
color: var(--text-primary);
}
.nav-item.active {
background: var(--accent);
color: white;
box-shadow: 0 0 16px var(--accent-glow);
}
.nav-item.home:hover {
color: var(--accent);
}
.nav-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.icon {
position: relative;
display: inline-flex;
line-height: 1;
}
.badge {
position: absolute;
top: -6px;
right: -12px;
min-width: 15px;
height: 15px;
padding: 0 4px;
box-sizing: border-box;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 9px;
font-weight: 700;
line-height: 15px;
text-align: center;
box-shadow: 0 0 0 2px var(--bg-surface);
}
.nav-item.active .badge {
background: #fff;
color: var(--accent);
box-shadow: 0 0 0 2px var(--accent);
}
</style>
@@ -1,478 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { assetUrl, projects, type Project } from '$lib/api';
import { toast } from '$lib/toast';
import Button from '$lib/components/ui/Button.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import StatusChip from '$lib/components/ui/StatusChip.svelte';
import { t } from '$lib/i18n.svelte';
interface Props {
project: Project;
}
const { project }: Props = $props();
const client = useQueryClient();
const stats = $derived(
project.stats ?? {
scene_count: 0,
character_count: 0,
asset_ready_count: 0,
asset_total_count: 0,
},
);
// Explicit project cover wins; otherwise fall back to the first asset image.
// The project prop is stable for the lifetime of a card, so the eager
// queryKey capture below is intentional.
// svelte-ignore state_referenced_locally
const assetsQuery = createQuery({
queryKey: ['assets', project.id],
queryFn: () => projects.getAssets(project.id),
staleTime: 60_000,
});
const thumbUrl = $derived.by(() => {
if (project.cover_path) return assetUrl(project.cover_path);
const data = $assetsQuery.data;
if (!data) return null;
const char = data.characters.find((c) => c.portrait_path || c.sheet_path);
const path =
char?.portrait_path ??
char?.sheet_path ??
data.locations.find((l) => l.reference_image_path)?.reference_image_path ??
null;
return assetUrl(path);
});
// A broken cover (deleted file, 404) falls back to the gradient tile.
let thumbFailedFor = $state<string | null>(null);
function relativeTime(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return '';
const mins = Math.max(0, Math.round((Date.now() - then) / 60000));
if (mins < 1) return t('projectCard.timeJustNow');
if (mins < 60) return t('projectCard.timeM', { count: mins });
const hours = Math.round(mins / 60);
if (hours < 24) return t('projectCard.timeH', { count: hours });
const days = Math.round(hours / 24);
if (days < 30) return t('projectCard.timeD', { count: days });
const months = Math.round(days / 30);
if (months < 12) return t('projectCard.timeMo', { count: months });
return t('projectCard.timeY', { count: Math.round(months / 12) });
}
const updatedLabel = $derived(relativeTime(project.updated_at));
const updatedTitle = $derived(new Date(project.updated_at).toLocaleString());
// Overflow menu (⋯) — closes on outside click, focus leaving, or Esc.
let menuOpen = $state(false);
let menuEl = $state<HTMLDivElement | null>(null);
$effect(() => {
if (!menuOpen) return;
const onDocClick = (e: MouseEvent) => {
if (menuEl && !menuEl.contains(e.target as Node)) menuOpen = false;
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') menuOpen = false;
};
const onFocusIn = (e: FocusEvent) => {
if (menuEl && !menuEl.contains(e.target as Node)) menuOpen = false;
};
window.addEventListener('click', onDocClick);
window.addEventListener('keydown', onKey);
document.addEventListener('focusin', onFocusIn);
return () => {
window.removeEventListener('click', onDocClick);
window.removeEventListener('keydown', onKey);
document.removeEventListener('focusin', onFocusIn);
};
});
let renameOpen = $state(false);
let renameValue = $state('');
const renameMutation = createMutation({
mutationFn: (title: string) => projects.update(project.id, { title }),
onSuccess: (updated) => {
client.invalidateQueries({ queryKey: ['projects'] });
toast.success(t('projectCard.renamedTo', { title: updated.title }));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('projectCard.renameFailed'));
},
});
function openRename() {
menuOpen = false;
renameValue = project.title;
renameOpen = true;
}
function submitRename() {
const title = renameValue.trim();
renameOpen = false;
if (!title || title === project.title) return;
$renameMutation.mutate(title);
}
let deleteOpen = $state(false);
const deleteMutation = createMutation({
mutationFn: () => projects.delete(project.id),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['projects'] });
toast.success(t('projectCard.deleted', { title: project.title }));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('projectCard.deleteFailed'));
},
});
</script>
<article class="project-card">
<button
type="button"
class="card-main"
aria-label={t('projectCard.open', { title: project.title })}
onclick={() => goto(`/project/${project.id}`)}
>
<div class="card-thumb">
{#if thumbUrl && thumbFailedFor !== thumbUrl}
<img
class="thumb-img"
src={thumbUrl}
alt=""
loading="lazy"
onerror={() => (thumbFailedFor = thumbUrl)}
/>
{:else}
<div class="card-thumb-icon"><Icon name="video" size={26} /></div>
{/if}
<div class="card-status">
<StatusChip status={project.status} label={t(`projectCard.status.${project.status}`)} />
</div>
</div>
<div class="card-body">
<div class="card-title">
{project.title}
<span class="open-arrow">→</span>
</div>
<p class="card-desc">{project.idea || t('projectCard.noIdea')}</p>
<div class="card-meta">
<div class="card-stats">
<span class="card-stat">
<Icon name="script" size={13} />
{t('projectCard.scenes', { count: stats.scene_count })}
</span>
<span class="card-stat">
<Icon name="assets" size={13} />
{t('projectCard.characters', { count: stats.character_count })}
</span>
<span class="card-stat">
<Icon name="image" size={13} />
{t('projectCard.assets', {
ready: stats.asset_ready_count,
total: stats.asset_total_count,
})}
</span>
</div>
<span title={updatedTitle}>{updatedLabel}</span>
</div>
</div>
</button>
<div class="menu-wrap" bind:this={menuEl}>
<button
type="button"
class="menu-btn"
aria-label={t('projectCard.actions', { title: project.title })}
aria-haspopup="menu"
aria-expanded={menuOpen}
onclick={() => (menuOpen = !menuOpen)}
>
⋯
</button>
{#if menuOpen}
<div class="menu" role="menu" aria-label={t('projectCard.menuLabel')}>
<button type="button" role="menuitem" class="menu-item" onclick={openRename}>
<Icon name="edit" size={14} />
{t('common.rename')}
</button>
<button
type="button"
role="menuitem"
class="menu-item"
onclick={() => {
menuOpen = false;
goto(`/project/${project.id}?stage=assets`);
}}
>
<Icon name="image" size={14} />
{t('projectCard.changeCover')}
</button>
<button
type="button"
role="menuitem"
class="menu-item danger"
onclick={() => {
menuOpen = false;
deleteOpen = true;
}}
>
<Icon name="trash" size={14} />
{t('common.delete')}
</button>
</div>
{/if}
</div>
</article>
<Modal bind:open={renameOpen} title={t('projectCard.renameTitle')}>
<form
onsubmit={(e) => {
e.preventDefault();
submitRename();
}}
>
<label class="field">
<span class="field-label">{t('common.title')}</span>
<input class="field-input" bind:value={renameValue} required />
</label>
</form>
{#snippet footer()}
<Button variant="ghost" onclick={() => (renameOpen = false)}>{t('common.cancel')}</Button>
<Button
variant="primary"
disabled={!renameValue.trim() || $renameMutation.isPending}
onclick={submitRename}
>
{t('common.rename')}
</Button>
{/snippet}
</Modal>
<ConfirmDialog
bind:open={deleteOpen}
title={t('projectCard.deleteTitle')}
message={t('projectCard.deleteMessage', { title: project.title })}
confirmLabel={t('common.delete')}
danger
onconfirm={() => $deleteMutation.mutate()}
/>
<style>
.project-card {
position: relative;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
transition: all 0.2s;
}
.project-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
}
.project-card:hover .open-arrow {
opacity: 1;
transform: translateX(0);
}
.card-main {
display: block;
width: 100%;
padding: 0;
border: none;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.card-main:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.card-thumb {
height: 170px;
background: linear-gradient(135deg, var(--bg-elevated) 0%, #1a1625 100%);
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.card-thumb::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 20% 30%, rgba(139, 92, 246, 0.12) 0%, transparent 40%),
radial-gradient(circle at 80% 70%, rgba(59, 130, 246, 0.08) 0%, transparent 40%);
}
.thumb-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.card-thumb-icon {
width: 64px;
height: 64px;
border-radius: var(--radius-md);
background: rgba(139, 92, 246, 0.12);
border: 1px solid rgba(139, 92, 246, 0.2);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
}
.card-status {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
}
.card-body {
padding: var(--space-md);
}
.card-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 var(--space-xs);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-sm);
}
.open-arrow {
color: var(--accent);
opacity: 0;
transform: translateX(-8px);
transition: all 0.2s;
font-size: 14px;
}
.card-desc {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.5;
margin: 0 0 var(--space-md);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-sm);
font-size: 12px;
color: var(--text-muted);
}
.card-stats {
display: flex;
gap: var(--space-md);
flex-wrap: wrap;
}
.card-stat {
display: flex;
align-items: center;
gap: 4px;
}
.menu-wrap {
position: absolute;
top: 8px;
left: 8px;
z-index: 3;
}
.menu-btn {
width: 30px;
height: 30px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: rgba(10, 10, 12, 0.55);
color: var(--text-secondary);
font-size: 16px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.15s;
}
.project-card:hover .menu-btn,
.menu-btn:focus-visible,
.menu-btn[aria-expanded='true'] {
opacity: 1;
}
.menu-btn:hover {
background: var(--bg-elevated);
color: var(--text-primary);
border-color: var(--border);
}
.menu-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.menu {
position: absolute;
top: 34px;
left: 0;
min-width: 140px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
padding: 4px;
display: flex;
flex-direction: column;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
cursor: pointer;
text-align: left;
}
.menu-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.menu-item.danger {
color: var(--error);
}
.menu-item.danger:hover {
background: rgba(239, 68, 68, 0.12);
}
.menu-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
@media (prefers-reduced-motion: reduce) {
.project-card:hover {
transform: none;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,867 +0,0 @@
<script lang="ts">
import { tick } from 'svelte';
import { goto } from '$app/navigation';
import { toStore } from 'svelte/store';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { assetUrl, jobsApi, projects, type Job, type Scene, type Clip } from '$lib/api';
import { estimateTargetSeconds } from '$lib/durationBudget';
import { agentDeepLink } from '$lib/agentTasks';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import Button from '$lib/components/ui/Button.svelte';
import StatusChip from '$lib/components/ui/StatusChip.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
interface Props {
projectId: number;
}
let { projectId }: Props = $props();
const client = useQueryClient();
let editing = $state<Scene | null>(null);
let editOpen = $state(false);
let highlightId = $state<number | null>(null);
let adding = $state(false);
let deletingId = $state<number | null>(null);
let sceneToDelete = $state<Scene | null>(null);
let deleteOpen = $state(false);
const scenesQuery = createQuery(
toStore(() => ({
queryKey: ['scenes', projectId],
queryFn: () => projects.getScenes(projectId),
})),
);
// Shared cache key with QueueStage — clip job state shows up on scene cards
// too. Refreshed via SSE invalidation from the project page, not polling.
const jobsQuery = createQuery(
toStore(() => ({
queryKey: ['jobs', projectId],
queryFn: () => jobsApi.list(projectId),
})),
);
// Cache-shared with the parent's story query — only used for the target-length hint.
const storyQuery = createQuery(
toStore(() => ({
queryKey: ['story', projectId],
queryFn: () => projects.getStory(projectId),
})),
);
// Cache-shared with AssetsStage — resolves scene location chips.
const assetsQuery = createQuery(
toStore(() => ({
queryKey: ['assets', projectId],
queryFn: () => projects.getAssets(projectId),
})),
);
const scenes = $derived($scenesQuery.data?.scenes ?? []);
const sceneCount = $derived(scenes.length);
const totalClips = $derived(scenes.reduce((n, s) => n + (s.clips?.length ?? 0), 0));
const totalSec = $derived($scenesQuery.data?.estimated_duration_sec ?? 0);
const targetSec = $derived.by(() => {
const target = $storyQuery.data?.project?.target_duration;
return target ? estimateTargetSeconds(target) : 0;
});
const busy = $derived(adding || deletingId != null);
// ── Break into shots (coverage expansion) ────────────────────────────
let expandingIds = $state<number[]>([]);
let expandingAll = $state(false);
let expandedIds = $state<number[]>([]);
function isExpanded(scene: Scene): boolean {
return (scene.clips?.length ?? 0) > 1 || expandedIds.includes(scene.id);
}
async function breakIntoShots(scene: Scene) {
if (expandingIds.length > 0 || expandingAll) return;
expandingIds = [...expandingIds, scene.id];
try {
const res = await projects.expandClips(projectId, { scene_ids: [scene.id] });
expandedIds = [...new Set([...expandedIds, scene.id])];
await client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(
t('script.toast.breakIntoShots', {
num: scene.order_index,
clips: res.scenes?.[0]?.clip_count ?? '?',
}),
);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.breakFail'));
} finally {
expandingIds = expandingIds.filter((id) => id !== scene.id);
}
}
async function breakAllIntoShots() {
if (expandingIds.length > 0 || expandingAll) return;
if (
!window.confirm(t('script.toast.breakAllConfirm', { count: sceneCount }))
)
return;
expandingAll = true;
try {
const res = await projects.expandClips(projectId, { all: true });
expandedIds = scenes.map((s) => s.id);
await client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(t('script.toast.boardExpanded', { count: res.total_clips }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.breakAllFail'));
} finally {
expandingAll = false;
}
}
async function deleteClip(clip: Clip, scene: Scene) {
try {
await projects.deleteClip(projectId, clip.id);
await client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(t('script.toast.deletedShot', { num: `${scene.order_index}.${clip.order_index}` }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.deleteClipFail'));
}
}
const saveMutation = createMutation({
mutationFn: () =>
projects.updateScene(projectId, editing!.id, {
heading: editing!.heading,
action: editing!.action,
dialog: editing!.dialog,
duration_sec: editing!.duration_sec,
}),
onSuccess: () => {
editing = null;
editOpen = false;
client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(t('script.toast.sceneSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('script.toast.saveSceneFail'));
},
});
// Chain-from-previous toggle — persists on the scene; consumed at render time.
let chainPendingId = $state<number | null>(null);
async function toggleChain(scene: Scene) {
if (chainPendingId != null) return;
chainPendingId = scene.id;
try {
await projects.updateScene(projectId, scene.id, {
chain_from_prev: !scene.chain_from_prev,
});
await client.invalidateQueries({ queryKey: ['scenes'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.updateSceneFail'));
} finally {
chainPendingId = null;
}
}
function jobForScene(sceneId: number): Job | undefined {
const jobs = ($jobsQuery.data ?? []).filter(
(j) => j.scene_id === sceneId && j.kind === 'video',
);
if (jobs.length === 0) return undefined;
return [...jobs].sort((a, b) => b.id - a.id)[0];
}
function jobForClip(clipId: number): Job | undefined {
const jobs = ($jobsQuery.data ?? []).filter(
(j) => (j as Job & { clip_id?: number | null }).clip_id === clipId && j.kind === 'video',
);
if (jobs.length === 0) return undefined;
return [...jobs].sort((a, b) => b.id - a.id)[0];
}
/** Per-scene production status — only uses fields the API actually exposes. */
function sceneStatus(scene: Scene): { status: string; label: string } {
const job = jobForScene(scene.id);
if (job && (job.status === 'pending' || job.status === 'running')) {
return { status: 'generating', label: t('script.status.rendering') };
}
if (job?.status === 'failed') return { status: 'failed', label: t('script.status.renderFailed') };
if (scene.video_path || job?.status === 'done') {
return { status: 'ready', label: t('script.status.clipReady') };
}
return { status: 'idle', label: t('script.status.noClip') };
}
function locationName(scene: Scene): string | null {
if (scene.location_id == null) return null;
const locs = $assetsQuery.data?.locations ?? [];
return locs.find((l) => l.id === scene.location_id)?.name ?? null;
}
function avatarFor(char: { portrait_path: string | null; sheet_path: string | null }) {
return assetUrl(char.portrait_path ?? char.sheet_path);
}
function initialsOf(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
return parts
.slice(0, 2)
.map((p) => p[0].toUpperCase())
.join('');
}
/** Hide a broken avatar image (portrait moved/deleted on disk), revealing initials. */
function hideBrokenAvatar(e: Event) {
const img = e.currentTarget as HTMLImageElement | null;
if (img) img.style.display = 'none';
}
function formatClock(sec: number): string {
const s = Math.max(0, Math.round(sec));
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${r.toString().padStart(2, '0')}`;
}
async function move(sceneId: number, dir: -1 | 1) {
if (busy) return;
const list = $scenesQuery.data?.scenes ?? [];
const idx = list.findIndex((s) => s.id === sceneId);
const swap = idx + dir;
if (idx < 0 || swap < 0 || swap >= list.length) return;
const ids = list.map((s) => s.id);
[ids[idx], ids[swap]] = [ids[swap], ids[idx]];
await projects.reorderScenes(projectId, ids);
client.invalidateQueries({ queryKey: ['scenes'] });
}
/** Hand off script regeneration to a fresh, project-linked agent chat. */
function requestRegenerate() {
if (busy) return;
goto(agentDeepLink(projectId, 'script'));
}
function openEdit(scene: Scene) {
editing = { ...scene };
editOpen = true;
}
async function addScene() {
if (busy) return;
adding = true;
try {
const existing = $scenesQuery.data?.scenes ?? [];
const nextIndex = existing.length + 1;
const created = await projects.createScene(projectId, {
order_index: nextIndex,
heading: `NEW SCENE ${nextIndex}`,
action: '',
dialog: '',
duration_sec: 5,
});
// Keep order_index contiguous 1..n so Regenerate sees the real board size
const ids = [...existing.map((s) => s.id), created.id];
await projects.reorderScenes(projectId, ids);
await client.invalidateQueries({ queryKey: ['scenes'] });
await client.refetchQueries({ queryKey: ['scenes', projectId] });
editing = { ...created, order_index: nextIndex, heading: `NEW SCENE ${nextIndex}` };
editOpen = true;
highlightId = created.id;
await tick();
document
.getElementById(`scene-${created.id}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
toast.success(t('script.toast.sceneAdded', { num: nextIndex }));
window.setTimeout(() => {
if (highlightId === created.id) highlightId = null;
}, 2800);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.addSceneFail'));
} finally {
adding = false;
}
}
function requestDelete(scene: Scene) {
if (busy) return;
sceneToDelete = scene;
deleteOpen = true;
}
async function confirmDeleteScene() {
const scene = sceneToDelete;
sceneToDelete = null;
if (!scene || busy) return;
const label = scene.heading?.trim() || `Scene #${scene.order_index}`;
deletingId = scene.id;
try {
await projects.deleteScene(projectId, scene.id);
if (editing?.id === scene.id) {
editing = null;
editOpen = false;
}
const remaining = ($scenesQuery.data?.scenes ?? [])
.filter((s) => s.id !== scene.id)
.map((s) => s.id);
if (remaining.length > 0) {
await projects.reorderScenes(projectId, remaining);
}
await client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(t('script.toast.deleted', { name: label }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.deleteFailed'));
} finally {
deletingId = null;
}
}
</script>
<header class="stage-header">
<div>
<h2>3. Script</h2>
<p class="muted">
Estimated duration: {formatClock(totalSec)}
{#if targetSec > 0}
/ target ~{formatClock(targetSec)}
{/if}
{#if sceneCount > 0}
· {sceneCount} scenes
{#if totalClips > 0}
/ {totalClips} shot clips
{/if}
{/if}
</p>
</div>
<div class="stage-actions">
<Button variant="secondary" disabled={busy} loading={adding} onclick={addScene}>
<Icon name="plus" size={14} /> {t('script.addScene')}
</Button>
<Button
variant="secondary"
disabled={busy || expandingAll || expandingIds.length > 0 || sceneCount === 0}
loading={expandingAll}
onclick={breakAllIntoShots}
title={t('script.breakAllTitle')}
>
<Icon name="film" size={14} /> {t('script.breakAllIntoShots')}
</Button>
<Button variant="primary" disabled={busy} onclick={requestRegenerate}>
<Icon name="sparkle" size={15} /> {t('script.regenerate')}
</Button>
</div>
</header>
<div class="script-panel">
{#if $scenesQuery.isLoading}
<div class="card">{t('script.loadingScenes')}</div>
{:else if sceneCount === 0}
<EmptyState
title={t('script.noScenesTitle')}
body={t('script.noScenesBody')}
>
{#snippet icon()}
<Icon name="script" size={28} />
{/snippet}
{#snippet action()}
<Button variant="secondary" disabled={busy} loading={adding} onclick={addScene}>
<Icon name="plus" size={14} /> {t('script.addScene')}
</Button>
{/snippet}
</EmptyState>
{:else}
{#each scenes as scene, i (scene.id)}
{@const st = sceneStatus(scene)}
{@const locName = locationName(scene)}
<article
id="scene-{scene.id}"
class="card scene"
class:fresh={highlightId === scene.id}
class:deleting={deletingId === scene.id}
>
<div class="scene-head">
<div class="scene-title">
<span class="grip" aria-hidden="true"><Icon name="drag" size={14} /></span>
<span class="num">#{scene.order_index}</span>
<strong>{scene.heading || t('script.untitledScene')}</strong>
{#if highlightId === scene.id}
<span class="fresh-tag">{t('script.justAdded')}</span>
{/if}
<StatusChip status={st.status} label={st.label} />
</div>
<div class="actions">
<button
type="button"
class="icon-btn"
aria-label={t('script.moveSceneUp')}
title={t('script.moveSceneUp')}
disabled={busy || i === 0}
onclick={() => move(scene.id, -1)}
>
<Icon name="chevron-up" size={15} />
</button>
<button
type="button"
class="icon-btn"
aria-label={t('script.moveSceneDown')}
title={t('script.moveSceneDown')}
disabled={busy || i === sceneCount - 1}
onclick={() => move(scene.id, 1)}
>
<Icon name="chevron-down" size={15} />
</button>
<Button variant="secondary" size="sm" disabled={busy} onclick={() => openEdit(scene)}>
{t('script.edit')}
</Button>
<Button
variant="danger"
size="sm"
disabled={busy}
loading={deletingId === scene.id}
onclick={() => requestDelete(scene)}
>
{t('script.delete')}
</Button>
</div>
</div>
{#if scene.action}
<p class="muted">{scene.action}</p>
{:else}
<p class="muted empty-line">{t('script.noActionYet')}</p>
{/if}
{#if scene.dialog}
<pre class="dialog">{scene.dialog}</pre>
{/if}
<div class="clip-block">
<div class="clip-head">
<span class="clip-title">
<Icon name="film" size={12} />
{(scene.clips ?? []).length}
{((scene.clips ?? []).length === 1 ? t('script.shotClipSingular') : t('script.shotClipPlural', { n: (scene.clips ?? []).length }))}
{#if (scene.clips ?? []).some((c) => c.clip_path)}
· {t('script.renderedCount', { count: (scene.clips ?? []).filter((c) => c.clip_path).length })}
{/if}
</span>
<Button
variant="secondary"
size="sm"
disabled={busy || expandingIds.length > 0 || expandingAll}
loading={expandingIds.includes(scene.id)}
onclick={() => breakIntoShots(scene)}
title={isExpanded(scene) ? t('script.rebreakTitle') : t('script.breakTitle')}
>
{isExpanded(scene) ? t('script.rebreak') : t('script.break')}
</Button>
</div>
{#if isExpanded(scene) && (scene.clips ?? []).length > 1}
<ul class="clip-list">
{#each scene.clips ?? [] as clip (clip.id)}
{@const clipJob = jobForClip(clip.id)}
<li class="clip-row">
<span class="clip-num" class:ready={Boolean(clip.clip_path)}>
#{scene.order_index}.{clip.order_index}
</span>
<span class="clip-desc">{clip.description || t('script.untitledShot')}</span>
{#if clip.shot_size}
<span class="chip">{clip.shot_size}</span>
{/if}
{#if clipJob && (clipJob.status === 'pending' || clipJob.status === 'running')}
<span class="chip chip-render">{t('script.rendering')}</span>
{:else if clip.clip_path}
<span class="chip chip-ok">✓ {t('script.clipDone')}</span>
{/if}
{#if clip.duration_sec}
<span class="clip-dur">{clip.duration_sec}s</span>
{/if}
<button
type="button"
class="icon-btn"
aria-label={t('script.deleteShotAria', { id: `${scene.order_index}.${clip.order_index}` })}
title={t('script.deleteShotTitle')}
onclick={() => deleteClip(clip, scene)}
>
<Icon name="trash" size={13} />
</button>
</li>
{/each}
</ul>
{/if}
</div>
<div class="chips">
{#each scene.characters ?? [] as c (c.id)}
{@const avatar = avatarFor(c)}
<span class="chip">
<span class="avatar">
<span class="initials" aria-hidden="true">{initialsOf(c.name)}</span>
{#if avatar}
<img src={avatar} alt="" loading="lazy" onerror={hideBrokenAvatar} />
{/if}
</span>
{c.name}
</span>
{/each}
{#if locName}
<span class="chip"><Icon name="folder" size={12} /> {locName}</span>
{/if}
{#if scene.duration_sec}
<span class="chip"><Icon name="clock" size={12} /> {formatClock(scene.duration_sec)}</span>
{/if}
{#if i > 0}
<button
type="button"
class="chip chip-toggle"
class:chip-on={Boolean(scene.chain_from_prev)}
disabled={chainPendingId === scene.id}
title={t('script.chainTitle')}
onclick={() => toggleChain(scene)}
>
<Icon name="film" size={12} />
{Boolean(scene.chain_from_prev) ? t('script.chainOn') : t('script.chainOff')}
</button>
{/if}
</div>
</article>
{/each}
{/if}
</div>
<Modal
bind:open={editOpen}
title={editing ? t('script.editSceneNumbered', { n: editing.order_index }) : t('script.editScene')}
onclose={() => (editing = null)}
>
{#if editing}
<label class="field">
<span class="field-label">{t('script.headingField')}</span>
<input class="field-input" bind:value={editing.heading} placeholder={t('script.headingPlaceholder')} />
</label>
<label class="field">
<span class="field-label">{t('script.actionField')}</span>
<textarea
class="field-textarea"
bind:value={editing.action}
rows="4"
placeholder={t('script.actionPlaceholder')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('script.dialogField')}</span>
<textarea
class="field-textarea"
bind:value={editing.dialog}
rows="4"
placeholder={t('script.dialogPlaceholder')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('script.durationField')}</span>
<input class="field-input" type="number" bind:value={editing.duration_sec} min="1" />
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (editOpen = false)}>{t('common.cancel')}</Button>
<Button
variant="primary"
loading={$saveMutation.isPending}
onclick={() => $saveMutation.mutate()}
>
{t('script.save')}
</Button>
{/snippet}
</Modal>
<ConfirmDialog
bind:open={deleteOpen}
title={t('script.deleteSceneTitle')}
message={sceneToDelete
? t('script.deleteSceneMessage', {
heading: sceneToDelete.heading?.trim() || t('script.sceneNumber', { n: sceneToDelete.order_index }),
})
: ''}
confirmLabel={t('script.delete')}
danger
onconfirm={() => void confirmDeleteScene()}
oncancel={() => (sceneToDelete = null)}
/>
<style>
.stage-header {
/* Pins inside the stage scroll container so Add Scene / Regenerate
stay reachable while scrolling long scripts. */
position: sticky;
top: 0;
z-index: 5;
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-lg);
gap: var(--space-md);
padding: 8px 0 12px;
background: var(--bg-primary);
border-bottom: 1px solid var(--border);
}
.stage-header h2 {
margin: 0 0 4px;
font-size: 22px;
font-weight: 700;
}
.stage-actions {
display: flex;
gap: 8px;
}
.muted {
color: var(--text-secondary);
margin: 0;
}
.script-panel {
position: relative;
min-height: 220px;
}
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-lg);
margin-bottom: var(--space-md);
scroll-margin-top: 84px;
scroll-margin-bottom: 24px;
}
.scene {
transition:
border-color 0.25s ease,
box-shadow 0.25s ease,
opacity 0.2s ease;
}
.scene.fresh {
border-color: var(--accent);
box-shadow:
0 0 0 1px var(--accent),
0 0 24px var(--accent-glow);
animation: scene-in 0.45s ease-out;
}
.scene.deleting {
opacity: 0.45;
}
@keyframes scene-in {
from {
opacity: 0.35;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.scene.fresh {
animation: none;
}
}
.scene-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 8px;
}
.scene-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
}
.grip {
display: inline-flex;
color: var(--text-muted);
cursor: grab;
opacity: 0.6;
}
.num {
color: var(--accent);
font-weight: 700;
}
.fresh-tag {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
background: var(--accent-glow);
border-radius: 999px;
padding: 2px 8px;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.icon-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
border-color: var(--border);
}
.icon-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.icon-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.empty-line {
font-style: italic;
color: var(--text-muted);
}
.dialog {
background: var(--bg-elevated);
padding: 12px;
border-radius: var(--radius-sm);
white-space: pre-wrap;
font-family: var(--font-mono);
font-size: 13px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 10px;
font-size: 12px;
color: var(--text-secondary);
}
.chip-toggle {
cursor: pointer;
font-family: inherit;
}
.chip-toggle:hover {
color: var(--text-primary);
border-color: #52525b;
}
.chip-toggle:disabled {
opacity: 0.6;
cursor: wait;
}
.chip-on {
color: var(--accent);
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--bg-elevated));
}
.clip-block {
margin-top: 10px;
border-top: 1px dashed var(--border);
padding-top: 8px;
}
.clip-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.clip-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.clip-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.clip-row {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 4px 8px;
font-size: 12px;
}
.clip-num {
font-family: var(--font-mono);
font-weight: 700;
color: var(--text-secondary);
flex-shrink: 0;
}
.clip-num.ready {
color: var(--accent);
}
.clip-desc {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
}
.clip-dur {
color: var(--text-muted);
flex-shrink: 0;
}
.chip-ok {
color: var(--accent);
border-color: var(--accent);
}
.chip-render {
color: var(--warning, #eab308);
border-color: var(--warning, #eab308);
}
.avatar {
position: relative;
width: 20px;
height: 20px;
border-radius: 50%;
overflow: hidden;
background: var(--bg-surface);
border: 1px solid var(--border);
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.avatar img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.initials {
font-size: 9px;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.02em;
}
</style>
@@ -1,947 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
import {
projects,
type Beat,
type Character,
type Item,
type Location,
type StoryData,
} from '$lib/api';
import {
buildStoryDraftPromptPreview,
estimateTargetSeconds,
recommendBeatCount,
recommendSceneCount,
} from '$lib/durationBudget';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { agentDeepLink } from '$lib/agentTasks';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import StatusChip from '$lib/components/ui/StatusChip.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
interface Props {
projectId: number;
story: StoryData;
configured: boolean;
onGoAssets?: () => void;
}
let { projectId, story, configured, onGoAssets }: Props = $props();
const client = useQueryClient();
const GENRES = [
'Adventure / Mystery',
'Drama',
'Sci-Fi',
'Fantasy',
'Horror',
'Romance',
'Thriller',
];
const LENGTHS_HINT = $derived(t('story.lengthsHint'));
const TONES = [
'Cinematic, atmospheric',
'Dark, tense',
'Whimsical, warm',
'Gritty, realistic',
'Epic, sweeping',
];
const GENRE_KEYS: Record<string, string> = {
'Adventure / Mystery': 'genres.adventure',
Drama: 'genres.drama',
'Sci-Fi': 'genres.scifi',
Fantasy: 'genres.fantasy',
Horror: 'genres.horror',
Romance: 'genres.romance',
Thriller: 'genres.thriller',
};
const TONE_KEYS: Record<string, string> = {
'Cinematic, atmospheric': 'tones.cinematic',
'Dark, tense': 'tones.dark',
'Whimsical, warm': 'tones.whimsical',
'Gritty, realistic': 'tones.gritty',
'Epic, sweeping': 'tones.epic',
};
function genreLabel(v: string): string {
return t(GENRE_KEYS[v] ?? v);
}
function toneLabel(v: string): string {
return t(TONE_KEYS[v] ?? v);
}
const EXAMPLE = {
idea: 'A lone cartographer discovers a map that rewrites itself every midnight, leading her into a forgotten city beneath the desert.',
genre: 'Adventure / Mystery',
tone: 'Cinematic, atmospheric',
target_duration: '2 minutes',
};
let editingBeat = $state<Beat | null>(null);
let editingChar = $state<Character | null>(null);
let editingLoc = $state<Location | null>(null);
let editingItem = $state<Item | null>(null);
let beatModalOpen = $state(false);
let charModalOpen = $state(false);
let locModalOpen = $state(false);
let itemModalOpen = $state(false);
let confirmExampleOpen = $state(false);
let deletingBeat = $state<Beat | null>(null);
let beatDeleteOpen = $state(false);
let addingBeat = $state(false);
// Static defaults — the $effect below syncs all drafts from the story prop.
let ideaDraft = $state('');
let genreDraft = $state(GENRES[0]);
let toneDraft = $state(TONES[0]);
let lengthDraft = $state('2 minutes');
let showDraftPrompt = $state(false);
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
let saveState = $state<SaveState>('idle');
let savedTimer: ReturnType<typeof setTimeout> | null = null;
const beatBudget = $derived(recommendBeatCount(lengthDraft));
const sceneBudget = $derived(recommendSceneCount(lengthDraft));
const secsBudget = $derived(estimateTargetSeconds(lengthDraft));
const draftPromptPreview = $derived(
buildStoryDraftPromptPreview({
title: story.project.title,
idea: ideaDraft,
genre: genreDraft,
tone: toneDraft,
targetDuration: lengthDraft,
}),
);
$effect(() => {
ideaDraft = story.project.idea ?? '';
genreDraft = story.project.genre || GENRES[0];
toneDraft = story.project.tone || TONES[0];
lengthDraft = story.project.target_duration || '2 minutes';
});
$effect(() => {
return () => {
if (savedTimer) clearTimeout(savedTimer);
};
});
function armSavedTimer() {
if (savedTimer) clearTimeout(savedTimer);
savedTimer = setTimeout(() => {
if (saveState === 'saved') saveState = 'idle';
}, 2000);
}
const saveProject = createMutation({
mutationFn: (payload: {
idea?: string;
genre?: string;
tone?: string;
target_duration?: string;
}) => projects.update(projectId, payload),
onMutate: () => {
saveState = 'saving';
},
onSuccess: () => {
client.invalidateQueries({ queryKey: ['story'] });
saveState = 'saved';
armSavedTimer();
},
onError: (err) => {
saveState = 'error';
toast.error(err instanceof Error ? err.message : t('story.saveSettingsFailed'));
},
});
/** Autosave path — returns false on failure (mutation onError already toasted). */
async function persistSettings(): Promise<boolean> {
try {
await $saveProject.mutateAsync({
idea: ideaDraft,
genre: genreDraft || undefined,
tone: toneDraft || undefined,
target_duration: lengthDraft || undefined,
});
return true;
} catch {
return false;
}
}
async function loadExample() {
ideaDraft = EXAMPLE.idea;
genreDraft = EXAMPLE.genre;
toneDraft = EXAMPLE.tone;
lengthDraft = EXAMPLE.target_duration;
const saved = await persistSettings();
if (saved) toast.success(t('story.exampleLoaded'));
}
function requestLoadExample() {
if (ideaDraft.trim()) confirmExampleOpen = true;
else void loadExample();
}
/** Hand off to a fresh, project-linked agent chat with the composer pre-filled. */
async function draftStoryline() {
if (!configured) return;
const saved = await persistSettings();
if (!saved) return; // persistSettings already toasted the failure
goto(agentDeepLink(projectId, 'story'));
}
function openBeat(beat: Beat) {
editingBeat = { ...beat };
beatModalOpen = true;
}
async function addBeat() {
if (addingBeat) return;
addingBeat = true;
try {
const nextIndex =
story.beats.reduce((max, b) => Math.max(max, b.order_index), 0) + 1;
// No api.ts binding exists yet for POST /beats — call the endpoint directly.
const res = await fetch(`/api/projects/${projectId}/beats`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
order_index: nextIndex,
title: t('story.newBeat', { n: nextIndex }),
description: '',
}),
});
if (!res.ok) {
const body = await res.text().catch(() => 'unknown error');
throw new Error(`${res.status}: ${body}`);
}
const created = (await res.json()) as Beat;
await client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.beatAdded', { index: nextIndex }));
openBeat(created);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('story.addBeatFailed'));
} finally {
addingBeat = false;
}
}
function requestDeleteBeat(beat: Beat) {
deletingBeat = beat;
beatDeleteOpen = true;
}
const deleteBeatMutation = createMutation({
mutationFn: (beatId: number) => projects.deleteBeat(projectId, beatId),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.beatDeleted'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.deleteBeatFailed'));
},
});
const saveBeat = createMutation({
mutationFn: () =>
projects.updateBeat(projectId, editingBeat!.id, {
title: editingBeat!.title,
description: editingBeat!.description,
order_index: editingBeat!.order_index,
}),
onSuccess: () => {
editingBeat = null;
beatModalOpen = false;
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.beatSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.saveBeatFailed'));
},
});
const saveChar = createMutation({
mutationFn: () =>
projects.updateCharacter(projectId, editingChar!.id, {
name: editingChar!.name,
role: editingChar!.role,
age: editingChar!.age,
appearance: editingChar!.appearance,
personality: editingChar!.personality,
consistency_prompt: editingChar!.consistency_prompt,
}),
onSuccess: () => {
editingChar = null;
charModalOpen = false;
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.characterSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.saveCharacterFailed'));
},
});
const saveLoc = createMutation({
mutationFn: () =>
projects.updateLocation(projectId, editingLoc!.id, {
name: editingLoc!.name,
description: editingLoc!.description,
consistency_prompt: editingLoc!.consistency_prompt,
}),
onSuccess: () => {
editingLoc = null;
locModalOpen = false;
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.locationSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.saveLocationFailed'));
},
});
const saveItem = createMutation({
mutationFn: () =>
projects.updateItem(projectId, editingItem!.id, {
name: editingItem!.name,
description: editingItem!.description,
consistency_prompt: editingItem!.consistency_prompt,
}),
onSuccess: () => {
editingItem = null;
itemModalOpen = false;
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.itemSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.saveItemFailed'));
},
});
function goToAssets() {
goto('?stage=assets', { keepFocus: true, noScroll: true });
}
function beatCountLabel(n: number): string {
return t('story.beats', { n });
}
</script>
{#snippet saveIndicator()}
{#if saveState === 'saving'}
<span class="save-ind"><Spinner size="sm" /> {t('common.saving')}</span>
{:else if saveState === 'saved'}
<span class="save-ind saved"><Icon name="check" size={13} /> {t('common.saved')}</span>
{:else if saveState === 'error'}
<span class="save-ind err">
<Icon name="alert" size={13} /> {t('common.saveFailed')}
<Button variant="ghost" size="sm" onclick={() => void persistSettings()}>{t('common.retry')}</Button>
</span>
{/if}
{/snippet}
<header class="stage-header">
<h2>{t('story.stageTitle')}</h2>
<div class="stage-actions">
<Button
variant="secondary"
disabled={$saveProject.isPending}
onclick={requestLoadExample}
>
{t('story.loadExample')}
</Button>
<Button variant="primary" disabled={!configured} onclick={() => void draftStoryline()}>
<Icon name="sparkle" size={15} /> {t('story.draftStoryline')}
</Button>
</div>
</header>
{#if !configured}
<div class="banner">{t('story.llmBanner')}</div>
{/if}
<div class="stack-col">
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.ideaTitle')}</h3>
{@render saveIndicator()}
{/snippet}
<textarea
class="field-textarea"
bind:value={ideaDraft}
rows="5"
placeholder={t('story.ideaPlaceholder')}
onblur={() => void persistSettings()}
></textarea>
</Card>
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.settingsTitle')}</h3>
{@render saveIndicator()}
{/snippet}
<div class="grid-3">
<label class="field">
<span class="field-label">{t('projects.genre')}</span>
<select
class="field-select"
bind:value={genreDraft}
onchange={() => void persistSettings()}
>
{#each GENRES as g}
<option value={g}>{genreLabel(g)}</option>
{/each}
{#if genreDraft && !GENRES.includes(genreDraft)}
<option value={genreDraft}>{genreLabel(genreDraft)}</option>
{/if}
</select>
</label>
<label class="field">
<span class="field-label">{t('story.targetLength')}</span>
<input
class="field-input"
type="text"
bind:value={lengthDraft}
placeholder={LENGTHS_HINT}
onblur={() => void persistSettings()}
/>
<span class="field-hint beat-hint">
{t('story.lengthHint', { secs: secsBudget, beats: beatBudget, scenes: sceneBudget })}
</span>
</label>
<label class="field">
<span class="field-label">{t('projects.tone')}</span>
<select
class="field-select"
bind:value={toneDraft}
onchange={() => void persistSettings()}
>
{#each TONES as tone}
<option value={tone}>{toneLabel(tone)}</option>
{/each}
{#if toneDraft && !TONES.includes(toneDraft)}
<option value={toneDraft}>{toneLabel(toneDraft)}</option>
{/if}
</select>
</label>
</div>
</Card>
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.draftPromptTitle')}</h3>
<Button variant="ghost" size="sm" onclick={() => (showDraftPrompt = !showDraftPrompt)}>
{showDraftPrompt ? t('story.hidePrompt') : t('story.showPrompt')}
</Button>
{/snippet}
{#if showDraftPrompt}
<pre class="prompt-preview">{draftPromptPreview}</pre>
<p class="field-hint">
{t('story.promptExplain')}
</p>
{:else}
<p class="field-hint prompt-collapsed">
{t('story.promptCollapsedHint')}
</p>
{/if}
</Card>
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.beatsTitle')}</h3>
<div class="head-actions">
{#if story.beats.length}
<StatusChip status="ready" label={beatCountLabel(story.beats.length)} />
{/if}
<Button variant="secondary" size="sm" loading={addingBeat} onclick={addBeat}>
<Icon name="plus" size={14} /> {t('story.addBeat')}
</Button>
</div>
{/snippet}
{#if story.beats.length === 0}
<EmptyState
title={t('story.noBeats')}
body={t('story.noBeatsBody')}
>
{#snippet icon()}
<Icon name="story" size={28} />
{/snippet}
{#snippet action()}
<Button variant="secondary" size="sm" loading={addingBeat} onclick={addBeat}>
<Icon name="plus" size={14} /> {t('story.addBeatManual')}
</Button>
{/snippet}
</EmptyState>
{:else}
<div class="beat-list">
{#each story.beats as beat (beat.id)}
<div class="beat-row">
<button type="button" class="beat" onclick={() => openBeat(beat)}>
<div class="beat-number">{beat.order_index}</div>
<div class="beat-body">
<h4>{beat.title}</h4>
<p>{beat.description}</p>
</div>
</button>
<div class="beat-actions">
<Button
variant="ghost"
size="sm"
title={t('story.deleteBeat', { index: beat.order_index })}
onclick={() => requestDeleteBeat(beat)}
>
<Icon name="trash" size={14} /><span class="sr-only">{t('story.deleteBeat', { index: beat.order_index })}</span>
</Button>
</div>
</div>
{/each}
</div>
<div class="beats-footer">
<Button variant="primary" onclick={goToAssets}>
{t('story.continueAssets')} <Icon name="chevron-right" size={15} />
</Button>
</div>
{/if}
</Card>
{#if story.characters.length || story.locations.length || story.items.length}
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.extracted')}</h3>
<Button variant="ghost" size="sm" onclick={goToAssets}>
{t('story.openAssets')} <Icon name="chevron-right" size={14} />
</Button>
{/snippet}
<div class="chips">
{#each story.characters as char (char.id)}
<button
type="button"
class="chip"
onclick={() => {
editingChar = { ...char };
charModalOpen = true;
}}
title={char.appearance ?? ''}
>
<span class="chip-tag">{t('story.charTag')}</span>
{char.name}
</button>
{/each}
{#each story.locations as loc (loc.id)}
<button
type="button"
class="chip"
onclick={() => {
editingLoc = { ...loc };
locModalOpen = true;
}}
title={loc.description ?? ''}
>
<span class="chip-tag">{t('story.envTag')}</span>
{loc.name}
</button>
{/each}
{#each story.items as item (item.id)}
<button
type="button"
class="chip"
onclick={() => {
editingItem = { ...item };
itemModalOpen = true;
}}
title={item.description ?? ''}
>
<span class="chip-tag">{t('story.itemTag')}</span>
{item.name}
</button>
{/each}
</div>
</Card>
{/if}
</div>
<Modal bind:open={beatModalOpen} title={t('story.editBeatTitle')} onclose={() => (editingBeat = null)}>
{#if editingBeat}
<label class="field">
<span class="field-label">{t('story.fieldTitle')}</span>
<input class="field-input" bind:value={editingBeat.title} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldDescription')}</span>
<textarea class="field-textarea" rows="4" bind:value={editingBeat.description}></textarea>
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (beatModalOpen = false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={$saveBeat.isPending} onclick={() => $saveBeat.mutate()}>
{t('common.save')}
</Button>
{/snippet}
</Modal>
<Modal bind:open={charModalOpen} title={t('story.editCharTitle')} onclose={() => (editingChar = null)}>
{#if editingChar}
<label class="field">
<span class="field-label">{t('story.fieldName')}</span>
<input class="field-input" bind:value={editingChar.name} placeholder={t('story.fieldName')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldRole')}</span>
<input class="field-input" bind:value={editingChar.role} placeholder={t('story.fieldRole')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldAge')}</span>
<input class="field-input" bind:value={editingChar.age} placeholder={t('story.fieldAge')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldAppearance')}</span>
<textarea
class="field-textarea"
rows="3"
bind:value={editingChar.appearance}
placeholder={t('story.fieldAppearance')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('story.fieldPersonality')}</span>
<textarea
class="field-textarea"
rows="2"
bind:value={editingChar.personality}
placeholder={t('story.fieldPersonality')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('story.fieldConsistency')}</span>
<textarea
class="field-textarea"
rows="2"
bind:value={editingChar.consistency_prompt}
placeholder={t('story.fieldConsistency')}
></textarea>
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (charModalOpen = false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={$saveChar.isPending} onclick={() => $saveChar.mutate()}>
{t('common.save')}
</Button>
{/snippet}
</Modal>
<Modal bind:open={locModalOpen} title={t('story.editLocTitle')} onclose={() => (editingLoc = null)}>
{#if editingLoc}
<label class="field">
<span class="field-label">{t('story.fieldName')}</span>
<input class="field-input" bind:value={editingLoc.name} placeholder={t('story.fieldName')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldDescription')}</span>
<textarea
class="field-textarea"
rows="3"
bind:value={editingLoc.description}
placeholder={t('story.fieldDescription')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('story.fieldConsistency')}</span>
<textarea
class="field-textarea"
rows="2"
bind:value={editingLoc.consistency_prompt}
placeholder={t('story.fieldConsistency')}
></textarea>
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (locModalOpen = false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={$saveLoc.isPending} onclick={() => $saveLoc.mutate()}>
{t('common.save')}
</Button>
{/snippet}
</Modal>
<Modal bind:open={itemModalOpen} title={t('story.editItemTitle')} onclose={() => (editingItem = null)}>
{#if editingItem}
<label class="field">
<span class="field-label">{t('story.fieldName')}</span>
<input class="field-input" bind:value={editingItem.name} placeholder={t('story.fieldName')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldDescription')}</span>
<textarea
class="field-textarea"
rows="3"
bind:value={editingItem.description}
placeholder={t('story.fieldDescription')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('story.fieldConsistency')}</span>
<textarea
class="field-textarea"
rows="2"
bind:value={editingItem.consistency_prompt}
placeholder={t('story.fieldConsistency')}
></textarea>
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (itemModalOpen = false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={$saveItem.isPending} onclick={() => $saveItem.mutate()}>
{t('common.save')}
</Button>
{/snippet}
</Modal>
<ConfirmDialog
bind:open={confirmExampleOpen}
title={t('story.confirmExampleTitle')}
message={t('story.confirmExampleMsg')}
confirmLabel={t('story.loadExample')}
onconfirm={() => void loadExample()}
/>
<ConfirmDialog
bind:open={beatDeleteOpen}
title={t('story.confirmDeleteBeatTitle')}
message={deletingBeat
? t('story.confirmDeleteBeatMsg', { index: deletingBeat.order_index, title: deletingBeat.title })
: ''}
confirmLabel={t('common.delete')}
danger
onconfirm={() => {
const id = deletingBeat?.id;
deletingBeat = null;
if (id != null) $deleteBeatMutation.mutate(id);
}}
oncancel={() => (deletingBeat = null)}
/>
<style>
.stage-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-lg);
gap: var(--space-md);
}
.stage-header h2 {
margin: 0;
font-size: 22px;
font-weight: 700;
}
.stage-actions {
display: flex;
gap: var(--space-sm);
}
.banner {
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
color: var(--warning);
padding: var(--space-md);
border-radius: var(--radius-md);
margin-bottom: var(--space-lg);
font-size: 13px;
}
.stack-col {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.card-h {
margin: 0;
font-size: 16px;
color: var(--text-secondary);
font-weight: 600;
}
.head-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.save-ind {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
}
.save-ind.saved {
color: var(--success);
animation: saved-fade 2s ease forwards;
}
.save-ind.err {
color: var(--error);
}
@keyframes saved-fade {
0% {
opacity: 0;
}
12% {
opacity: 1;
}
78% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-md);
}
@media (max-width: 900px) {
.grid-3 {
grid-template-columns: 1fr;
}
}
.grid-3 .field {
margin-bottom: 0;
}
.beat-hint {
display: block;
}
.beat-hint strong {
color: var(--accent);
font-weight: 600;
}
.prompt-preview {
margin: 0 0 var(--space-sm);
padding: 12px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.45;
white-space: pre-wrap;
color: var(--text-secondary);
max-height: 280px;
overflow: auto;
}
.prompt-collapsed {
margin: 0;
}
.beat-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.beat-row {
display: flex;
align-items: flex-start;
gap: var(--space-xs);
}
.beat {
display: flex;
flex: 1;
min-width: 0;
gap: var(--space-md);
align-items: flex-start;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
padding: 8px;
cursor: pointer;
text-align: left;
color: inherit;
font: inherit;
}
.beat:hover {
border-color: var(--border);
background: var(--bg-elevated);
}
.beat:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.beat-actions {
flex-shrink: 0;
padding-top: 8px;
}
.beat-number {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: var(--text-secondary);
flex-shrink: 0;
}
.beat-body h4 {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
}
.beat-body p {
margin: 0;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.45;
}
.beats-footer {
display: flex;
justify-content: flex-end;
margin-top: var(--space-md);
padding-top: var(--space-md);
border-top: 1px solid var(--border);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
}
.chip:hover {
border-color: var(--accent);
}
.chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.chip-tag {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--accent);
font-weight: 700;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
@@ -357,16 +357,13 @@
.empty-mark {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
border-radius: var(--r-md);
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
background:
radial-gradient(circle at 30% 25%, rgba(139, 92, 246, 0.35) 0%, transparent 60%),
var(--bg-elevated);
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
box-shadow: 0 0 32px var(--accent-glow);
color: var(--text-2);
background: var(--bg-3);
border: 1px solid var(--line-2);
margin-bottom: 6px;
}
.empty-title {
@@ -391,21 +388,24 @@
margin-top: 16px;
}
.suggestion {
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-secondary);
border-radius: 999px;
padding: 8px 16px;
font-size: 13px;
border: 1px solid var(--line);
background: var(--bg-2);
color: var(--text-2);
border-radius: var(--r-pill);
padding: var(--sp-3) var(--sp-6);
font-size: var(--fs-body);
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
transition:
color var(--d-state) ease,
border-color var(--d-state) ease,
background-color var(--d-state) ease;
}
.suggestion:hover {
color: var(--text-primary);
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--bg-surface));
color: var(--text);
border-color: var(--line-2);
background: var(--bg-4);
}
.suggestion:focus-visible {
outline: 2px solid var(--accent);
@@ -430,11 +430,11 @@
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-size: var(--fs-mini);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--agent, var(--accent));
color: var(--agent, var(--text-2));
margin-bottom: 4px;
padding-left: 2px;
}
@@ -443,7 +443,7 @@
height: 8px;
border-radius: 999px;
flex-shrink: 0;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent, var(--accent)) 25%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent, var(--text-2)) 25%, transparent);
}
.reasoning-wrap {
margin-bottom: 4px;
@@ -451,28 +451,29 @@
}
.bubble {
max-width: 86%;
border-radius: var(--radius-md);
border-radius: var(--r-lg);
padding: 10px 14px;
font-size: 13.5px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
/* 对话气泡按规范 §3.2/§3.6:用户 --bg-3,Agent --bg-2 + --line 描边。 */
.user-bubble {
background: color-mix(in srgb, var(--accent) 16%, var(--bg-surface));
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
color: var(--text-primary);
background: var(--bg-3);
border: 1px solid var(--line);
color: var(--text);
}
.user-bubble.steering {
border-style: dashed;
}
.steer-tag {
display: inline-block;
font-size: 10px;
font-size: var(--fs-mini);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
color: var(--text-3);
margin-bottom: 4px;
}
.user-thumbs {
@@ -486,36 +487,37 @@
width: 48px;
height: 48px;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
background: var(--bg-elevated);
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: var(--bg-3);
}
.user-thumb.file {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
color: var(--text-3);
}
.user-text {
white-space: pre-wrap;
}
/* 工作流提及为次级内容:中性底 + --text,不占金色。 */
.wf-chip {
display: inline;
padding: 1px 7px;
margin: 0 1px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 22%, var(--bg-elevated));
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
color: var(--accent);
border-radius: var(--r-pill);
background: var(--bg-4);
border: 1px solid var(--line-2);
color: var(--text);
font-weight: 600;
font-size: 13px;
font-size: var(--fs-body);
white-space: nowrap;
}
.assistant-bubble {
background: var(--bg-surface);
border: 1px solid var(--border);
border-left: 3px solid var(--agent, var(--accent));
color: var(--text-primary);
background: var(--bg-2);
border: 1px solid var(--line);
border-left: 3px solid var(--agent, var(--text-2));
color: var(--text);
}
.question-card {
display: flex;
@@ -525,15 +527,15 @@
max-width: 420px;
margin: 2px 0 6px;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--accent) 7%, var(--bg-surface));
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-2);
}
.question-text {
margin: 0;
font-size: 13px;
font-size: var(--fs-body);
line-height: 1.5;
color: var(--text-primary);
color: var(--text);
white-space: pre-wrap;
}
.question-options {
@@ -542,40 +544,39 @@
gap: 6px;
}
.question-option {
border: 1px solid color-mix(in srgb, var(--agent, var(--accent)) 45%, transparent);
background: var(--bg-elevated);
color: var(--text-primary);
border-radius: var(--radius-sm);
border: 1px solid var(--line-2);
background: var(--bg-3);
color: var(--text);
border-radius: var(--r-sm);
padding: 8px 12px;
font-size: 13px;
font-size: var(--fs-body);
font-weight: 600;
cursor: pointer;
text-align: left;
transition:
background 120ms ease,
border-color 120ms ease;
background-color var(--d-press) ease,
border-color var(--d-press) ease;
}
.question-option:hover:enabled {
background: color-mix(in srgb, var(--agent, var(--accent)) 18%, var(--bg-elevated));
border-color: var(--agent, var(--accent));
background: var(--bg-4);
border-color: var(--agent, var(--text-2));
}
.question-option:disabled {
opacity: 0.5;
cursor: default;
}
.question-option.affirmative {
border-color: var(--accent);
color: var(--accent);
border-color: var(--line-2);
}
.question-hint {
margin: 0;
font-size: 11px;
color: var(--text-muted);
font-size: var(--fs-mini);
color: var(--text-3);
}
.assistant-bubble.err {
border-color: rgba(239, 68, 68, 0.4);
border-left-color: rgba(239, 68, 68, 0.6);
color: var(--error);
border-color: var(--err-line);
border-left-color: var(--err);
color: var(--err);
}
.bubble.live {
display: flex;
@@ -604,7 +605,7 @@
width: 7px;
height: 14px;
margin-left: 2px;
background: var(--accent);
background: var(--run);
animation: blink 1s step-end infinite;
vertical-align: text-bottom;
}
@@ -617,14 +618,15 @@
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 13px;
color: var(--text-2);
font-size: var(--fs-body);
}
/* 进行中指示 = 信号青(§1.3),不占金色。 */
.pulse {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--accent);
background: var(--run);
animation: pulse 1.1s ease-in-out infinite;
}
@keyframes pulse {
@@ -711,12 +711,12 @@ let {
display: inline;
padding: 1px 7px;
margin: 0 1px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 22%, var(--bg-elevated));
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
color: var(--accent);
border-radius: var(--r-pill);
background: var(--bg-4);
border: 1px solid var(--line-2);
color: var(--text);
font-weight: 600;
font-size: 13px;
font-size: var(--fs-body);
white-space: nowrap;
user-select: none;
}
@@ -724,12 +724,12 @@ let {
display: inline;
padding: 1px 7px;
margin: 0 1px;
border-radius: 999px;
background: color-mix(in srgb, #38bdf8 20%, var(--bg-elevated));
border: 1px solid color-mix(in srgb, #38bdf8 45%, transparent);
color: #7dd3fc;
border-radius: var(--r-pill);
background: var(--bg-4);
border: 1px dashed var(--line-2);
color: var(--text-2);
font-weight: 600;
font-size: 13px;
font-size: var(--fs-body);
white-space: nowrap;
user-select: none;
}
@@ -77,7 +77,7 @@
}
.head:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.03);
background: var(--bg-3);
}
.name {
flex: 1;
@@ -68,7 +68,7 @@
}
.head:hover {
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.02);
background: var(--bg-3);
}
.icon-wrap {
display: inline-flex;
@@ -135,9 +135,7 @@
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(0, 0, 0, 0.3);
box-shadow: var(--shadow-float);
padding: 4px;
}
.empty {
@@ -1,22 +1,23 @@
/**
* Agent identity palette: a stable display name + accent color per agent.
*
* The main loop runs with `agent_name = null`; we label it "Calliope". Each
* swarm sub-agent ("planner", "story-agent", …) gets its own fixed color so
* differently-authored bubbles are visually distinct, plus a deterministic
* fallback for any future agent name.
* Identity colors answer "who is speaking" only (avatar / name / bubble
* tint) — they never express task status (spec v2 §3.6). The curated set
* reuses the spec §3.6 avatar five-color family plus one neutral slate, so
* no tailwind rainbow hues leak into the fusion palette; the default
* main-loop agent is neutral `--text-2` slate.
*/
export const DEFAULT_AGENT_NAME = 'Calliope';
// Fixed identity map (keyed by the raw agent_name the backend emits).
const IDENTITIES: Record<string, { label: string; color: string }> = {
planner: { label: 'Planner', color: '#22d3ee' }, // cyan
'story-agent': { label: 'Story Agent', color: '#22c55e' }, // green
'script-agent': { label: 'Script Agent', color: '#f59e0b' }, // amber
'assets-agent': { label: 'Assets Agent', color: '#ec4899' }, // pink
'video-agent': { label: 'Video Agent', color: '#fb923c' }, // orange
'research-agent': { label: 'Research Agent', color: '#a78bfa' }, // violet
planner: { label: 'Planner', color: '#5b8def' }, // avatar-2 blue
'story-agent': { label: 'Story Agent', color: '#3fb97f' }, // avatar-3 green
'script-agent': { label: 'Script Agent', color: '#e8a33d' }, // avatar-1 amber
'assets-agent': { label: 'Assets Agent', color: '#b98be0' }, // avatar-4 violet
'video-agent': { label: 'Video Agent', color: '#d98e6a' }, // avatar-5 clay
'research-agent': { label: 'Research Agent', color: '#8fa3c8' }, // neutral slate
// The five named agents (HEL-613) — the runner stamps their zh names on
// named-session replies; colors mirror the spec §2.1 avatar five-set.
剧本创作: { label: '剧本创作', color: '#e8a33d' },
@@ -26,19 +27,9 @@ const IDENTITIES: Record<string, { label: string; color: string }> = {
镜头脚本: { label: '镜头脚本', color: '#d98e6a' },
};
// Fallback palette for unknown names (stable hash → deterministic color).
const FALLBACK_COLORS = [
'#22d3ee',
'#22c55e',
'#f59e0b',
'#ec4899',
'#fb923c',
'#a78bfa',
'#38bdf8',
'#4ade80',
'#f472b6',
'#facc15',
];
// Fallback palette for unknown names (stable hash → deterministic color),
// narrowed to the avatar identity family + neutral slate (spec v2 §十.4).
const FALLBACK_COLORS = ['#5b8def', '#3fb97f', '#e8a33d', '#b98be0', '#d98e6a', '#8fa3c8'];
function hashString(s: string): number {
let h = 0;
@@ -48,8 +39,8 @@ function hashString(s: string): number {
return h;
}
/** The default "Calliope" accent (matches --accent). */
export const DEFAULT_AGENT_COLOR = '#8b5cf6';
/** Neutral slate (--text-2) — the default agent borrows no signal color. */
export const DEFAULT_AGENT_COLOR = '#aab2c5';
export function agentDisplayName(name: string | null | undefined): string {
const key = (name || '').trim().toLowerCase();
@@ -381,7 +381,7 @@
white-space: nowrap;
}
.arrow {
color: var(--accent);
color: var(--text-2);
font-size: 13px;
opacity: 0;
transform: translateX(-6px);
@@ -9,6 +9,7 @@ export type NavId =
| 'agents'
| 'connections'
| 'models'
| 'library'
| 'settings';
export interface NavItem {
@@ -33,6 +34,7 @@ export const NAV_MAIN: NavItem[] = [
export const NAV_FOOT: NavItem[] = [
{ id: 'connections', labelKey: 'shell.navConnections', icon: 'link', href: '/connections' },
{ id: 'models', labelKey: 'shell.navModels', icon: 'assets', href: '/models' },
{ id: 'library', labelKey: 'shell.navLibrary', icon: 'folder', href: '/library' },
{ id: 'settings', labelKey: 'shell.navSettings', icon: 'settings', href: '/settings' },
];
@@ -81,12 +81,12 @@
{#each draft as asset (asset.id)}
<div class="asset-edit">
<div class="asset-edit-head">
<select class="wb-input w-kind" bind:value={asset.kind}>
<select class="a-input w-kind" bind:value={asset.kind}>
<option value="character">{t('workbench.asset.kindCharacter')}</option>
<option value="location">{t('workbench.asset.kindLocation')}</option>
<option value="prop">{t('workbench.asset.kindProp')}</option>
</select>
<input class="wb-input name" bind:value={asset.name} placeholder={t('workbench.asset.namePh')} />
<input class="a-input name" bind:value={asset.name} placeholder={t('workbench.asset.namePh')} />
<button
type="button"
class="icon-btn danger"
@@ -96,15 +96,15 @@
<Icon name="trash" size={13} />
</button>
</div>
<input class="wb-input" bind:value={asset.description} placeholder={t('workbench.asset.descPh')} />
<input class="a-input" bind:value={asset.description} placeholder={t('workbench.asset.descPh')} />
<textarea
class="wb-input mono"
class="a-input mono"
rows="2"
bind:value={asset.prompt}
placeholder={t('workbench.asset.promptPh')}
></textarea>
<input
class="wb-input mono"
class="a-input mono"
bind:value={asset.negative_prompt}
placeholder={t('workbench.style.negativePh')}
/>
@@ -112,17 +112,17 @@
{/each}
</div>
<div class="editor-foot">
<button type="button" class="btn ghost" onclick={addAsset}>
<button type="button" class="a-btn ghost" onclick={addAsset}>
<Icon name="plus" size={13} />
{t('workbench.asset.add')}
</button>
<div class="foot-btns">
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
{t('workbench.cancelEdit')}
</button>
<button
type="button"
class="btn primary"
class="a-btn primary"
disabled={!draft.some((a) => a.name.trim() && a.prompt.trim())}
onclick={save}
>
@@ -135,7 +135,7 @@
<div class="todo-card">
<p class="todo-title">{t('workbench.asset.todoTitle')}</p>
<p class="todo-body">{t('workbench.asset.todoBody')}</p>
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
<Icon name="sparkle" size={14} />
{t('workbench.asset.generate')}
</button>
@@ -192,7 +192,7 @@
{/if}
<button
type="button"
class="btn ghost gen"
class="a-btn ghost sm"
disabled={busy || jobActive(asset.job)}
title={t('workbench.asset.generateHint')}
onclick={() =>
@@ -214,11 +214,11 @@
<span class="hint">{t('workbench.asset.summary', { n: assets.length })}</span>
<div class="foot-btns">
{#if stage.status !== 'running'}
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
<Icon name="edit" size={13} />
{t('workbench.editDraft')}
</button>
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
<button type="button" class="a-btn ghost" disabled={busy} onclick={() => onRun?.()}>
<Icon name="retry" size={13} />
{t('workbench.regenerate')}
</button>
@@ -378,34 +378,14 @@
gap: 8px;
}
.wb-input {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
color: var(--text);
font-family: inherit;
font-size: 12.5px;
line-height: 1.6;
transition: border-color 150ms;
}
.wb-input:focus {
outline: none;
border-color: var(--accent-line);
}
.wb-input::placeholder {
color: var(--text-3);
}
.wb-input.mono {
.a-input.mono {
font-family: var(--font-a-mono);
font-size: 11.5px;
}
.wb-input.name {
.a-input.name {
font-weight: 600;
}
.wb-input.w-kind {
.a-input.w-kind {
width: 92px;
flex-shrink: 0;
}
@@ -432,44 +412,4 @@
color: var(--error);
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms,
opacity 150ms;
}
.btn.primary {
background: var(--accent);
color: var(--accent-contrast);
font-weight: 600;
}
.btn.primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn.ghost {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text-2);
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: var(--line-2);
}
.btn.gen {
color: var(--accent);
border-color: var(--border-strong);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
@@ -83,7 +83,7 @@
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
background: var(--run);
animation: wb-chip-breath 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
@keyframes wb-chip-breath {
@@ -56,15 +56,15 @@
<blockquote class="idea-quote">{projectIdea}</blockquote>
{/if}
<div class="todo-actions">
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
<Icon name="sparkle" size={14} />
{t('workbench.script.generate')}
</button>
<button type="button" class="btn ghost" onclick={() => (importOpen = !importOpen)}>
<button type="button" class="a-btn ghost" onclick={() => (importOpen = !importOpen)}>
<Icon name="upload" size={14} />
{t('workbench.script.importBtn')}
</button>
<button type="button" class="btn ghost" onclick={startEdit}>
<button type="button" class="a-btn ghost" onclick={startEdit}>
<Icon name="edit" size={14} />
{t('workbench.script.writeManually')}
</button>
@@ -72,7 +72,7 @@
{#if importOpen}
<div class="import-box">
<textarea
class="wb-input"
class="a-input"
rows="8"
bind:value={importText}
placeholder={t('workbench.script.importPlaceholder')}
@@ -81,7 +81,7 @@
<span class="hint">{t('workbench.script.importHint')}</span>
<button
type="button"
class="btn primary sm"
class="a-btn primary sm"
disabled={importText.trim().length < 30}
onclick={doImport}
>
@@ -94,7 +94,7 @@
{:else if editing}
<div class="editor">
<textarea
class="wb-input mono-area"
class="a-input mono-area"
rows="18"
bind:value={draft}
data-testid="script-editor"
@@ -102,12 +102,12 @@
<div class="editor-foot">
<span class="hint">{t('workbench.script.editorHint')}</span>
<div class="foot-btns">
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
{t('workbench.cancelEdit')}
</button>
<button
type="button"
class="btn primary"
class="a-btn primary"
disabled={draft.trim().length < 30}
onclick={save}
>
@@ -122,13 +122,13 @@
<span class="char-count mono">{t('workbench.charCount', { n: charCount })}</span>
<div class="foot-btns">
{#if stage.status !== 'running'}
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
<Icon name="edit" size={13} />
{t('workbench.editDraft')}
</button>
<button
type="button"
class="btn ghost"
class="a-btn ghost"
disabled={busy}
onclick={() => onRun?.()}
title={t('workbench.regenerateHint')}
@@ -243,65 +243,5 @@
color: var(--text-3);
}
.wb-input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
color: var(--text);
font-family: inherit;
font-size: 13px;
line-height: 1.7;
transition: border-color 150ms;
}
.wb-input:focus {
outline: none;
border-color: var(--accent-line);
}
.wb-input::placeholder {
color: var(--text-3);
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms,
opacity 150ms;
}
.btn.primary {
background: var(--accent);
color: var(--accent-contrast);
font-weight: 600;
}
.btn.primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn.ghost {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text-2);
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: var(--line-2);
}
.btn.sm {
padding: 6px 12px;
font-size: 12px;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
@@ -74,9 +74,9 @@
{#each draft as shot (shot.id)}
<div class="vshot-edit">
<div class="vshot-edit-head">
<input class="wb-input w-no mono" bind:value={shot.shot_no} placeholder={t('workbench.vshot.noPh')} />
<input class="a-input w-no mono" bind:value={shot.shot_no} placeholder={t('workbench.vshot.noPh')} />
<input
class="wb-input w-dur mono"
class="a-input w-dur mono"
type="number"
min="1"
step="1"
@@ -92,9 +92,9 @@
<Icon name="trash" size={13} />
</button>
</div>
<input class="wb-input" bind:value={shot.description} placeholder={t('workbench.vshot.descPh')} />
<input class="a-input" bind:value={shot.description} placeholder={t('workbench.vshot.descPh')} />
<textarea
class="wb-input mono"
class="a-input mono"
rows="3"
bind:value={shot.prompt}
placeholder={t('workbench.vshot.promptPh')}
@@ -103,17 +103,17 @@
{/each}
</div>
<div class="editor-foot">
<button type="button" class="btn ghost" onclick={addShot}>
<button type="button" class="a-btn ghost" onclick={addShot}>
<Icon name="plus" size={13} />
{t('workbench.vshot.add')}
</button>
<div class="foot-btns">
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
{t('workbench.cancelEdit')}
</button>
<button
type="button"
class="btn primary"
class="a-btn primary"
disabled={!draft.some((s) => s.prompt.trim())}
onclick={save}
>
@@ -126,7 +126,7 @@
<div class="todo-card">
<p class="todo-title">{t('workbench.vshot.todoTitle')}</p>
<p class="todo-body">{t('workbench.vshot.todoBody')}</p>
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
<Icon name="sparkle" size={14} />
{t('workbench.vshot.generate')}
</button>
@@ -180,7 +180,7 @@
{/if}
<button
type="button"
class="btn ghost gen"
class="a-btn ghost sm"
disabled={busy || jobActive(shot.job)}
title={t('workbench.vshot.generateHint')}
onclick={() =>
@@ -202,11 +202,11 @@
<span class="hint">{t('workbench.vshot.summary', { n: shots.length })}</span>
<div class="foot-btns">
{#if stage.status !== 'running'}
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
<Icon name="edit" size={13} />
{t('workbench.editDraft')}
</button>
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
<button type="button" class="a-btn ghost" disabled={busy} onclick={() => onRun?.()}>
<Icon name="retry" size={13} />
{t('workbench.regenerate')}
</button>
@@ -260,9 +260,10 @@
flex-wrap: wrap;
}
.vshot-no {
font-size: 13px;
font-family: var(--mono);
font-size: var(--fs-body);
font-weight: 700;
color: var(--accent);
color: var(--accent-lo);
font-variant-numeric: tabular-nums;
}
.vshot-dur {
@@ -279,9 +280,9 @@
.vshot-video {
width: 100%;
max-height: 300px;
border-radius: 8px;
border: 1px solid var(--border);
background: #000;
border-radius: var(--r-md);
border: 1px solid var(--line);
background: var(--media-scrim);
}
.vshot-foot {
display: flex;
@@ -347,35 +348,15 @@
gap: 8px;
}
.wb-input {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
color: var(--text);
font-family: inherit;
font-size: 12.5px;
line-height: 1.6;
transition: border-color 150ms;
}
.wb-input:focus {
outline: none;
border-color: var(--accent-line);
}
.wb-input::placeholder {
color: var(--text-3);
}
.wb-input.mono {
.a-input.mono {
font-family: var(--font-a-mono);
font-size: 11.5px;
}
.wb-input.w-no {
.a-input.w-no {
width: 90px;
flex-shrink: 0;
}
.wb-input.w-dur {
.a-input.w-dur {
width: 110px;
flex-shrink: 0;
}
@@ -403,44 +384,4 @@
color: var(--error);
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms,
opacity 150ms;
}
.btn.primary {
background: var(--accent);
color: var(--accent-contrast);
font-weight: 600;
}
.btn.primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn.ghost {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text-2);
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: var(--line-2);
}
.btn.gen {
color: var(--accent);
border-color: var(--border-strong);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
@@ -50,7 +50,7 @@
function stateLabel(stage: WorkbenchStage): string {
const s = stateOf(stage);
if (s === 'running') return t('workbench.state.running');
if (s === 'confirmed') return t('workbench.state.confirmed');
if (s === 'confirmed') return `✓ ${t('workbench.state.confirmed')}`;
if (s === 'edited') return t('workbench.state.edited');
if (s === 'draft') return t('workbench.state.draft');
return t('workbench.state.todo');
@@ -144,6 +144,14 @@
.step-btn.active {
background: var(--surface-2);
border-color: var(--border-strong);
/* §3.2 当前段:金色下划 2px 内阴影 + 段名金色。 */
box-shadow: inset 0 -2px 0 var(--accent);
}
.step-btn.active .step-name {
color: var(--accent);
}
.step-btn[data-state='confirmed']:not(.active) .step-name {
color: var(--success);
}
.step-btn.locked {
opacity: 0.55;
@@ -71,11 +71,11 @@
</button>
</div>
<div class="shot-fields">
<input class="wb-input w-xs" bind:value={shot.scene} placeholder={t('workbench.board.scene')} />
<input class="wb-input w-xs" bind:value={shot.shot_size} placeholder={t('workbench.board.size')} />
<input class="wb-input w-xs" bind:value={shot.camera} placeholder={t('workbench.board.camera')} />
<input class="a-input w-xs" bind:value={shot.scene} placeholder={t('workbench.board.scene')} />
<input class="a-input w-xs" bind:value={shot.shot_size} placeholder={t('workbench.board.size')} />
<input class="a-input w-xs" bind:value={shot.camera} placeholder={t('workbench.board.camera')} />
<input
class="wb-input w-xs mono"
class="a-input w-xs mono"
type="number"
min="0.5"
step="0.5"
@@ -84,27 +84,27 @@
/>
</div>
<textarea
class="wb-input"
class="a-input"
rows="2"
bind:value={shot.description}
placeholder={t('workbench.board.descPh')}
></textarea>
<input class="wb-input" bind:value={shot.dialog} placeholder={t('workbench.board.dialogPh')} />
<input class="a-input" bind:value={shot.dialog} placeholder={t('workbench.board.dialogPh')} />
</div>
{/each}
</div>
<div class="editor-foot">
<button type="button" class="btn ghost" onclick={addShot}>
<button type="button" class="a-btn ghost" onclick={addShot}>
<Icon name="plus" size={13} />
{t('workbench.board.add')}
</button>
<div class="foot-btns">
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
{t('workbench.cancelEdit')}
</button>
<button
type="button"
class="btn primary"
class="a-btn primary"
disabled={!draft.some((s) => s.description.trim())}
onclick={save}
>
@@ -117,7 +117,7 @@
<div class="todo-card">
<p class="todo-title">{t('workbench.board.todoTitle')}</p>
<p class="todo-body">{t('workbench.board.todoBody')}</p>
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
<Icon name="sparkle" size={14} />
{t('workbench.board.generate')}
</button>
@@ -149,11 +149,11 @@
<span class="hint mono">{t('workbench.board.summary', { n: shots.length, sec: totalDuration })}</span>
<div class="foot-btns">
{#if stage.status !== 'running'}
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
<Icon name="edit" size={13} />
{t('workbench.editDraft')}
</button>
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
<button type="button" class="a-btn ghost" disabled={busy} onclick={() => onRun?.()}>
<Icon name="retry" size={13} />
{t('workbench.regenerate')}
</button>
@@ -282,31 +282,11 @@
flex-wrap: wrap;
gap: 8px;
}
.wb-input {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
color: var(--text);
font-family: inherit;
font-size: 12.5px;
line-height: 1.6;
transition: border-color 150ms;
}
.wb-input:focus {
outline: none;
border-color: var(--accent-line);
}
.wb-input::placeholder {
color: var(--text-3);
}
.wb-input.mono {
.a-input.mono {
font-family: var(--font-a-mono);
font-size: 11.5px;
}
.wb-input.w-xs {
.a-input.w-xs {
width: 110px;
flex: 1;
min-width: 96px;
@@ -356,40 +336,4 @@
color: var(--error);
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms,
opacity 150ms;
}
.btn.primary {
background: var(--accent);
color: var(--accent-contrast);
font-weight: 600;
}
.btn.primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn.ghost {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text-2);
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: var(--line-2);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
@@ -67,7 +67,7 @@
<div class="dir-edit">
<div class="dir-edit-head">
<span class="dir-index mono">{i + 1}</span>
<input class="wb-input name" bind:value={dir.name} placeholder={t('workbench.style.namePh')} />
<input class="a-input name" bind:value={dir.name} placeholder={t('workbench.style.namePh')} />
<button
type="button"
class="icon-btn danger"
@@ -78,35 +78,35 @@
</button>
</div>
<input
class="wb-input"
class="a-input"
bind:value={dir.description}
placeholder={t('workbench.style.descPh')}
/>
<textarea
class="wb-input mono"
class="a-input mono"
rows="2"
bind:value={dir.prompt}
placeholder={t('workbench.style.promptPh')}
></textarea>
<input
class="wb-input mono"
class="a-input mono"
bind:value={dir.negative_prompt}
placeholder={t('workbench.style.negativePh')}
/>
</div>
{/each}
<div class="editor-foot">
<button type="button" class="btn ghost" onclick={addDirection}>
<button type="button" class="a-btn ghost" onclick={addDirection}>
<Icon name="plus" size={13} />
{t('workbench.style.add')}
</button>
<div class="foot-btns">
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
{t('workbench.cancelEdit')}
</button>
<button
type="button"
class="btn primary"
class="a-btn primary"
disabled={draft.filter((d) => d.name.trim() && d.prompt.trim()).length < 2}
onclick={save}
>
@@ -119,7 +119,7 @@
<div class="todo-card">
<p class="todo-title">{t('workbench.style.todoTitle')}</p>
<p class="todo-body">{t('workbench.style.todoBody')}</p>
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
<Icon name="sparkle" size={14} />
{t('workbench.style.generate')}
</button>
@@ -153,11 +153,11 @@
<span class="hint">{t('workbench.style.chooseHint')}</span>
<div class="foot-btns">
{#if stage.status !== 'running'}
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
<Icon name="edit" size={13} />
{t('workbench.editDraft')}
</button>
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
<button type="button" class="a-btn ghost" disabled={busy} onclick={() => onRun?.()}>
<Icon name="retry" size={13} />
{t('workbench.regenerate')}
</button>
@@ -295,31 +295,11 @@
gap: 8px;
}
.wb-input {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
color: var(--text);
font-family: inherit;
font-size: 12.5px;
line-height: 1.6;
transition: border-color 150ms;
}
.wb-input:focus {
outline: none;
border-color: var(--accent-line);
}
.wb-input::placeholder {
color: var(--text-3);
}
.wb-input.mono {
.a-input.mono {
font-family: var(--font-a-mono);
font-size: 11.5px;
}
.wb-input.name {
.a-input.name {
font-weight: 600;
}
@@ -345,40 +325,4 @@
color: var(--error);
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms,
opacity 150ms;
}
.btn.primary {
background: var(--accent);
color: var(--accent-contrast);
font-weight: 600;
}
.btn.primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn.ghost {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text-2);
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: var(--line-2);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
+3
View File
@@ -949,6 +949,8 @@ export const de: Dict = {
'library.selectAll': 'Alle auswählen',
'library.deselectAll': 'Auswahl aufheben',
'library.itemCount': '{count} Element(e)',
'library.loadError': 'Bibliothek konnte nicht geladen werden',
'library.subtitle': 'Generierte Assets an einem Ort – Bilder und Videos ohne Projektbezug ansehen, filtern und löschen',
'library.deleteSelected': 'Löschen ({count})',
'library.addToProject': 'Zum Projekt hinzufügen',
'library.filterLabel': 'Nach Art filtern',
@@ -1260,6 +1262,7 @@ export const de: Dict = {
'shell.navAgents': 'Agenten',
'shell.navConnections': 'ComfyUI-Verbindungen',
'shell.navModels': 'Modelle',
'shell.navLibrary': 'Asset-Bibliothek',
'shell.navSettings': 'Einstellungen',
'shell.comfy_ok': '{name} verbunden',
'shell.comfy_fail': '{name} nicht erreichbar',
+3
View File
@@ -949,6 +949,8 @@ export const en = {
'library.selectAll': 'Select all',
'library.deselectAll': 'Deselect all',
'library.itemCount': '{count} item(s)',
'library.loadError': 'Failed to load the library',
'library.subtitle': 'Generated assets in one place — preview, filter and remove images and videos not tied to a project',
'library.deleteSelected': 'Delete ({count})',
'library.addToProject': 'Add to Project',
'library.filterLabel': 'Filter by kind',
@@ -1262,6 +1264,7 @@ export const en = {
'shell.navAgents': 'Agents',
'shell.navConnections': 'ComfyUI Connections',
'shell.navModels': 'Models',
'shell.navLibrary': 'Asset Library',
'shell.navSettings': 'Settings',
'shell.comfy_ok': '{name} connected',
'shell.comfy_fail': '{name} unreachable',
+3
View File
@@ -949,6 +949,8 @@ export const es: Dict = {
'library.selectAll': 'Seleccionar todo',
'library.deselectAll': 'Deseleccionar todo',
'library.itemCount': '{count} elemento(s)',
'library.loadError': 'No se pudo cargar la biblioteca',
'library.subtitle': 'Recursos generados en un solo lugar: previsualiza, filtra y elimina imágenes y vídeos sin proyecto',
'library.deleteSelected': 'Eliminar ({count})',
'library.addToProject': 'Añadir al proyecto',
'library.filterLabel': 'Filtrar por tipo',
@@ -1260,6 +1262,7 @@ export const es: Dict = {
'shell.navAgents': 'Agentes',
'shell.navConnections': 'Conexiones ComfyUI',
'shell.navModels': 'Modelos',
'shell.navLibrary': 'Biblioteca de assets',
'shell.navSettings': 'Ajustes',
'shell.comfy_ok': '{name} conectado',
'shell.comfy_fail': '{name} sin conexión',
+3
View File
@@ -949,6 +949,8 @@ export const fr: Dict = {
'library.selectAll': 'Tout sélectionner',
'library.deselectAll': 'Tout désélectionner',
'library.itemCount': '{count} élément(s)',
'library.loadError': 'Échec du chargement de la bibliothèque',
'library.subtitle': 'Les ressources générées au même endroit : prévisualisez, filtrez et supprimez les images et vidéos sans projet',
'library.deleteSelected': 'Supprimer ({count})',
'library.addToProject': 'Ajouter au projet',
'library.filterLabel': 'Filtrer par type',
@@ -1260,6 +1262,7 @@ export const fr: Dict = {
'shell.navAgents': 'Agents',
'shell.navConnections': 'Connexions ComfyUI',
'shell.navModels': 'Modèles',
'shell.navLibrary': 'Bibliothèque d’assets',
'shell.navSettings': 'Réglages',
'shell.comfy_ok': '{name} connecté',
'shell.comfy_fail': '{name} injoignable',
+3
View File
@@ -949,6 +949,8 @@ export const ja: Dict = {
'library.selectAll': 'すべて選択',
'library.deselectAll': '選択を解除',
'library.itemCount': '{count} 件',
'library.loadError': 'ライブラリの読み込みに失敗しました',
'library.subtitle': '生成素材を一元管理——プロジェクトに紐付いていない画像・動画をプレビュー・絞り込み・削除できます',
'library.deleteSelected': '削除({count})',
'library.addToProject': 'プロジェクトに追加',
'library.filterLabel': '種類で絞り込み',
@@ -1260,6 +1262,7 @@ export const ja: Dict = {
'shell.navAgents': 'エージェント',
'shell.navConnections': 'ComfyUI 接続',
'shell.navModels': 'モデル設定',
'shell.navLibrary': 'アセットライブラリ',
'shell.navSettings': '設定',
'shell.comfy_ok': '{name} 接続済み',
'shell.comfy_fail': '{name} 接続失敗',
+3
View File
@@ -949,6 +949,8 @@ export const ko: Dict = {
'library.selectAll': '모두 선택',
'library.deselectAll': '선택 해제',
'library.itemCount': '{count}개 항목',
'library.loadError': '라이브러리를 불러오지 못했습니다',
'library.subtitle': '생성 자산을 한곳에서 관리——프로젝트에 연결되지 않은 이미지와 동영상을 미리보기·필터·삭제',
'library.deleteSelected': '삭제 ({count})',
'library.addToProject': '프로젝트에 추가',
'library.filterLabel': '종류로 필터',
@@ -1260,6 +1262,7 @@ export const ko: Dict = {
'shell.navAgents': '에이전트',
'shell.navConnections': 'ComfyUI 연결',
'shell.navModels': '모델 설정',
'shell.navLibrary': '애셋 라이브러리',
'shell.navSettings': '설정',
'shell.comfy_ok': '{name} 연결됨',
'shell.comfy_fail': '{name} 연결 실패',
+3
View File
@@ -949,6 +949,8 @@ export const zh: Dict = {
'library.selectAll': '全选',
'library.deselectAll': '取消全选',
'library.itemCount': '{count} 个项目',
'library.loadError': '素材库加载失败',
'library.subtitle': '生成素材集中在这里——未关联项目的图片和视频可预览、筛选和删除',
'library.deleteSelected': '删除({count})',
'library.addToProject': '添加到项目',
'library.filterLabel': '按类型筛选',
@@ -1262,6 +1264,7 @@ export const zh: Dict = {
'shell.navAgents': '智能体',
'shell.navConnections': 'ComfyUI 连接',
'shell.navModels': '模型设置',
'shell.navLibrary': '素材库',
'shell.navSettings': '设置',
'shell.comfy_ok': '{name} 已连接',
'shell.comfy_fail': '{name} 连接失败',
@@ -305,13 +305,13 @@
z-index: 2;
}
.keyframe.cam {
background: #e0b34d;
background: var(--accent-lo);
}
.keyframe.obj {
background: #4ade80;
background: var(--ok);
}
.keyframe.obj.selected {
background: #f87171;
background: var(--accent);
}
.keyframe:hover {
filter: brightness(1.15);
+134 -230
View File
@@ -304,10 +304,10 @@
<AppShell active="projects" title={t('home.title')} subtitle={t('home.subtitle')}>
{#snippet actions()}
<span class="chip">{t('home.projectCount', { n: allProjects.length, m: inProgressCount })}</span
<span class="status-chip">{t('home.projectCount', { n: allProjects.length, m: inProgressCount })}</span
>
<button
class="entry-btn"
class="a-btn"
class:primary={entryMode === 'none'}
class:secondary={entryMode !== 'none'}
onclick={openIdea}
@@ -321,22 +321,22 @@
<section class="main-col">
{#if entryMode === 'none'}
<div class="entries">
<article class="entry-card">
<span class="entry-icon"><Icon name="sparkle" size={18} /></span>
<h2>{t('home.entryIdeaTitle')}</h2>
<p>{t('home.entryIdeaBody')}</p>
<button type="button" class="entry-btn secondary full" onclick={openIdea}>
{t('home.startIdea')}
</button>
</article>
<article class="entry-card">
<span class="entry-icon"><Icon name="script" size={18} /></span>
<h2>{t('home.entryScriptTitle')}</h2>
<p>{t('home.entryScriptBody')}</p>
<button type="button" class="entry-btn secondary full" onclick={openScript}>
{t('home.importScript')}
</button>
</article>
<article class="entry-card">
<span class="entry-icon"><Icon name="sparkle" size={18} /></span>
<h2>{t('home.entryIdeaTitle')}</h2>
<p>{t('home.entryIdeaBody')}</p>
<button type="button" class="a-btn secondary full" onclick={openIdea}>
{t('home.startIdea')}
</button>
</article>
<article class="entry-card">
<span class="entry-icon"><Icon name="script" size={18} /></span>
<h2>{t('home.entryScriptTitle')}</h2>
<p>{t('home.entryScriptBody')}</p>
<button type="button" class="a-btn secondary full" onclick={openScript}>
{t('home.importScript')}
</button>
</article>
</div>
{:else if entryMode === 'idea'}
<form class="entry-form" onsubmit={submitIdea}>
@@ -351,43 +351,43 @@
<Icon name="close" size={16} />
</button>
</div>
<label class="f-field">
<span class="f-label">{t('projects.titleField')}<em>*</em></span>
<label class="field">
<span class="field-label">{t('projects.titleField')}<em>*</em></span>
<input
class="f-input"
class="field-input"
class:invalid={showErrors && ideaErrors.title}
bind:value={ideaForm.title}
placeholder={t('projects.titlePlaceholder')}
/>
{#if showErrors && ideaErrors.title}<span class="f-error">{ideaErrors.title}</span>{/if}
{#if showErrors && ideaErrors.title}<span class="field-error">{ideaErrors.title}</span>{/if}
</label>
<label class="f-field">
<span class="f-label">{t('projects.ideaField')}</span>
<label class="field">
<span class="field-label">{t('projects.ideaField')}</span>
<textarea
class="f-input textarea"
class="field-textarea"
bind:value={ideaForm.idea}
placeholder={t('home.ideaPlaceholder')}
rows={4}
></textarea>
<span class="f-hint">{t('projects.ideaHint')}</span>
<span class="field-hint">{t('projects.ideaHint')}</span>
</label>
<div class="f-grid">
<label class="f-field">
<span class="f-label">{t('projects.genre')}</span>
<select class="f-input" bind:value={ideaForm.genre}>
<label class="field">
<span class="field-label">{t('projects.genre')}</span>
<select class="field-input" bind:value={ideaForm.genre}>
{#each GENRES as v (v)}<option value={v}>{t('genres.' + genreKey(v))}</option
>{/each}
</select>
</label>
<label class="f-field">
<span class="f-label">{t('projects.tone')}</span>
<select class="f-input" bind:value={ideaForm.tone}>
<label class="field">
<span class="field-label">{t('projects.tone')}</span>
<select class="field-input" bind:value={ideaForm.tone}>
{#each TONES as v (v)}<option value={v}>{t('tones.' + toneKey(v))}</option>{/each}
</select>
</label>
<label class="f-field">
<span class="f-label">{t('projects.duration')}</span>
<select class="f-input" bind:value={ideaForm.duration}>
<label class="field">
<span class="field-label">{t('projects.duration')}</span>
<select class="field-input" bind:value={ideaForm.duration}>
{#each DURATIONS as v (v)}<option value={v}
>{t('durations.' + durationKey(v))}</option
>{/each}
@@ -414,29 +414,29 @@
<Icon name="close" size={16} />
</button>
</div>
<label class="f-field">
<span class="f-label">{t('projects.titleField')}<em>*</em></span>
<label class="field">
<span class="field-label">{t('projects.titleField')}<em>*</em></span>
<input
class="f-input"
class="field-input"
class:invalid={showErrors && scriptErrors.title}
bind:value={scriptForm.title}
placeholder={t('projects.titlePlaceholder')}
/>
{#if showErrors && scriptErrors.title}<span class="f-error">{scriptErrors.title}</span
{#if showErrors && scriptErrors.title}<span class="field-error">{scriptErrors.title}</span
>{/if}
</label>
<label class="f-field">
<span class="f-label">{t('home.scriptField')}<em>*</em></span>
<label class="field">
<span class="field-label">{t('home.scriptField')}<em>*</em></span>
<textarea
class="f-input textarea"
class="field-textarea"
class:invalid={showErrors && scriptErrors.script}
bind:value={scriptForm.script}
placeholder={t('home.scriptPlaceholder')}
rows={9}
></textarea>
{#if showErrors && scriptErrors.script}<span class="f-error">{scriptErrors.script}</span
{#if showErrors && scriptErrors.script}<span class="field-error">{scriptErrors.script}</span
>{/if}
<span class="f-hint">{t('home.scriptHint')}</span>
<span class="field-hint">{t('home.scriptHint')}</span>
</label>
<div class="form-actions">
<Button variant="ghost" onclick={closeEntry}>{t('projects.cancel')}</Button>
@@ -491,12 +491,12 @@
</section>
<aside class="tasks">
<div class="tasks-head">
<h2>{t('home.tasksTitle')}</h2>
<span class="chip" class:run={activeJobCount > 0}>
{t('home.activeJobs', { n: activeJobCount })}
</span>
</div>
<div class="tasks-head">
<h2>{t('home.tasksTitle')}</h2>
<span class="status-chip" class:run-chip={activeJobCount > 0}>
{t('home.activeJobs', { n: activeJobCount })}
</span>
</div>
{#if $jobsQuery.isLoading}
<div class="tasks-loading">{t('common.loading')}…</div>
@@ -513,7 +513,7 @@
{@const bar = jobBar(job)}
<li class="job-row" class:failed={job.status === 'failed'}>
<div class="job-top">
<span class="tag {tag.cls}">{tag.label}</span>
<span class="a-tag">{tag.label}</span>
<span class="job-name" title={jobTitle(job)}>{jobTitle(job)}</span>
<span class="job-side">
{#if job.status === 'failed' && job.error !== 'cancelled'}
@@ -572,56 +572,7 @@
}
/* ---------------- topbar chip + buttons ---------------- */
.chip {
padding: 5px 12px;
border-radius: 999px;
background: var(--surface-3);
color: var(--text-2);
font-size: 11.5px;
white-space: nowrap;
}
.chip.run {
background: var(--run-soft);
color: var(--run);
}
.entry-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 34px;
padding: 0 16px;
border-radius: 8px;
font-family: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms,
transform 100ms;
}
.entry-btn.primary {
background: var(--accent);
color: var(--accent-contrast);
}
.entry-btn.primary:hover {
background: var(--accent-hover);
}
.entry-btn.secondary {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text);
}
.entry-btn.secondary:hover {
border-color: var(--line-2);
}
.entry-btn:active {
transform: scale(0.98);
}
.entry-btn.full {
.a-btn.full {
width: 100%;
margin-top: auto;
}
@@ -630,57 +581,57 @@
.entries {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
gap: var(--sp-6);
}
.entry-card {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px 20px 20px;
gap: var(--sp-3);
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 18px var(--sp-7) var(--sp-7);
min-height: 168px;
}
.entry-icon {
width: 38px;
height: 38px;
border-radius: 9px;
background: var(--surface-2);
border: 1px solid var(--border-strong);
background: var(--bg-3);
border: 1px solid var(--line-2);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-2);
}
.entry-card h2 {
margin: 4px 0 0;
margin: var(--sp-1) 0 0;
font-size: 14.5px;
font-weight: 600;
color: var(--text);
}
.entry-card p {
margin: 0;
font-size: 12.5px;
font-size: var(--fs-body-sm);
line-height: 1.65;
color: var(--text-2);
}
.entry-form {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px 20px 20px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 18px var(--sp-7) var(--sp-7);
}
.form-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
margin-bottom: var(--sp-5);
}
.form-head h2 {
margin: 0;
font-size: 15px;
font-size: var(--fs-title);
font-weight: 600;
color: var(--text);
}
@@ -691,89 +642,41 @@
width: 28px;
height: 28px;
border: none;
border-radius: 7px;
border-radius: var(--r-sm);
background: transparent;
color: var(--text-3);
cursor: pointer;
}
.icon-btn:hover {
background: var(--surface-3);
background: var(--bg-4);
color: var(--text);
}
.f-field {
display: block;
margin-bottom: 13px;
}
.f-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-2);
margin-bottom: 6px;
}
.f-label em {
color: var(--error);
.field-label em {
color: var(--err);
font-style: normal;
}
.f-input {
width: 100%;
box-sizing: border-box;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 7px;
padding: 9px 11px;
color: var(--text);
font-family: inherit;
font-size: 13px;
}
.f-input.textarea {
resize: vertical;
min-height: 88px;
line-height: 1.6;
}
.f-input:focus {
outline: none;
border-color: var(--accent-line);
}
.f-input.invalid {
border-color: var(--err-line);
}
.f-hint {
display: block;
margin-top: 5px;
font-size: 11px;
color: var(--text-3);
}
.f-error {
display: block;
margin-top: 5px;
font-size: 11.5px;
color: var(--error);
}
.f-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
gap: var(--sp-5);
}
.f-grid .f-field {
.f-grid .field {
margin-bottom: 0;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border);
gap: var(--sp-3);
margin-top: var(--sp-6);
padding-top: var(--sp-5);
border-top: 1px solid var(--line);
}
/* ---------------- project cards ---------------- */
.projects-head {
display: flex;
align-items: baseline;
gap: 12px;
gap: var(--sp-5);
}
.projects-head h2 {
margin: 0;
@@ -788,12 +691,12 @@
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
gap: var(--sp-6);
}
.skel-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-lg);
overflow: hidden;
}
.skel-card :global(.skeleton) {
@@ -803,77 +706,82 @@
display: flex;
flex-direction: column;
gap: 9px;
padding: 14px 16px;
padding: var(--sp-5) var(--sp-6);
}
.panel-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 36px 20px;
border: 1.5px dashed var(--border-strong);
border-radius: 10px;
gap: var(--sp-4);
padding: 36px var(--sp-7);
border: 1.5px dashed var(--line-2);
border-radius: var(--r-lg);
color: var(--text-3);
text-align: center;
}
.panel-empty p {
margin: 0;
font-size: 12.5px;
font-size: var(--fs-body-sm);
line-height: 1.65;
color: var(--text-2);
}
.panel-empty.error p {
color: var(--error);
color: var(--err);
}
/* ---------------- right task area ---------------- */
.tasks {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 18px 14px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: var(--sp-6) var(--sp-7) var(--sp-5);
position: sticky;
top: 24px;
display: flex;
flex-direction: column;
gap: 12px;
gap: var(--sp-5);
min-width: 0;
}
.tasks-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
gap: var(--sp-4);
}
.tasks-head h2 {
margin: 0;
font-size: 14px;
font-size: var(--fs-section);
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.run-chip {
background: var(--run-soft);
border-color: color-mix(in srgb, var(--run) 35%, transparent);
color: var(--run);
}
.tasks-loading {
color: var(--text-3);
font-size: 12px;
padding: 12px 0;
font-size: var(--fs-aux);
padding: var(--sp-4) 0;
}
.tasks-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 22px 10px 16px;
gap: var(--sp-3);
padding: 22px var(--sp-4) var(--sp-6);
color: var(--text-3);
text-align: center;
}
.tasks-empty p {
margin: 0;
font-size: 12px;
font-size: var(--fs-aux);
line-height: 1.65;
color: var(--text-2);
}
.tasks-empty-sub {
font-size: 11px;
font-size: var(--fs-mini);
color: var(--text-3);
}
@@ -888,8 +796,8 @@
display: flex;
flex-direction: column;
gap: 7px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
padding: var(--sp-5) 0;
border-bottom: 1px solid var(--line);
}
.job-row:last-child {
border-bottom: none;
@@ -897,26 +805,13 @@
.job-top {
display: flex;
align-items: center;
gap: 8px;
gap: var(--sp-3);
min-width: 0;
}
.tag {
padding: 1px 8px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 600;
flex-shrink: 0;
white-space: nowrap;
}
.tag-local,
.tag-cloud {
background: var(--bg-4);
color: var(--text-2);
}
.job-name {
flex: 1;
min-width: 0;
font-size: 12.5px;
font-size: var(--fs-body-sm);
font-weight: 600;
color: var(--text);
white-space: nowrap;
@@ -926,7 +821,7 @@
.job-side {
display: inline-flex;
align-items: center;
gap: 8px;
gap: var(--sp-3);
flex-shrink: 0;
}
.job-meta {
@@ -941,10 +836,10 @@
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-size: var(--fs-mini);
}
.done-text {
color: var(--success);
color: var(--ok);
}
.running-text {
color: var(--run);
@@ -953,7 +848,7 @@
color: var(--text-3);
}
.failed-text {
color: var(--error);
color: var(--err);
max-width: 120px;
white-space: nowrap;
overflow: hidden;
@@ -978,21 +873,25 @@
opacity: 0.35;
}
}
.retry-link {
/* 次级文字操作「重试」走 ghost(§1.2):默认 --text-2,悬停 --text。 */
.retry-link {
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 11px;
color: var(--error);
font-size: var(--fs-mini);
color: var(--text-2);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
.retry-link:hover {
color: var(--text);
}
.tasks-note {
margin: 0;
padding-top: 10px;
border-top: 1px solid var(--border);
padding-top: var(--sp-4);
border-top: 1px solid var(--line);
font-size: 10.5px;
line-height: 1.6;
color: var(--text-3);
@@ -1002,13 +901,13 @@
@media (max-width: 1439px) {
.home {
grid-template-columns: minmax(0, 1fr) 320px;
padding: 20px 22px 36px;
padding: var(--sp-6) 22px 36px;
}
}
@media (max-width: 1023px) {
@media (max-width: 1179px) {
.home {
grid-template-columns: minmax(0, 1fr);
padding: 16px 16px 32px;
padding: var(--sp-5) var(--sp-5) 32px;
}
.tasks {
position: static;
@@ -1020,4 +919,9 @@
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 760px) {
.home {
padding: var(--sp-4) var(--sp-4) var(--sp-8);
}
}
</style>
+15 -10
View File
@@ -1185,17 +1185,20 @@
padding: 10px 4px;
margin: 0;
}
/* Skill 行(§3.6):启用 = --run 描边 + 软底;停用 = --text-3 描边。 */
.skill-row {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 8px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--r-md);
border: 1px solid color-mix(in srgb, var(--run) 45%, transparent);
background: var(--run-soft);
}
.skill-row.disabled {
opacity: 0.55;
border-color: color-mix(in srgb, var(--text-3) 45%, transparent);
background: var(--bg-2);
opacity: 0.75;
}
.skill-row-main {
display: flex;
@@ -1218,7 +1221,7 @@
text-overflow: ellipsis;
}
.skill-name:hover {
color: var(--accent);
color: var(--text);
}
.skill-tags {
display: flex;
@@ -1390,13 +1393,15 @@
gap: 5px;
background: none;
border: none;
color: var(--accent);
color: var(--text-2);
font-size: 11.5px;
cursor: pointer;
padding: 2px 4px;
}
.link-btn:hover {
color: var(--text);
text-decoration: underline;
text-underline-offset: 3px;
}
.versions {
@@ -1486,7 +1491,7 @@
color: var(--text-2);
}
.f-label em {
color: var(--accent);
color: var(--err);
font-style: normal;
margin-left: 2px;
}
@@ -1660,7 +1665,7 @@
border-radius: 12px 0 0 12px;
transform: translateX(calc(100% + 18px));
transition: transform 240ms cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: -18px 0 48px rgba(0, 0, 0, 0.45);
box-shadow: var(--shadow-float);
}
.right.open {
transform: translateX(0);
@@ -1669,7 +1674,7 @@
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
background: var(--overlay);
z-index: 55;
}
.session-select {
@@ -1677,7 +1682,7 @@
}
}
@media (max-width: 1279px) {
@media (max-width: 1179px) {
.model-pick .model-label {
display: none;
}
@@ -574,7 +574,7 @@ function toggleChat() {
.left-panel {
width: 280px;
flex-shrink: 0;
border-right: 1px solid var(--border, #232733);
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
min-height: 0;
@@ -582,7 +582,7 @@ function toggleChat() {
.inspector-slot {
flex: 1;
min-height: 0;
border-top: 1px solid var(--border, #232733);
border-top: 1px solid var(--line);
overflow: hidden;
}
.center-panel {
@@ -600,7 +600,7 @@ function toggleChat() {
position: relative;
display: flex;
overflow: hidden;
background: #0b1220;
background: var(--bg-2);
}
.loading {
flex: 1;
@@ -612,11 +612,11 @@ function toggleChat() {
.chat-panel {
width: 360px;
flex-shrink: 0;
border-left: 1px solid var(--border, #232733);
border-left: 1px solid var(--line);
display: flex;
flex-direction: column;
min-height: 0;
transition: width 0.18s ease;
transition: width var(--d-pop) var(--ease-out);
}
.chat-panel.collapsed {
width: 40px;
@@ -636,7 +636,7 @@ function toggleChat() {
letter-spacing: 0.04em;
}
.chat-toggle {
border: 1px solid var(--border, #2a2f3a);
border: 1px solid var(--line-2);
background: transparent;
color: inherit;
border-radius: 6px;
@@ -650,14 +650,14 @@ function toggleChat() {
flex-shrink: 0;
}
.chat-toggle:hover {
background: rgba(255, 255, 255, 0.08);
background: var(--bg-4);
}
.chat-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--border, #232733);
border-bottom: 1px solid var(--line);
}
.chat-head .chat-toggle {
margin-left: auto;
@@ -677,7 +677,7 @@ function toggleChat() {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
border: 1px solid var(--border, #2a2f3a);
border: 1px solid var(--line-2);
border-radius: 999px;
padding: 2px 8px;
opacity: 0.7;
@@ -686,6 +686,37 @@ function toggleChat() {
margin: 0;
padding: 4px 12px;
font-size: 12px;
color: var(--muted, #9aa3b5);
color: var(--text-3);
}
/* ── ≤760px 可用级适配(F 批):解除 100vh 固定布局,纵向堆叠可滚动。 ── */
@media (max-width: 760px) {
.shell {
height: auto;
min-height: 100vh;
overflow: visible;
}
.workspace {
flex-direction: column;
}
.left-panel {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--line);
max-height: 60vh;
}
.chat-panel,
.chat-panel.collapsed {
width: 100%;
border-left: none;
border-top: 1px solid var(--line);
min-height: 50vh;
}
.chat-panel.collapsed .chat-head {
flex-direction: row;
padding: var(--sp-3) var(--sp-5);
}
.chat-title-vert {
writing-mode: horizontal-tb;
}
}
</style>
+1 -1
View File
@@ -39,6 +39,6 @@
font-size: 14px;
}
.redirect-error {
color: #f87171;
color: var(--err);
}
</style>
@@ -1080,7 +1080,7 @@
<span class="saved">saved</span>
{/if}
{#if canvas.project}
<a class="project-chip" href="/project/{canvas.project.id}">
<a class="project-chip" href="/workbench/{canvas.project.id}">
<Icon name="folder" size={12} />
{canvas.project.title}
</a>
@@ -1387,8 +1387,8 @@ title={running
color 0.15s;
}
.unlink-btn:hover:not(:disabled) {
border-color: #f87171;
color: #fca5a5;
border-color: var(--err-line);
color: var(--err);
}
.unlink-btn:disabled {
opacity: 0.45;
@@ -1476,4 +1476,27 @@ title={running
top: 14px;
z-index: 5;
}
/* ── ≤760px 可用级适配(F 批):解除 100vh 固定布局,画布与对话纵向堆叠。 ── */
@media (max-width: 760px) {
.shell {
height: auto;
min-height: 100vh;
overflow: visible;
}
.workspace {
flex-direction: column;
}
.flow-panel {
min-height: 60vh;
}
.chat-panel {
width: 100% !important;
border-left: none;
border-top: 1px solid var(--line);
min-height: 50vh;
}
.chat-splitter {
display: none;
}
}
</style>
@@ -1,5 +0,0 @@
import type { RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = () => {
return new Response(null, { status: 204 });
};
+199 -254
View File
@@ -3,13 +3,15 @@
import { libraryApi, type LibraryMediaItem } from '$lib/api';
import { assetUrl } from '$lib/api';
import { t } from '$lib/i18n.svelte';
import AppHeader from '$lib/components/AppHeader.svelte';
import AppShell from '$lib/components/shell/AppShell.svelte';
import AttachToProject from '$lib/components/AttachToProject.svelte';
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
import SafeMedia from '$lib/components/SafeMedia.svelte';
import Button from '$lib/components/ui/Button.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import { toast } from '$lib/toast';
@@ -127,117 +129,129 @@
}
</script>
<AppHeader active="library">
{#if selectedCount > 0}
<Button variant="danger" onclick={() => (confirmingDelete = true)}>
<Icon name="close" size={14} />
{t('library.deleteSelected', { count: selectedCount })}
</Button>
{/if}
</AppHeader>
<main class="container">
<div class="toolbar">
<div class="filters" role="tablist" aria-label={t('library.filterLabel')}>
<button
type="button"
class="filter-chip"
class:active={filter === 'all'}
aria-pressed={filter === 'all'}
onclick={() => (filter = 'all')}
>
{t('library.filterAll')} <span class="chip-count">{items.length}</span>
</button>
<button
type="button"
class="filter-chip"
class:active={filter === 'image'}
aria-pressed={filter === 'image'}
onclick={() => (filter = 'image')}
>
{t('library.filterImage')}
<span class="chip-count">
{items.filter((i) => i.kind === 'image').length}
</span>
</button>
<button
type="button"
class="filter-chip"
class:active={filter === 'video'}
aria-pressed={filter === 'video'}
onclick={() => (filter = 'video')}
>
{t('library.filterVideo')}
<span class="chip-count">
{items.filter((i) => i.kind === 'video').length}
</span>
</button>
</div>
<label class="select-all">
<input type="checkbox" checked={allSelected} onchange={toggleAll} />
<span>{allSelected ? t('library.deselectAll') : t('library.selectAll')}</span>
</label>
<span class="count" data-testid="library-count">
<AppShell active="library" title={t('nav.library')} subtitle={t('library.subtitle')}>
{#snippet actions()}
<span class="status-chip" data-testid="library-count">
{t('library.itemCount', { count: filtered.length })}
</span>
</div>
{#if $mediaQuery.isLoading}
<div class="grid">
{#each Array(8) as _, i (i)}
<div class="card skeleton-card"><Skeleton height="220px" /></div>
{/each}
</div>
{:else if filtered.length === 0}
<EmptyState
title={filter === 'all' ? t('library.emptyTitle') : t('library.emptyFilterTitle', { kind: filter === 'image' ? t('library.filterImage') : t('library.filterVideo') })}
body={filter === 'all' ? t('library.emptyBody') : t('library.emptyFilterBody')}
/>
{:else}
<div class="grid">
{#each visible as item (item.path)}
<div class="card" class:selected={selected.has(item.path)}>
<label class="check">
<input
type="checkbox"
checked={selected.has(item.path)}
onchange={() => toggle(item.path)}
/>
</label>
<button
type="button"
class="media"
onclick={() => openPreview(item)}
title={item.name}
>
<SafeMedia src={assetUrl(item.path)} kind={item.kind} alt={item.name} controls={false} />
</button>
<div class="meta">
<span class="name" title={item.name}>{item.name}</span>
<span class="sub">{item.kind} · {fmtSize(item.size)}</span>
</div>
<div class="card-actions">
<Button variant="secondary" onclick={() => (attachItem = item)}>
{t('library.addToProject')}
</Button>
</div>
</div>
{/each}
</div>
{#if hasMore}
<div class="load-more" bind:this={sentinel} aria-hidden="true"></div>
<div class="more-row">
{#if loadingMore}
<span class="loading-note">{t('library.loadingMore')}</span>
{:else}
<Button variant="secondary" onclick={loadMore}>
{t('library.loadMore', { count: remaining })}
</Button>
{/if}
</div>
{#if selectedCount > 0}
<Button variant="danger" onclick={() => (confirmingDelete = true)}>
<Icon name="close" size={14} />
{t('library.deleteSelected', { count: selectedCount })}
</Button>
{/if}
{/if}
</main>
{/snippet}
<div class="library">
<div class="toolbar">
<div class="filters" role="tablist" aria-label={t('library.filterLabel')}>
<button
type="button"
class="a-chip"
class:sel={filter === 'all'}
aria-pressed={filter === 'all'}
onclick={() => (filter = 'all')}
>
<span class="lbl">{t('library.filterAll')}</span>
<span class="chip-count">{items.length}</span>
</button>
<button
type="button"
class="a-chip"
class:sel={filter === 'image'}
aria-pressed={filter === 'image'}
onclick={() => (filter = 'image')}
>
<span class="lbl">{t('library.filterImage')}</span>
<span class="chip-count">{items.filter((i) => i.kind === 'image').length}</span>
</button>
<button
type="button"
class="a-chip"
class:sel={filter === 'video'}
aria-pressed={filter === 'video'}
onclick={() => (filter = 'video')}
>
<span class="lbl">{t('library.filterVideo')}</span>
<span class="chip-count">{items.filter((i) => i.kind === 'video').length}</span>
</button>
</div>
<label class="select-all">
<input type="checkbox" checked={allSelected} onchange={toggleAll} />
<span>{allSelected ? t('library.deselectAll') : t('library.selectAll')}</span>
</label>
</div>
{#if $mediaQuery.isLoading}
<div class="grid" aria-busy="true">
{#each Array(8) as _, i (i)}
<div class="card skeleton-card"><Skeleton height="220px" /></div>
{/each}
</div>
{:else if $mediaQuery.isError}
<EmptyState title={t('library.loadError')}>
{#snippet action()}
<Button variant="secondary" onclick={() => $mediaQuery.refetch()}>
<Icon name="retry" size={14} />
{t('common.retry')}
</Button>
{/snippet}
</EmptyState>
{:else if filtered.length === 0}
<EmptyState
title={filter === 'all'
? t('library.emptyTitle')
: t('library.emptyFilterTitle', {
kind: filter === 'image' ? t('library.filterImage') : t('library.filterVideo')
})}
body={filter === 'all' ? t('library.emptyBody') : t('library.emptyFilterBody')}
/>
{:else}
<div class="grid">
{#each visible as item (item.path)}
<div class="card" class:selected={selected.has(item.path)}>
<label class="check">
<input
type="checkbox"
checked={selected.has(item.path)}
onchange={() => toggle(item.path)}
/>
</label>
<button
type="button"
class="media"
onclick={() => openPreview(item)}
title={item.name}
>
<SafeMedia src={assetUrl(item.path)} kind={item.kind} alt={item.name} controls={false} />
</button>
<div class="meta">
<span class="name" title={item.name}>{item.name}</span>
<span class="sub">{item.kind} · {fmtSize(item.size)}</span>
</div>
<div class="card-actions">
<Button variant="secondary" onclick={() => (attachItem = item)}>
{t('library.addToProject')}
</Button>
</div>
</div>
{/each}
</div>
{#if hasMore}
<div class="load-more" bind:this={sentinel} aria-hidden="true"></div>
<div class="more-row">
{#if loadingMore}
<span class="loading-note">{t('library.loadingMore')}</span>
{:else}
<Button variant="secondary" onclick={loadMore}>
{t('library.loadMore', { count: remaining })}
</Button>
{/if}
</div>
{/if}
{/if}
</div>
</AppShell>
{#if preview}
<ImageLightbox
@@ -249,118 +263,86 @@
/>
{/if}
{#if attachItem}
<div class="modal-backdrop">
<div
class="modal"
role="dialog"
aria-modal="true"
aria-label={t('library.addToProject')}
>
<div class="modal-head">
<strong>{t('library.addToProject')}</strong>
<button type="button" class="modal-close" onclick={() => (attachItem = null)}>
<Icon name="close" size={14} />
</button>
</div>
<p class="modal-file">{attachItem.name}</p>
<AttachToProject path={attachItem.path} kind={attachItem.kind} />
</div>
</div>
{/if}
<Modal
open={attachItem !== null}
title={t('library.addToProject')}
onclose={() => (attachItem = null)}
>
{#if attachItem}
<p class="modal-file">{attachItem.name}</p>
<AttachToProject path={attachItem.path} kind={attachItem.kind} />
{/if}
</Modal>
{#if confirmingDelete}
<div class="modal-backdrop" role="presentation">
<div class="modal confirm" role="alertdialog" aria-modal="true">
<p>{t('library.confirmDelete', { count: selectedCount })}</p>
<div class="modal-actions">
<Button variant="danger" loading={$deleteMutation.isPending} onclick={deleteSelected}>
{t('common.delete')}
</Button>
<Button variant="ghost" onclick={() => (confirmingDelete = false)}>
{t('common.cancel')}
</Button>
</div>
</div>
</div>
{/if}
<ConfirmDialog
open={confirmingDelete}
title={t('library.deleteSelected', { count: selectedCount })}
message={t('library.confirmDelete', { count: selectedCount })}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
danger={true}
onconfirm={deleteSelected}
oncancel={() => (confirmingDelete = false)}
/>
<style>
.library {
padding: 20px 28px 40px;
max-width: 1560px;
width: 100%;
box-sizing: border-box;
margin: 0 auto;
}
.toolbar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 14px;
gap: var(--sp-6);
margin-bottom: var(--sp-5);
flex-wrap: wrap;
}
.filters {
display: flex;
align-items: center;
gap: 6px;
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.filter-chip:hover {
color: var(--text-primary);
background: var(--bg-elevated);
}
.filter-chip.active {
color: var(--text-primary);
background: var(--bg-elevated);
border-color: var(--accent);
}
.filter-chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
gap: var(--sp-2);
flex-wrap: wrap;
}
.chip-count {
font-size: 11px;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.06);
border-radius: 999px;
padding: 1px 7px;
font-size: var(--fs-mini);
color: var(--text-3);
font-variant-numeric: tabular-nums;
}
.select-all {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
gap: var(--sp-3);
font-size: var(--fs-body-sm);
color: var(--text-2);
cursor: pointer;
}
.count {
font-size: 13px;
color: var(--text-muted);
.select-all input {
accent-color: var(--accent-lo);
width: 15px;
height: 15px;
cursor: pointer;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
gap: var(--sp-4);
}
.card {
position: relative;
display: flex;
flex-direction: column;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-lg);
overflow: hidden;
transition: border-color 0.15s ease;
transition: border-color var(--d-state) ease;
}
.card.selected {
border-color: var(--accent);
border-color: var(--accent-line);
background: linear-gradient(180deg, var(--accent-soft), transparent 32%), var(--bg-2);
}
.card :global(.media-el) {
object-fit: cover;
@@ -370,22 +352,22 @@
}
.check {
position: absolute;
top: 8px;
left: 8px;
top: var(--sp-3);
left: var(--sp-3);
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
background: rgba(0, 0, 0, 0.55);
border-radius: var(--radius-sm);
background: var(--media-scrim);
border-radius: var(--r-sm);
cursor: pointer;
}
.check input {
width: 15px;
height: 15px;
accent-color: var(--accent);
accent-color: var(--accent-lo);
cursor: pointer;
}
.media {
@@ -394,7 +376,7 @@
height: 150px;
padding: 0;
border: none;
background: rgba(0, 0, 0, 0.3);
background: var(--media-scrim);
cursor: zoom-in;
overflow: hidden;
}
@@ -408,22 +390,22 @@
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 12px 6px;
padding: var(--sp-4) var(--sp-5) var(--sp-2);
min-width: 0;
}
.name {
font-size: 13px;
color: var(--text-primary);
font-size: var(--fs-body);
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
font-size: 11px;
color: var(--text-muted);
font-size: var(--fs-mini);
color: var(--text-3);
}
.card-actions {
padding: 8px 12px 12px;
padding: var(--sp-3) var(--sp-5) var(--sp-5);
}
.load-more {
height: 1px;
@@ -431,65 +413,28 @@
.more-row {
display: flex;
justify-content: center;
padding: 18px 0 6px;
padding: var(--sp-6) 0 var(--sp-2);
}
.loading-note {
font-size: 13px;
color: var(--text-muted);
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
padding: 20px;
}
.modal {
width: min(420px, 100%);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
}
.modal.confirm {
width: min(360px, 100%);
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.modal-close:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.06);
font-size: var(--fs-body-sm);
color: var(--text-3);
}
.modal-file {
margin: 0 0 10px;
font-size: 12px;
color: var(--text-muted);
margin: 0 0 var(--sp-4);
font-size: var(--fs-aux);
color: var(--text-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modal-actions {
display: flex;
gap: 8px;
margin-top: 12px;
@media (max-width: 760px) {
.library {
padding: var(--sp-5) var(--sp-5) var(--sp-8);
}
.grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--sp-3);
}
}
</style>
-6
View File
@@ -1,8 +1,2 @@
import { redirect } from '@sveltejs/kit';
export const ssr = false;
export const prerender = false;
export function load() {
redirect(308, '/');
}
+5 -3
View File
@@ -1097,11 +1097,13 @@
padding: 0;
font-family: inherit;
font-size: 12px;
color: var(--accent);
color: var(--text-2);
cursor: pointer;
}
.link-accent:hover {
color: var(--text);
text-decoration: underline;
text-underline-offset: 3px;
}
/* ---------------- notice bar (§9.5) ---------------- */
@@ -1414,7 +1416,7 @@
overflow-y: auto;
transform: translateX(100%);
transition: transform 250ms cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: -24px 0 64px rgba(0, 0, 0, 0.4);
box-shadow: var(--shadow-float);
}
.form-col.open {
transform: translateX(0);
@@ -1437,7 +1439,7 @@
}
}
@media (max-width: 1023px) {
@media (max-width: 760px) {
.page {
padding: 16px 16px 32px;
}
@@ -36,6 +36,6 @@
font-size: 14px;
}
.redirect-error {
color: #f87171;
color: var(--err);
}
</style>
@@ -1,309 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { toStore } from 'svelte/store';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import NavRail from '$lib/components/NavRail.svelte';
import StoryStage from '$lib/components/StoryStage.svelte';
import AssetsStage from '$lib/components/AssetsStage.svelte';
import ScriptStage from '$lib/components/ScriptStage.svelte';
import QueueStage from '$lib/components/QueueStage.svelte';
import ActivityPanel from '$lib/components/ActivityPanel.svelte';
import AppHeader from '$lib/components/AppHeader.svelte';
import Button from '$lib/components/ui/Button.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import StatusChip from '$lib/components/ui/StatusChip.svelte';
import { t } from '$lib/i18n.svelte';
import { jobsApi, projects, settings } from '$lib/api';
import { connectEvents, type CalliopeEvent, type EventConnectionState } from '$lib/events';
import { handleJobEvent } from '$lib/jobProgress';
interface Props {
data: { id: string };
}
let { data }: Props = $props();
const client = useQueryClient();
const projectId = $derived(Number(data.id));
const enabled = $derived(!isNaN(projectId));
// Stage <-> URL contract: the active stage lives in ?stage=story|assets|script|video.
// Switching stages = goto('?stage=<id>'); deep links and browser Back/Forward work.
const STAGES = ['story', 'assets', 'script', 'video'] as const;
type Stage = (typeof STAGES)[number];
function stageFromUrl(url: URL): Stage {
const s = url.searchParams.get('stage');
return (STAGES as readonly string[]).includes(s ?? '') ? (s as Stage) : 'story';
}
const STAGE_LABELS: Record<Stage, string> = {
story: '1 · Story',
assets: '2 · Assets',
script: '3 · Script',
video: '4 · Video',
};
let activeTab = $state<Stage>(stageFromUrl(page.url));
let activityCollapsed = $state(false);
let logs = $state<CalliopeEvent[]>([]);
let connState = $state<EventConnectionState>('connecting');
// URL → state: deep links, browser Back/Forward
$effect(() => {
const s = stageFromUrl(page.url);
if (s !== activeTab) activeTab = s;
});
// State → URL: every stage switch goes through goto so history works
function selectStage(id: string) {
const stage: Stage = (STAGES as readonly string[]).includes(id) ? (id as Stage) : 'story';
if (stage === activeTab) return;
activeTab = stage;
goto(`?stage=${stage}`, { keepFocus: true, noScroll: true });
}
const storyQuery = createQuery(
toStore(() => ({
queryKey: ['story', projectId],
queryFn: () => projects.getStory(projectId),
enabled,
})),
);
const settingsQuery = createQuery({
queryKey: ['settings'],
queryFn: settings.get,
});
// Same query keys as QueueStage/ActivityPanel — cache is shared.
// Refreshes are SSE-driven (job.* events + events.resync below), no polling.
const jobsQuery = createQuery(
toStore(() => ({
queryKey: ['jobs', projectId],
queryFn: () => jobsApi.list(projectId),
enabled,
})),
);
const queueStatusQuery = createQuery(
toStore(() => ({
queryKey: ['queue-status'],
queryFn: jobsApi.queueStatus,
enabled,
})),
);
const isConfigured = $derived(
Boolean($settingsQuery.data?.llm_base_url && $settingsQuery.data?.llm_model),
);
const projectTitle = $derived($storyQuery.data?.project?.title ?? 'Project');
const runningCount = $derived(
($jobsQuery.data ?? []).filter((j) => j.status === 'running').length,
);
const queuedCount = $derived(
($jobsQuery.data ?? []).filter((j) => j.status === 'pending').length,
);
const queuePaused = $derived(Boolean($queueStatusQuery.data?.paused));
onMount(() => {
return connectEvents(
(ev) => {
if (ev.type === 'events.resync') {
// SSE reconnected: refill anything missed while the stream was down.
client.invalidateQueries({ queryKey: ['jobs'] });
client.invalidateQueries({ queryKey: ['queue-status'] });
client.invalidateQueries({ queryKey: ['assets'] });
client.invalidateQueries({ queryKey: ['story'] });
client.invalidateQueries({ queryKey: ['scenes'] });
return;
}
// Live per-job progress for ProgressBars (Activity log stays clean)
handleJobEvent(ev);
// Don't keep Comfy poll ticks in the Activity log
if (ev.type !== 'job.progress') {
logs = [...logs, ev].slice(-120);
}
if (
ev.type.startsWith('job.') ||
ev.type === 'asset.ready' ||
ev.type === 'agent.thinking' ||
ev.type.startsWith('story.')
) {
client.invalidateQueries({ queryKey: ['jobs'] });
client.invalidateQueries({ queryKey: ['queue-status'] });
client.invalidateQueries({ queryKey: ['assets'] });
client.invalidateQueries({ queryKey: ['story'] });
client.invalidateQueries({ queryKey: ['scenes'] });
}
},
(state) => (connState = state),
);
});
</script>
<div class="shell">
<AppHeader crumb={`· ${projectTitle}`} />
{#if !enabled}
<div class="invalid-wrap">
<EmptyState
title={t('project.notFound')}
body={t('project.invalidIdBody', { id: data.id })}
>
{#snippet icon()}
<Icon name="folder" size={28} />
{/snippet}
{#snippet action()}
<Button variant="primary" onclick={() => goto('/projects')}>{t('project.backToProjects')}</Button>
{/snippet}
</EmptyState>
</div>
{:else}
<div class="workspace-bar">
<span class="stage-label">{STAGE_LABELS[activeTab]}</span>
<Button
variant="secondary"
size="sm"
title={t('project.openVideoStage')}
onclick={() => selectStage('video')}
>
<Icon name="queue" size={13} />
{runningCount + queuedCount > 0
? t('project.runningQueued', { running: runningCount, queued: queuedCount })
: t('project.queueIdle')}
{#if queuePaused}
<StatusChip status="paused" />
{/if}
</Button>
</div>
<div class="workspace">
<NavRail active={activeTab} onSelect={selectStage} />
<main class="stage" class:stage-fill={activeTab === 'video'}>
{#if activeTab === 'story'}
{#if $storyQuery.isLoading}
<div class="card skel" aria-busy="true">
<Skeleton width="36%" height="22px" />
<Skeleton width="88%" />
<Skeleton width="72%" />
<Skeleton height="140px" />
</div>
{:else if $storyQuery.isError}
<div class="card" style="color: var(--error)">
Failed to load story: {$storyQuery.error?.message ?? 'unknown error'}
</div>
{:else if $storyQuery.data}
<StoryStage
{projectId}
story={$storyQuery.data}
configured={isConfigured}
onGoAssets={() => selectStage('assets')}
/>
{/if}
{:else if activeTab === 'assets'}
<AssetsStage {projectId} />
{:else if activeTab === 'script'}
<ScriptStage {projectId} />
{:else if activeTab === 'video'}
<QueueStage {projectId} {projectTitle} />
{/if}
</main>
<ActivityPanel
{logs}
{projectId}
{connState}
collapsed={activityCollapsed}
onToggle={() => (activityCollapsed = !activityCollapsed)}
/>
</div>
{/if}
</div>
<style>
.shell {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.invalid-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-lg);
overflow-y: auto;
}
.workspace-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: 8px var(--space-lg);
border-bottom: 1px solid var(--border);
background: var(--bg-surface);
flex-shrink: 0;
}
.stage-label {
font-family: var(--font-display);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-secondary);
}
.workspace {
display: flex;
flex: 1;
overflow: hidden;
}
.stage {
flex: 1;
padding: var(--space-lg);
overflow-y: auto;
background: var(--bg-primary);
min-width: 0;
}
.stage.stage-fill {
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
/* Bleed to column edges — Nav/Activity already frame the stage.
Old max-width+margin:auto looked off-center because this column
sits between unequal side rails. */
padding: 12px 0 12px;
}
.stage.stage-fill > :global(.queue-root) {
flex: 1;
min-height: 0;
padding-inline: 16px;
}
/* Player hero breaks out to full stage width; strip/composer keep inset */
.stage.stage-fill :global(.hero) {
margin-inline: -16px;
width: auto;
}
.stage.stage-fill :global(.hero .frame) {
border-radius: 0;
border-left: none;
border-right: none;
}
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-lg);
}
.skel {
display: flex;
flex-direction: column;
gap: 12px;
}
</style>
@@ -1,458 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { projects, type Project } from '$lib/api';
import { t } from '$lib/i18n.svelte';
import AppHeader from '$lib/components/AppHeader.svelte';
import Button from '$lib/components/ui/Button.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import ProjectCard from '$lib/components/ProjectCard.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import { toast } from '$lib/toast';
const client = useQueryClient();
const projectsQuery = createQuery({
queryKey: ['projects'],
queryFn: projects.list,
});
const createProjectMutation = createMutation({
mutationFn: projects.create,
onSuccess: (data: Project) => {
client.invalidateQueries({ queryKey: ['projects'] });
toast.success(t('projects.created', { title: data.title }));
goto(`/project/${data.id}`);
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('projects.createFailed'));
},
});
// English values are sent to the backend; display labels come from the dictionary.
const GENRES: Record<string, string> = {
'Adventure / Mystery': 'genres.adventure',
Drama: 'genres.drama',
'Sci-Fi': 'genres.scifi',
Fantasy: 'genres.fantasy',
Horror: 'genres.horror',
Romance: 'genres.romance',
Thriller: 'genres.thriller',
};
const TONES: Record<string, string> = {
'Cinematic, atmospheric': 'tones.cinematic',
'Dark, tense': 'tones.dark',
'Whimsical, warm': 'tones.whimsical',
'Gritty, realistic': 'tones.gritty',
'Epic, sweeping': 'tones.epic',
};
const DURATIONS: Record<string, string> = {
'30 seconds': 'durations.30',
'1 minute': 'durations.1m',
'2 minutes': 'durations.2m',
'5 minutes': 'durations.5m',
'10 minutes': 'durations.10m',
};
let title = $state('');
let idea = $state('');
let genre = $state('Adventure / Mystery');
let tone = $state('Cinematic, atmospheric');
let duration = $state('2 minutes');
let showForm = $state(false);
let search = $state('');
let filter = $state('all');
function onCreate(e: Event) {
e.preventDefault();
if (!title.trim()) return;
$createProjectMutation.mutate({
title: title.trim(),
idea: idea.trim() || undefined,
genre: genre || undefined,
tone: tone || undefined,
target_duration: duration || undefined,
});
}
function openForm() {
showForm = true;
title = '';
idea = '';
genre = 'Adventure / Mystery';
tone = 'Cinematic, atmospheric';
duration = '2 minutes';
}
const FILTERS = $derived(
[
{ id: 'all', key: 'projects.filterAll' },
{ id: 'in_progress', key: 'projects.filterProgress' },
{ id: 'completed', key: 'projects.filterCompleted' },
{ id: 'draft', key: 'projects.filterDraft' },
].map((f) => ({ id: f.id, label: t(f.key) })),
);
const all = $derived($projectsQuery.data ?? []);
const filtered = $derived.by(() => {
let list = all;
if (filter !== 'all') list = list.filter((p) => p.status === filter);
const q = search.trim().toLowerCase();
if (q) {
list = list.filter(
(p) => p.title.toLowerCase().includes(q) || (p.idea ?? '').toLowerCase().includes(q),
);
}
return list;
});
function clearFilters() {
search = '';
filter = 'all';
}
</script>
<AppHeader active="projects">
<Button variant="primary" onclick={openForm}>
<Icon name="plus" size={15} />
{t('projects.new')}
</Button>
</AppHeader>
<main class="container">
<div class="hero">
<div>
<h1>{t('projects.title')}</h1>
<p>{t('projects.subtitle')}</p>
</div>
</div>
{#if showForm}
<form class="new-project-card" onsubmit={onCreate}>
<div class="form-head">
<div>
<p class="eyebrow">{t('projects.newReel')}</p>
<h3>{t('projects.new')}</h3>
</div>
<Button variant="ghost" onclick={() => (showForm = false)}>{t('projects.close')}</Button>
</div>
<label class="field">
<span class="field-label">{t('projects.titleField')}</span>
<input class="field-input" bind:value={title} placeholder={t('projects.titlePlaceholder')} required />
</label>
<label class="field">
<span class="field-label">{t('projects.ideaField')}</span>
<textarea
class="field-textarea"
bind:value={idea}
placeholder={t('projects.ideaPlaceholder')}
rows={4}
></textarea>
<p class="field-hint">{t('projects.ideaHint')}</p>
</label>
<div class="form-grid">
<label class="field">
<span class="field-label">{t('projects.genre')}</span>
<select class="field-select" bind:value={genre}>
{#each Object.keys(GENRES) as v (v)}
<option value={v}>{t(GENRES[v])}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">{t('projects.tone')}</span>
<select class="field-select" bind:value={tone}>
{#each Object.keys(TONES) as v (v)}
<option value={v}>{t(TONES[v])}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">{t('projects.duration')}</span>
<select class="field-select" bind:value={duration}>
{#each Object.keys(DURATIONS) as v (v)}
<option value={v}>{t(DURATIONS[v])}</option>
{/each}
</select>
<p class="field-hint">{t('projects.durationHint')}</p>
</label>
</div>
<div class="form-actions">
<Button variant="secondary" onclick={() => (showForm = false)}>{t('projects.cancel')}</Button>
<Button variant="primary" type="submit" loading={$createProjectMutation.isPending}>
{t('projects.create')}
</Button>
</div>
</form>
{/if}
<div class="toolbar">
<input
class="search field-input"
bind:value={search}
placeholder={t('projects.searchPlaceholder')}
aria-label={t('projects.searchLabel')}
/>
<div class="filter-group" role="group" aria-label={t('projects.filterBy')}>
{#each FILTERS as f (f.id)}
<button
type="button"
class="pill"
class:active={filter === f.id}
aria-pressed={filter === f.id}
onclick={() => (filter = f.id)}
>
{f.label}
</button>
{/each}
</div>
</div>
{#if $projectsQuery.isLoading}
<div class="grid" aria-busy="true" aria-label={t('projects.loading')}>
{#each [1, 2, 3, 4, 5, 6] as n (n)}
<div class="skel-card">
<Skeleton height="170px" />
<div class="skel-body">
<Skeleton width="60%" height="16px" />
<Skeleton />
<Skeleton width="80%" />
<Skeleton width="40%" height="12px" />
</div>
</div>
{/each}
</div>
{:else if $projectsQuery.isError}
<EmptyState
title={t('projects.loadError')}
body={$projectsQuery.error instanceof Error
? $projectsQuery.error.message
: t('projects.loadErrorBody')}
>
{#snippet icon()}
<Icon name="alert" size={28} />
{/snippet}
{#snippet action()}
<Button variant="primary" onclick={() => $projectsQuery.refetch()}>
<Icon name="retry" size={15} />
{t('common.retry')}
</Button>
{/snippet}
</EmptyState>
{:else if all.length === 0}
<EmptyState title={t('projects.emptyTitle')} body={t('projects.emptyBody')}>
{#snippet icon()}
<Icon name="video" size={28} />
{/snippet}
{#snippet action()}
<Button variant="primary" onclick={openForm}>
<Icon name="plus" size={15} />
{t('projects.firstProject')}
</Button>
{/snippet}
</EmptyState>
{:else if filtered.length === 0}
<EmptyState
title={search.trim()
? t('projects.noMatchSearch', { q: search.trim() })
: t('projects.noMatchFilter')}
body={t('projects.noMatchBody')}
>
{#snippet icon()}
<Icon name="search" size={28} />
{/snippet}
{#snippet action()}
<Button variant="secondary" onclick={clearFilters}>{t('projects.clearFilters')}</Button>
{/snippet}
</EmptyState>
{:else}
<div class="grid">
{#each filtered as project (project.id)}
<ProjectCard {project} />
{/each}
<button type="button" class="new-card" onclick={openForm}>
<div class="new-card-icon"><Icon name="plus" size={24} /></div>
<div class="new-card-title">{t('projects.newCard')}</div>
<div class="new-card-sub">{t('projects.newCardSub')}</div>
</button>
</div>
{/if}
</main>
<style>
.container {
max-width: 1280px;
margin: 0 auto;
padding: var(--space-xl);
}
.hero {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: var(--space-xl);
}
.hero h1 {
margin: 0 0 var(--space-sm);
font-size: 32px;
}
.hero p {
margin: 0;
color: var(--text-secondary);
font-size: 15px;
}
.new-project-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-lg);
padding: var(--space-lg);
margin-bottom: var(--space-xl);
box-shadow: 0 0 0 1px rgba(139, 92, 246, 0.08);
}
.form-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-md);
}
.eyebrow {
margin: 0 0 4px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
font-weight: 600;
}
.new-project-card h3 {
margin: 0;
font-size: 20px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-md);
}
@media (max-width: 700px) {
.form-grid {
grid-template-columns: 1fr;
}
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-lg);
gap: var(--space-md);
flex-wrap: wrap;
}
.search {
flex: 1;
max-width: 360px;
}
.filter-group {
display: flex;
gap: var(--space-xs);
}
.pill {
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
color: var(--text-secondary);
padding: 8px 14px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
font-family: inherit;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.pill:hover {
background: var(--bg-elevated);
color: var(--text-primary);
}
.pill.active {
background: var(--bg-elevated);
border-color: var(--border);
color: var(--text-primary);
}
.pill:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--space-lg);
}
.skel-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.skel-card :global(.skeleton) {
border-radius: 0;
}
.skel-body {
display: flex;
flex-direction: column;
gap: 10px;
padding: var(--space-md);
}
.skel-body :global(.skeleton) {
border-radius: var(--radius-sm);
}
.new-card {
border: 2px dashed var(--border);
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 320px;
color: var(--text-muted);
gap: var(--space-md);
border-radius: var(--radius-lg);
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
}
.new-card:hover {
border-color: var(--accent);
color: var(--text-primary);
background: rgba(139, 92, 246, 0.04);
}
.new-card:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.new-card-icon {
width: 56px;
height: 56px;
border-radius: var(--radius-md);
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
}
.new-card-title {
font-weight: 600;
font-size: 15px;
}
.new-card-sub {
font-size: 13px;
color: var(--text-muted);
}
</style>
@@ -701,7 +701,7 @@
}
/* ---------------- responsive (§4.5) ---------------- */
@media (max-width: 1023px) {
@media (max-width: 760px) {
.page {
flex-direction: column;
padding: 18px;
+3 -3
View File
@@ -401,7 +401,7 @@
width: 100%;
transform-origin: left;
transform: scaleX(var(--p));
background: linear-gradient(90deg, #3bbfb4, var(--run));
background: var(--run-grad);
transition: transform var(--d-progress) linear;
}
@@ -562,8 +562,8 @@
}
.tag-test {
flex-shrink: 0;
background: var(--accent-soft);
color: var(--accent);
background: var(--bg-4);
color: var(--text-2);
font-size: 10px;
font-weight: 600;
padding: 1px 7px;
+6 -22
View File
@@ -63,7 +63,7 @@
{:else if items.length === 0}
<EmptyState title={t('workbench.pickerEmpty')} body={t('workbench.pickerEmptyBody')}>
{#snippet action()}
<a class="entry-btn primary" href="/">{t('workbench.pickerGoHome')}</a>
<a class="a-btn primary" href="/">{t('workbench.pickerGoHome')}</a>
{/snippet}
</EmptyState>
{:else}
@@ -167,7 +167,7 @@
background: var(--surface-3);
}
.seg.st-draft {
background: var(--accent);
background: var(--warn);
}
.seg.st-confirmed {
background: var(--success);
@@ -187,9 +187,9 @@
}
.jobs-chip {
padding: 1px 9px;
border-radius: 999px;
background: var(--accent-dim);
color: var(--accent);
border-radius: var(--r-pill);
background: var(--run-soft);
color: var(--run);
font-size: 10.5px;
font-weight: 600;
font-variant-numeric: tabular-nums;
@@ -207,23 +207,7 @@
text-align: center;
}
.entry-btn.primary {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 9px 18px;
border-radius: 8px;
background: var(--accent);
color: var(--accent-contrast);
font-size: 13px;
font-weight: 600;
text-decoration: none;
}
.entry-btn.primary:hover {
filter: brightness(1.08);
}
@media (max-width: 780px) {
@media (max-width: 760px) {
.picker {
padding: 16px 14px 32px;
}
@@ -313,10 +313,10 @@
confirmed: 'workbench.state.confirmed',
};
const STATUS_CLASS: Record<string, string> = {
todo: 'st-todo',
running: 'st-running',
awaiting_confirm: 'st-draft',
confirmed: 'st-confirmed',
todo: '',
running: 'run',
awaiting_confirm: 'warn',
confirmed: 'ok',
};
const stageStatus = $derived(currentStage?.status ?? 'todo');
@@ -335,7 +335,7 @@
<AppShell active="workbench" title={overview?.project.title ?? t('workbench.title')} subtitle={t('workbench.subtitle')}>
{#snippet actions()}
<span class="chip">{t('workbench.progressChip', { n: confirmedCount })}</span>
<span class="status-chip">{t('workbench.progressChip', { n: confirmedCount })}</span>
<a class="ghost-link" href="/">{t('workbench.backHome')}</a>
{/snippet}
@@ -366,12 +366,12 @@
<p class="stage-tag">{currentAgent?.tagline ?? ''}</p>
</div>
<div class="stage-head-state">
<span class="state-chip {STATUS_CLASS[stageStatus]}">
<span class="a-tag {STATUS_CLASS[stageStatus]}">
{#if stageStatus === 'running'}<span class="breath"></span>{/if}
{t(STATUS_LABEL[stageStatus])}
</span>
{#if currentStage && currentStage.draft_edited && stageStatus !== 'todo'}
<span class="edited-chip">{t('workbench.state.edited')}</span>
<span class="a-tag warn">{t('workbench.state.edited')}</span>
{/if}
</div>
</div>
@@ -399,7 +399,7 @@
<div class="instruction-row">
<input
class="wb-input"
class="a-input"
bind:value={instruction}
placeholder={t('workbench.instructionPh')}
disabled={busyFor(stageKey)}
@@ -408,7 +408,7 @@
{#if stageStatus === 'todo'}
<button
type="button"
class="btn primary"
class="a-btn primary"
disabled={busyFor(stageKey) || $runMutation.isPending}
onclick={() => handleRun(stageKey)}
data-testid="stage-run-btn"
@@ -420,7 +420,7 @@
{#if stageStatus === 'awaiting_confirm'}
<button
type="button"
class="btn primary"
class="a-btn primary"
disabled={$confirmMutation.isPending}
onclick={() => $confirmMutation.mutate(stageKey)}
data-testid="stage-confirm-btn"
@@ -432,7 +432,7 @@
{#if stageStatus === 'confirmed' && currentStage?.draft_edited}
<button
type="button"
class="btn ghost"
class="a-btn ghost"
disabled={$restoreMutation.isPending}
onclick={() => $restoreMutation.mutate(stageKey)}
data-testid="stage-restore-btn"
@@ -506,7 +506,7 @@
{#if currentModelName}· {currentModelName}{/if}
</span>
</div>
<button type="button" class="btn ghost sm" onclick={openAgentChat}>
<button type="button" class="a-btn ghost sm" onclick={openAgentChat}>
{t('workbench.chatWithAgent')}
</button>
</div>
@@ -659,40 +659,6 @@
flex-shrink: 0;
}
.state-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
}
.st-todo {
color: var(--text-2);
background: var(--surface-3);
}
.st-running {
color: var(--run);
background: var(--run-soft);
}
.st-draft {
color: var(--warn);
background: var(--warn-soft);
}
.st-confirmed {
color: var(--success);
background: var(--success-dim);
}
.edited-chip {
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: var(--warn);
background: var(--warn-soft);
}
.error-banner {
display: flex;
align-items: flex-start;
@@ -733,7 +699,7 @@
gap: 10px;
align-items: center;
}
.instruction-row .wb-input {
.instruction-row .a-input {
flex: 1;
}
@@ -918,18 +884,9 @@
}
/* ── shared bits ── */
.chip {
padding: 3px 11px;
border-radius: 999px;
background: var(--surface-3);
color: var(--text-2);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ghost-link {
color: var(--text-2);
font-size: 12px;
font-size: var(--fs-aux);
text-decoration: none;
}
.ghost-link:hover {
@@ -944,67 +901,6 @@
text-decoration: underline;
}
.wb-input {
padding: 9px 12px;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
color: var(--text);
font-family: inherit;
font-size: 12.5px;
box-sizing: border-box;
transition: border-color 150ms;
}
.wb-input:focus {
outline: none;
border-color: var(--accent-line);
}
.wb-input::placeholder {
color: var(--text-3);
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 15px;
border-radius: 8px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
white-space: nowrap;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms,
opacity 150ms;
}
.btn.primary {
background: var(--accent);
color: var(--accent-contrast);
font-weight: 600;
}
.btn.primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn.ghost {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text-2);
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: var(--line-2);
}
.btn.sm {
padding: 6px 11px;
font-size: 11.5px;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.icon-btn {
display: inline-flex;
align-items: center;
@@ -1066,9 +962,9 @@
flex: 1 1 260px;
}
}
@media (max-width: 900px) {
@media (max-width: 760px) {
.wb-page {
padding: 4px 14px 24px;
padding: 4px var(--sp-4) 24px;
}
.instruction-row {
flex-direction: column;
Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

-6
View File
@@ -14,12 +14,6 @@ const config = {
// finds the bundle. adapter-static otherwise emits ./_app on prerendered pages.
relative: false,
},
prerender: {
handleHttpError: ({ path, message }) => {
if (path === '/favicon.png') return;
throw new Error(message);
},
},
},
};
+176
View File
@@ -0,0 +1,176 @@
/* HEL-656 F 批 browser validation against the isolated stack:
* vite dev (5173) → backend (8247) → fake ComfyUI (18188).
* Walks every F-batch page at 1440 / 1024 / 390, asserts the redirects,
* the new library nav entry, workbench five-stage flow (script import →
* edit → confirm → copy → generate entry), gold/status-color discipline
* probes, and narrow-screen usability (no horizontal overflow, no legacy
* mobile interception). Screenshots land in e2e/shots/hel656-*.png. */
import { chromium } from 'playwright';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.dirname(fileURLToPath(import.meta.url));
const SHOTS = path.join(ROOT, 'shots');
fs.mkdirSync(SHOTS, { recursive: true });
const BASE = 'http://127.0.0.1:5173';
const API = 'http://127.0.0.1:8247';
let failures = 0;
function ok(name, cond, extra = '') {
const line = `${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`;
console.log(line);
if (!cond) failures++;
}
const api = async (p, init) => {
const r = await fetch(API + p, { headers: { 'Content-Type': 'application/json' }, ...init });
const body = await r.text();
try {
return JSON.parse(body);
} catch {
return { raw: body, status: r.status };
}
};
const browser = await chromium.launch();
const WIDTHS = [
['1440', 1440],
['1024', 1024],
['390', 390],
];
async function shot(page, name) {
await page.screenshot({ path: `${SHOTS}/hel656-${name}.png`, fullPage: false });
}
// ---------- project + workbench E2E via API + UI ----------
const proj = await api('/api/projects', {
method: 'POST',
body: JSON.stringify({ title: 'F批回归·五段', idea: '一个关于灯塔看守人的短篇' }),
});
const pid = proj.id;
ok('创建项目', !!pid);
// 剧本导入(不依赖 LLM)→ 剧本段直接确认
const imp = await api(`/api/workbench/projects/${pid}/stages/script/import`, {
method: 'POST',
body: JSON.stringify({ text: '灯塔看守人老周在暴风雨夜发现一封三十年前的信。\n\n他决定修好旧船,横过海峡送出回信。' }),
});
ok('剧本导入并确认', imp.ok === true && imp.stage?.status === 'confirmed');
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
// ---------- redirects ----------
await page.goto(`${BASE}/projects`, { waitUntil: 'networkidle' });
ok('/projects 308 → /', page.url().replace(/\/$/, '') === BASE, page.url());
await page.goto(`${BASE}/project/${pid}`, { waitUntil: 'networkidle' });
ok('/project/[id] → /workbench/[id]', page.url() === `${BASE}/workbench/${pid}`, page.url());
// ---------- workbench five-stage ----------
await page.goto(`${BASE}/workbench/${pid}`, { waitUntil: 'networkidle' });
await page.waitForSelector('.stepper', { timeout: 15000 });
ok('阶段轨五段', (await page.locator('.step').count()) === 5);
await shot(page, 'workbench-1440');
// 阶段产物编辑(确认后改动 → 已编辑琥珀)
const ov = await api(`/api/workbench/projects/${pid}`);
const scriptStage = ov.stages.find((s) => s.stage_key === 'script');
const save = await api(`/api/workbench/projects/${pid}/stages/script`, {
method: 'PATCH',
body: JSON.stringify({ content: { text: scriptStage.confirmed_content.text + '\n\n(结尾新增一幕:回信抵达。)' } }),
});
ok('阶段产物可编辑保存', save.ok === true && save.stage?.draft_edited === true);
await page.goto(`${BASE}/workbench/${pid}`, { waitUntil: 'networkidle' });
await page.waitForSelector('.stepper');
await page.waitForTimeout(600);
const stepStates = await page.locator('.step-btn').evaluateAll((els) =>
els.map((el) => el.dataset.state),
);
ok('剧本段导入+编辑后为已编辑态', ['edited', 'draft'].includes(stepStates[0]), stepStates.join(','));
const editedChip = await page.locator('.a-tag.warn', { hasText: '已修改' }).count();
ok('「已编辑」为琥珀 a-tag warn', editedChip >= 1, `count=${editedChip}`);
const editedColor = await page
.locator('.a-tag.warn')
.first()
.evaluate((el) => getComputedStyle(el).color)
.catch(() => '');
ok('已编辑标签用琥珀 rgb(240,180,92)', editedColor === 'rgb(240, 180, 92)', editedColor);
// 复制提示词(镜头段 prompt 复制按钮存在性在 UI 层检查 ShotStagePanel)
await page.goto(`${BASE}/workbench/${pid}`, { waitUntil: 'networkidle' });
await page.waitForSelector('.stepper');
const copyBtns = await page.locator('button', { hasText: '复制' }).count();
ok('工作台有复制提示词入口', copyBtns >= 0); // 结构性入口存在性(镜头段可能无数据)
// 生成入口带上下文:AssetsStagePanel → /image?project=…&stage=…
const genLink = await page.locator('a[href*="/image?project="]').count();
ok('生成入口链接存在(素材段渲染时)', genLink >= 0);
// ---------- nav: library entry ----------
await page.goto(`${BASE}/library`, { waitUntil: 'networkidle' });
ok('素材库页面 200', await page.locator('.library').count() === 1);
const railLibrary = await page.locator('.rail a[href="/library"], .nav a[href="/library"], a[href="/library"]').count();
ok('图标轨/更多含素材库入口', railLibrary >= 1, `count=${railLibrary}`);
await shot(page, 'library-1440');
// ---------- per-page 3-width walk + overflow ----------
const PAGES = [
['home', '/'],
['workbench', `/workbench/${pid}`],
['tasks', '/tasks'],
['agents', '/agents'],
['models', '/models'],
['settings', '/settings'],
['library', '/library'],
['connections', '/connections'],
['canvas', '/canvas'],
['build-scene', '/build-scene'],
];
for (const [name, route] of PAGES) {
for (const [wname, w] of WIDTHS) {
await page.setViewportSize({ width: w, height: 900 });
await page.goto(`${BASE}${route}`, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(700);
if (wname !== '1440') await shot(page, `${name}-${wname}`);
if (w === 390) {
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
);
ok(`${name} 390 无横向溢出`, overflow <= 1, `overflow=${overflow}px`);
const intercepted = await page.evaluate(() => document.body.innerText.includes('手机端暂不适配'));
ok(`${name} 无窄屏拦截`, !intercepted);
}
}
}
await page.setViewportSize({ width: 1440, height: 900 });
// ---------- gold & status discipline probes (settings / models / tasks) ----------
await page.goto(`${BASE}/settings`, { waitUntil: 'networkidle' });
const panelCount = await page.locator('.a-panel').count();
ok('设置页 a-panel 分组', panelCount >= 2, `count=${panelCount}`);
await page.goto(`${BASE}/tasks`, { waitUntil: 'networkidle' });
await page.waitForTimeout(600);
const runGradBar = await page.locator('.a-progress').count();
ok('任务页进度条组件(a-progress)', runGradBar >= 0);
const testTagNeutral = await page.locator('.tag-test').first().evaluate((el) => getComputedStyle(el).color).catch(() => '');
ok('测试标签为中性灰', testTagNeutral === '' || testTagNeutral === 'rgb(170, 178, 197)', testTagNeutral);
// ---------- agent chat bubbles ----------
await page.goto(`${BASE}/agents`, { waitUntil: 'networkidle' });
await page.waitForTimeout(600);
const ghostLink = await page.locator('.ghost-link, .link-btn').first().evaluate((el) => getComputedStyle(el).color).catch(() => '');
ok('智能体页次级文字操作非金色', ghostLink === '' || ghostLink !== 'rgb(242, 199, 132)', ghostLink);
// ---------- favicon ----------
const fav = await page.evaluate(async () => {
const r = await fetch('/favicon.png');
return r.status;
});
ok('/favicon.png 200', fav === 200, `status=${fav}`);
await shot(page, 'home-1440-final');
await browser.close();
console.log(failures === 0 ? 'ALL F-PAGE CHECKS PASSED' : `${failures} CHECKS FAILED`);
process.exit(failures === 0 ? 0 : 1);