HEL-652: B 批导航壳层——60px 图标轨、顶栏与手机底栏

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

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