Headline: - Group colour cascades to every non-overridden child (layer or clip track). New `colorOverridden` flag on Layer / ClipTrack / ScriptTrack / TrackGroup; setTrackColor on a non-group pins the track, clearing re-inherits from the parent group. moveTrackIntoGroup also inherits. normalizeScene migrates legacy scenes (existing colour ⇒ overridden). - Keyframes inside a button-asset timeline snap to the 4 state slots (normal/hover/pressed/disabled). upsertKeyframe / setKeyframeTime / duplicateKeyframe all enforce it; colliding drags refuse the move, duplicates walk to the nearest free slot. - Backspace / Delete on a selected layer removes it (respecting the layer's own lock and any enclosing group lock). - "Still frame" allowed on the last keyframe: the layer stays on stage past its final kf. Timeline draws a solid dim tail line from the last kf to the end, and single-keyframe tracks now show a full-span line so on-stage/off-stage reads at a glance. Height stays 3px. - Project name moved next to the logo, turns into a rename button on hover (pencil fades in). Rename syncs the stored record when the project is already persisted. - Shadow + blur keyframes animate in the preview and exported runtime: the runtime's lerpProps used to drop them, so every preview rendered shadow=0 between keyframes. RUNTIME_VERSION 11 → 13 (covers the button-mask + shadow fixes). Held-last-kf also taught to the runtime. - Button masks render across every path: edit-mode mask-preview, nested buttons on the main stage, sidebar thumbnails, fullscreen preview + exports. drawButtonFrame new in render.ts. Smaller fixes: - Dragging layer A on the stage with a stale selection on layer B used to move B and snap A back. Now the shift-selected branch only runs when the dragged layer is itself in the selection. - Clip-track row was missing its icon — PhFilmStrip was referenced but never imported. Added, plus dropped the bespoke `gap` on clip / script name rows so their icon+label spacing matches layer rows. - Clip-art drawer pre-lays-out its 400px grid behind `overflow: hidden` so slide-in reveals a finished grid instead of reflowing auto-fill columns over 220ms. - Timeline clip-track dropzone hides until the library has a clip; its "drop a clip here" hint stays centred in the visible ruler area. - Keyframe context menu: check-mark and icons share a single 12px column so the ✓ on an easing option lines up with PhPauseCircle / PhCopy / PhTrash on the rows below. "Still frame"'s active indicator moved to the trailing-check slot. - Button editor's mask-preview now shows the button's own stage instead of leaking through to the main scene. drawButtonFrame drives it. Regression tests cover all of the above: group-color cascade, pin, clear-re-inherit, migration, button-kf snap and collision rules, backspace-deletes-selected-layer (incl. lock guards + editable-target guard), still-frame-past-end visibility, clipart drawer rigid width, mask rendering in drawButtonFrame / drawSceneFrame, shadow + blur interpolation in both canvas and runtime paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
818 lines
21 KiB
Vue
818 lines
21 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
import {
|
|
PhArrowClockwise,
|
|
PhArrowCounterClockwise,
|
|
PhArrowLeft,
|
|
PhCursorClick,
|
|
PhExport,
|
|
PhFilmStrip,
|
|
PhFolderOpen,
|
|
PhFloppyDisk,
|
|
PhGearSix,
|
|
PhPause,
|
|
PhPencilSimpleLine,
|
|
PhPlay,
|
|
PhSkipBack,
|
|
PhSparkle,
|
|
PhStop,
|
|
} from '@phosphor-icons/vue'
|
|
import AboutDialog from './components/AboutDialog.vue'
|
|
import AssetSidebar from './components/AssetSidebar.vue'
|
|
import CanvasStage from './components/CanvasStage.vue'
|
|
import ClipartDrawer from './components/ClipartDrawer.vue'
|
|
import DocumentSettings from './components/DocumentSettings.vue'
|
|
import ExportDialog from './components/ExportDialog.vue'
|
|
import FullscreenPreview from './components/FullscreenPreview.vue'
|
|
import PropertiesPanel from './components/PropertiesPanel.vue'
|
|
import Timeline from './components/Timeline.vue'
|
|
import WelcomeDialog from './components/WelcomeDialog.vue'
|
|
import TestOverlay from './devtools/TestOverlay.vue'
|
|
import ruwioLogo from './assets/logo.png'
|
|
import { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './components/ui'
|
|
import { t } from './i18n'
|
|
import { useHistory } from './composables/useHistory'
|
|
import { usePlaybackLoop } from './composables/usePlaybackLoop'
|
|
import {
|
|
ProjectFileError,
|
|
downloadProject,
|
|
pickProjectFile,
|
|
readProjectFile,
|
|
} from './lib/projectFile'
|
|
import {
|
|
getLastOpenedId,
|
|
listRecentProjects,
|
|
saveProjectToStore,
|
|
setLastOpenedId,
|
|
type StoredProject,
|
|
} from './lib/projectStore'
|
|
import { importImageFile } from './lib/fileLoader'
|
|
import { useClipartStore } from './stores/clipart'
|
|
import { createBlankScene, useSceneStore } from './stores/scene'
|
|
|
|
const settingsOpen = ref(false)
|
|
const exportOpen = ref(false)
|
|
const testsOpen = ref(false)
|
|
const welcomeOpen = ref(false)
|
|
const fullscreenOpen = ref(false)
|
|
const aboutOpen = ref(false)
|
|
const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight'
|
|
const DEFAULT_TIMELINE_HEIGHT = 220
|
|
const MIN_TIMELINE_HEIGHT = 120
|
|
const MAX_TIMELINE_HEIGHT_MARGIN = 160
|
|
|
|
function readStoredTimelineHeight(): number {
|
|
try {
|
|
const raw = localStorage.getItem(TIMELINE_HEIGHT_KEY)
|
|
if (raw === null) return DEFAULT_TIMELINE_HEIGHT
|
|
const n = Number.parseInt(raw, 10)
|
|
if (Number.isFinite(n) && n >= MIN_TIMELINE_HEIGHT) return n
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return DEFAULT_TIMELINE_HEIGHT
|
|
}
|
|
|
|
const timelineHeight = ref(readStoredTimelineHeight())
|
|
|
|
function maxTimelineHeight(): number {
|
|
return Math.max(MIN_TIMELINE_HEIGHT, window.innerHeight - MAX_TIMELINE_HEIGHT_MARGIN)
|
|
}
|
|
|
|
const appStyle = computed(() => ({
|
|
'--timeline-height': `${timelineHeight.value}px`,
|
|
}))
|
|
|
|
let resizeStartY = 0
|
|
let resizeStartHeight = 0
|
|
let resizePointerId = -1
|
|
const resizing = ref(false)
|
|
|
|
function onTimelineResizeStart(e: PointerEvent) {
|
|
if (e.button !== 0) return
|
|
e.preventDefault()
|
|
const el = e.currentTarget as HTMLElement
|
|
el.setPointerCapture(e.pointerId)
|
|
resizePointerId = e.pointerId
|
|
resizeStartY = e.clientY
|
|
resizeStartHeight = timelineHeight.value
|
|
resizing.value = true
|
|
document.body.style.userSelect = 'none'
|
|
}
|
|
|
|
function onTimelineResizeMove(e: PointerEvent) {
|
|
if (!resizing.value || e.pointerId !== resizePointerId) return
|
|
const dy = resizeStartY - e.clientY
|
|
const next = Math.max(
|
|
MIN_TIMELINE_HEIGHT,
|
|
Math.min(maxTimelineHeight(), resizeStartHeight + dy),
|
|
)
|
|
timelineHeight.value = next
|
|
}
|
|
|
|
function onTimelineResizeEnd(e: PointerEvent) {
|
|
if (!resizing.value || e.pointerId !== resizePointerId) return
|
|
const el = e.currentTarget as HTMLElement
|
|
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
|
resizePointerId = -1
|
|
resizing.value = false
|
|
document.body.style.userSelect = ''
|
|
try {
|
|
localStorage.setItem(TIMELINE_HEIGHT_KEY, String(timelineHeight.value))
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
function onWindowResize() {
|
|
const max = maxTimelineHeight()
|
|
if (timelineHeight.value > max) timelineHeight.value = max
|
|
}
|
|
const loadError = ref<string | null>(null)
|
|
const savedToast = ref<string | null>(null)
|
|
const currentProjectId = ref<string | null>(null)
|
|
const currentProjectName = ref<string | null>(null)
|
|
|
|
usePlaybackLoop()
|
|
const store = useSceneStore()
|
|
const clipart = useClipartStore()
|
|
const history = useHistory()
|
|
|
|
function rewind() {
|
|
store.setPlayhead(store.playbackIn)
|
|
}
|
|
|
|
function stop() {
|
|
store.pause()
|
|
}
|
|
|
|
async function saveProject() {
|
|
const scene = JSON.parse(JSON.stringify(store.scene))
|
|
let name = currentProjectName.value
|
|
if (!name) {
|
|
const suggested = t('app.saveDialog.defaultName', { date: new Date().toLocaleDateString() })
|
|
const entered = await promptDialog({
|
|
title: t('app.saveDialog.title'),
|
|
message: t('app.saveDialog.message'),
|
|
defaultValue: suggested,
|
|
confirmLabel: t('app.saveDialog.confirm'),
|
|
})
|
|
if (entered === null) return
|
|
name = entered.trim() || suggested
|
|
}
|
|
const rec = await saveProjectToStore(scene, {
|
|
id: currentProjectId.value ?? undefined,
|
|
name,
|
|
})
|
|
currentProjectId.value = rec.id
|
|
currentProjectName.value = rec.name
|
|
setLastOpenedId(rec.id)
|
|
showSavedToast(t('app.savedToast', { name: rec.name }))
|
|
}
|
|
|
|
async function renameProject() {
|
|
const suggested =
|
|
currentProjectName.value ??
|
|
t('app.saveDialog.defaultName', { date: new Date().toLocaleDateString() })
|
|
const entered = await promptDialog({
|
|
title: t('app.renameProjectDialog.title'),
|
|
message: t('app.renameProjectDialog.message'),
|
|
defaultValue: suggested,
|
|
confirmLabel: t('app.renameProjectDialog.confirm'),
|
|
})
|
|
if (entered === null) return
|
|
const trimmed = entered.trim()
|
|
if (!trimmed || trimmed === currentProjectName.value) return
|
|
currentProjectName.value = trimmed
|
|
// If the project is already persisted in browser storage, sync the
|
|
// new name to its record so future loads (and the recents list) use
|
|
// it. Unsaved projects just get the in-memory name — the next save
|
|
// picks it up.
|
|
if (currentProjectId.value) {
|
|
const scene = JSON.parse(JSON.stringify(store.scene))
|
|
const rec = await saveProjectToStore(scene, {
|
|
id: currentProjectId.value,
|
|
name: trimmed,
|
|
})
|
|
currentProjectName.value = rec.name
|
|
}
|
|
}
|
|
|
|
function exportProjectFile() {
|
|
const scene = JSON.parse(JSON.stringify(store.scene))
|
|
const base = currentProjectName.value ?? 'animatr'
|
|
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'animatr'
|
|
downloadProject(scene, `${safe}.json`)
|
|
}
|
|
|
|
async function importFromFile() {
|
|
const file = await pickProjectFile()
|
|
if (!file) return
|
|
const hasContent = store.scene.layers.length > 0 || store.scene.assets.length > 0
|
|
if (hasContent) {
|
|
const ok = await confirmDialog({
|
|
title: t('app.replaceDialog.title'),
|
|
message: t('app.replaceDialog.message'),
|
|
confirmLabel: t('app.replaceDialog.confirm'),
|
|
variant: 'danger',
|
|
})
|
|
if (!ok) return
|
|
}
|
|
try {
|
|
const scene = await readProjectFile(file)
|
|
store.loadScene(scene)
|
|
history.resetTo(scene)
|
|
currentProjectId.value = null
|
|
currentProjectName.value = file.name.replace(/\.[^.]+$/, '')
|
|
loadError.value = null
|
|
} catch (err) {
|
|
const msg =
|
|
err instanceof ProjectFileError ? err.message : (err as Error).message || String(err)
|
|
loadError.value = msg
|
|
setTimeout(() => {
|
|
loadError.value = null
|
|
}, 6000)
|
|
}
|
|
}
|
|
|
|
function openFromRecent(project: StoredProject) {
|
|
const cloned = JSON.parse(JSON.stringify(project.scene))
|
|
store.loadScene(cloned)
|
|
history.resetTo(cloned)
|
|
currentProjectId.value = project.id
|
|
currentProjectName.value = project.name
|
|
setLastOpenedId(project.id)
|
|
loadError.value = null
|
|
}
|
|
|
|
function startNewProject() {
|
|
const blank = createBlankScene()
|
|
store.loadScene(blank)
|
|
history.resetTo(blank)
|
|
currentProjectId.value = null
|
|
currentProjectName.value = null
|
|
loadError.value = null
|
|
}
|
|
|
|
function showSavedToast(msg: string) {
|
|
savedToast.value = msg
|
|
setTimeout(() => {
|
|
if (savedToast.value === msg) savedToast.value = null
|
|
}, 2500)
|
|
}
|
|
|
|
async function bootWelcome() {
|
|
const recents = await listRecentProjects()
|
|
if (recents.length > 0) {
|
|
welcomeOpen.value = true
|
|
const lastId = getLastOpenedId()
|
|
if (lastId) {
|
|
const last = recents.find((p) => p.id === lastId)
|
|
if (last) {
|
|
currentProjectName.value = last.name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function isEditable(el: EventTarget | null): boolean {
|
|
if (!(el instanceof HTMLElement)) return false
|
|
const tag = el.tagName
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
|
|
if (el.isContentEditable) return true
|
|
return false
|
|
}
|
|
|
|
function onDocumentContextMenu(e: MouseEvent) {
|
|
// Allow native context menu only on editable inputs (for paste, etc.).
|
|
if (isEditable(e.target)) return
|
|
e.preventDefault()
|
|
}
|
|
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
|
|
e.preventDefault()
|
|
testsOpen.value = true
|
|
return
|
|
}
|
|
const mod = e.ctrlKey || e.metaKey
|
|
if (mod && !isEditable(e.target)) {
|
|
const key = e.key.toLowerCase()
|
|
if (key === 'z' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
history.undo()
|
|
return
|
|
}
|
|
if ((key === 'z' && e.shiftKey) || key === 'y') {
|
|
e.preventDefault()
|
|
history.redo()
|
|
return
|
|
}
|
|
}
|
|
if (!isEditable(e.target) && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
|
|
const key = e.key.toLowerCase()
|
|
if (key === 'i') {
|
|
e.preventDefault()
|
|
store.setPlaybackRange(Math.min(store.playhead, store.playbackOut), null)
|
|
return
|
|
}
|
|
if (key === 'o') {
|
|
e.preventDefault()
|
|
store.setPlaybackRange(null, Math.max(store.playhead, store.playbackIn))
|
|
return
|
|
}
|
|
if (key === 'r') {
|
|
e.preventDefault()
|
|
rewind()
|
|
return
|
|
}
|
|
}
|
|
if (e.code !== 'Space') return
|
|
if (isEditable(e.target)) return
|
|
e.preventDefault()
|
|
if (e.shiftKey) {
|
|
store.pause()
|
|
fullscreenOpen.value = true
|
|
return
|
|
}
|
|
store.togglePlay()
|
|
}
|
|
|
|
function isTextInputTarget(target: EventTarget | null): boolean {
|
|
if (!(target instanceof HTMLElement)) return false
|
|
if (target.isContentEditable) return true
|
|
const tag = target.tagName
|
|
if (tag === 'INPUT') {
|
|
const t = (target as HTMLInputElement).type
|
|
// Only block typed text inputs — color / range / checkbox shouldn't swallow paste
|
|
return ['text', 'search', 'email', 'number', 'tel', 'url', 'password'].includes(t)
|
|
}
|
|
if (tag === 'TEXTAREA') return true
|
|
return false
|
|
}
|
|
|
|
async function onPaste(e: ClipboardEvent) {
|
|
if (isTextInputTarget(e.target)) return
|
|
const data = e.clipboardData
|
|
if (!data) return
|
|
const items = Array.from(data.items)
|
|
const imageItems = items.filter((it) => it.kind === 'file' && it.type.startsWith('image/'))
|
|
if (imageItems.length === 0) return
|
|
e.preventDefault()
|
|
for (const it of imageItems) {
|
|
const file = it.getAsFile()
|
|
if (!file) continue
|
|
try {
|
|
await importImageFile(store, file)
|
|
} catch {
|
|
/* ignore invalid paste payloads */
|
|
}
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('keydown', onKeyDown)
|
|
document.addEventListener('contextmenu', onDocumentContextMenu)
|
|
document.addEventListener('paste', onPaste)
|
|
window.addEventListener('resize', onWindowResize)
|
|
void clipart.initialize()
|
|
bootWelcome()
|
|
})
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('keydown', onKeyDown)
|
|
document.removeEventListener('contextmenu', onDocumentContextMenu)
|
|
document.removeEventListener('paste', onPaste)
|
|
window.removeEventListener('resize', onWindowResize)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="shell">
|
|
<ClipartDrawer />
|
|
<div class="app" :style="appStyle">
|
|
<header class="topbar">
|
|
<div class="brand-area">
|
|
<button class="brand" :title="t('about.openTooltip')" @click="aboutOpen = true">
|
|
<img :src="ruwioLogo" alt="RUWIO" class="brand-logo" />
|
|
</button>
|
|
<button
|
|
class="project-name-btn"
|
|
:class="{ 'is-untitled': !currentProjectName }"
|
|
:title="t('app.renameProject')"
|
|
@click="renameProject"
|
|
>
|
|
<span class="project-name-label">
|
|
{{ currentProjectName ?? t('app.untitledProject') }}
|
|
</span>
|
|
<PhPencilSimpleLine :size="12" class="project-name-pencil" />
|
|
</button>
|
|
</div>
|
|
<div class="transport">
|
|
<IconButton
|
|
:icon="PhArrowCounterClockwise"
|
|
icon-weight="bold"
|
|
size="md"
|
|
:disabled="!history.canUndo.value"
|
|
:title="t('app.undo')"
|
|
@click="history.undo"
|
|
/>
|
|
<IconButton
|
|
:icon="PhArrowClockwise"
|
|
icon-weight="bold"
|
|
size="md"
|
|
:disabled="!history.canRedo.value"
|
|
:title="t('app.redo')"
|
|
@click="history.redo"
|
|
/>
|
|
<div class="tsep"></div>
|
|
<IconButton
|
|
:icon="PhSkipBack"
|
|
icon-weight="fill"
|
|
size="md"
|
|
:title="t('app.jumpToStart')"
|
|
@click="rewind"
|
|
/>
|
|
<IconButton
|
|
:icon="store.playing ? PhPause : PhPlay"
|
|
icon-weight="fill"
|
|
size="md"
|
|
variant="primary"
|
|
:title="store.playing ? t('app.pause') : t('app.play')"
|
|
@click="store.togglePlay()"
|
|
/>
|
|
<IconButton
|
|
:icon="PhStop"
|
|
icon-weight="fill"
|
|
size="md"
|
|
:title="t('app.stop')"
|
|
@click="stop"
|
|
/>
|
|
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span>
|
|
</div>
|
|
<div class="topbar-right">
|
|
<button class="icon-btn" :title="t('app.recentProjects')" @click="welcomeOpen = true">
|
|
<PhFolderOpen :size="16" />
|
|
</button>
|
|
<button class="icon-btn" :title="t('app.saveProject')" @click="saveProject">
|
|
<PhFloppyDisk :size="16" />
|
|
</button>
|
|
<button class="icon-btn" :title="t('app.documentSettings')" @click="settingsOpen = true">
|
|
<PhGearSix :size="16" />
|
|
</button>
|
|
<button class="export-btn" :title="t('app.export')" @click="exportOpen = true">
|
|
<PhExport :size="14" weight="bold" />
|
|
{{ t('app.export') }}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
<aside class="sidebar">
|
|
<div class="sidebar-scroll"><AssetSidebar /></div>
|
|
<div v-if="store.activeClipId" class="scope-footer">
|
|
<button class="scope-back" @click="store.exitClip">
|
|
<PhArrowLeft :size="12" weight="bold" />
|
|
{{ t('app.mainTimeline') }}
|
|
</button>
|
|
<span class="scope-current" :title="store.activeClip?.name ?? ''">
|
|
<PhFilmStrip :size="12" />
|
|
{{ store.activeClip?.name ?? '' }}
|
|
</span>
|
|
</div>
|
|
<div v-else-if="store.activeButtonAssetId" class="scope-footer">
|
|
<button class="scope-back" @click="store.exitButtonAssetEdit">
|
|
<PhArrowLeft :size="12" weight="bold" />
|
|
{{ t('app.mainTimeline') }}
|
|
</button>
|
|
<span class="scope-current" :title="store.activeButtonAsset?.name ?? ''">
|
|
<PhCursorClick :size="12" />
|
|
{{ store.activeButtonAsset?.name ?? '' }}
|
|
</span>
|
|
</div>
|
|
</aside>
|
|
<main class="stage"><CanvasStage /></main>
|
|
<aside class="properties"><PropertiesPanel /></aside>
|
|
<footer class="timeline">
|
|
<div
|
|
class="tl-resize"
|
|
:class="{ resizing }"
|
|
:title="t('app.dragResize')"
|
|
@pointerdown="onTimelineResizeStart"
|
|
@pointermove="onTimelineResizeMove"
|
|
@pointerup="onTimelineResizeEnd"
|
|
@pointercancel="onTimelineResizeEnd"
|
|
></div>
|
|
<Timeline />
|
|
</footer>
|
|
<DocumentSettings v-model="settingsOpen" />
|
|
<ExportDialog v-model="exportOpen" />
|
|
<TestOverlay v-model="testsOpen" />
|
|
<FullscreenPreview v-model="fullscreenOpen" />
|
|
<AboutDialog v-model="aboutOpen" />
|
|
<DialogsHost />
|
|
<WelcomeDialog
|
|
v-model="welcomeOpen"
|
|
@open="openFromRecent"
|
|
@new="startNewProject"
|
|
@import="importFromFile"
|
|
/>
|
|
<Toast :visible="!!loadError" variant="error">
|
|
<strong>{{ t('app.loadFailed') }}</strong>
|
|
<span>{{ loadError }}</span>
|
|
</Toast>
|
|
<Toast :visible="!!savedToast" variant="info" :message="savedToast ?? ''" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.shell {
|
|
display: flex;
|
|
align-items: stretch;
|
|
height: 100vh;
|
|
width: 100vw;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.app {
|
|
display: grid;
|
|
grid-template-columns: 260px 1fr 300px;
|
|
grid-template-rows: 48px 1fr var(--timeline-height, 220px);
|
|
grid-template-areas:
|
|
'top top top'
|
|
'side stage props'
|
|
'time time time';
|
|
flex: 1 1 0;
|
|
min-width: 0;
|
|
height: 100%;
|
|
}
|
|
|
|
.topbar {
|
|
grid-area: top;
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 16px;
|
|
background: var(--panel);
|
|
border-bottom: 1px solid var(--border);
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
.brand-area {
|
|
flex: 1 1 0;
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
min-width: 0;
|
|
}
|
|
|
|
.brand {
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
letter-spacing: -0.01em;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 4px 8px;
|
|
margin-left: -8px;
|
|
background: transparent;
|
|
border: 1px solid transparent;
|
|
color: var(--text);
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.brand:hover {
|
|
background: var(--panel-alt);
|
|
border-color: var(--border);
|
|
}
|
|
|
|
.brand-icon {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.transport {
|
|
display: flex;
|
|
gap: 4px;
|
|
align-items: center;
|
|
}
|
|
|
|
.tsep {
|
|
width: 1px;
|
|
height: 18px;
|
|
background: var(--border);
|
|
margin: 0 4px;
|
|
}
|
|
|
|
.time {
|
|
margin-left: 10px;
|
|
font-variant-numeric: tabular-nums;
|
|
color: var(--text-dim);
|
|
font-size: 11px;
|
|
min-width: 92px;
|
|
text-align: center;
|
|
}
|
|
|
|
.topbar-right {
|
|
flex: 1 1 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 6px;
|
|
}
|
|
|
|
.brand-logo {
|
|
height: 40px;
|
|
width: auto;
|
|
object-fit: contain;
|
|
display: block;
|
|
user-select: none;
|
|
-webkit-user-drag: none;
|
|
}
|
|
|
|
.export-btn {
|
|
padding: 6px 12px;
|
|
border: 1px solid var(--accent);
|
|
color: var(--accent);
|
|
background: var(--accent-soft);
|
|
border-radius: var(--radius-sm);
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.export-btn:hover:not(:disabled) {
|
|
background: var(--accent-soft-strong);
|
|
}
|
|
|
|
.icon-btn {
|
|
width: 30px;
|
|
height: 30px;
|
|
padding: 0;
|
|
justify-content: center;
|
|
background: transparent;
|
|
border: 1px solid var(--border);
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.icon-btn:hover:not(:disabled) {
|
|
background: var(--panel-alt);
|
|
color: var(--text);
|
|
}
|
|
|
|
.sidebar {
|
|
grid-area: side;
|
|
background: var(--panel);
|
|
border-right: 1px solid var(--border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.sidebar-scroll {
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
overflow: auto;
|
|
}
|
|
|
|
.scope-footer {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 10px;
|
|
border-top: 1px solid var(--border-strong);
|
|
background: var(--accent-soft);
|
|
color: var(--accent);
|
|
font-size: 11px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.scope-back {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 4px 8px;
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--text-dim-strong);
|
|
font-size: 11px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.scope-back:hover {
|
|
background: var(--panel-alt);
|
|
color: var(--text);
|
|
border-color: var(--border-strong);
|
|
}
|
|
|
|
.scope-current {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-weight: 500;
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
min-width: 0;
|
|
}
|
|
|
|
.stage {
|
|
grid-area: stage;
|
|
background: var(--bg);
|
|
overflow: hidden;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
}
|
|
|
|
.properties {
|
|
grid-area: props;
|
|
background: var(--panel);
|
|
border-left: 1px solid var(--border);
|
|
overflow: auto;
|
|
}
|
|
|
|
.timeline {
|
|
grid-area: time;
|
|
background: var(--panel);
|
|
border-top: 1px solid var(--border);
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
.tl-resize {
|
|
position: absolute;
|
|
top: -7px;
|
|
left: 0;
|
|
right: 0;
|
|
height: 14px;
|
|
cursor: ns-resize;
|
|
z-index: 20;
|
|
touch-action: none;
|
|
background: transparent;
|
|
transition: background 120ms var(--ease);
|
|
}
|
|
|
|
.tl-resize::after {
|
|
content: '';
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
top: 7px;
|
|
height: 1px;
|
|
background: transparent;
|
|
transition: background 120ms var(--ease);
|
|
}
|
|
|
|
.tl-resize:hover::after,
|
|
.tl-resize.resizing::after {
|
|
background: var(--accent);
|
|
}
|
|
|
|
.tl-resize.resizing {
|
|
background: rgba(110, 168, 255, 0.08);
|
|
}
|
|
|
|
.project-name-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
max-width: 260px;
|
|
padding: 4px 8px;
|
|
font: inherit;
|
|
font-size: 12px;
|
|
color: var(--text);
|
|
background: transparent;
|
|
border: 1px solid transparent;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.project-name-btn.is-untitled {
|
|
color: var(--text-dim);
|
|
font-style: italic;
|
|
}
|
|
|
|
.project-name-btn:hover {
|
|
background: var(--panel-alt);
|
|
border-color: var(--border);
|
|
}
|
|
|
|
.project-name-label {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
min-width: 0;
|
|
}
|
|
|
|
.project-name-pencil {
|
|
color: var(--text-dim);
|
|
opacity: 0;
|
|
transition: opacity 140ms var(--ease);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.project-name-btn:hover .project-name-pencil,
|
|
.project-name-btn:focus-visible .project-name-pencil {
|
|
opacity: 1;
|
|
}
|
|
</style>
|