- Rename the app to RUWIO: title, APP_NAME, topbar logo (40px PNG), welcome dialog header and about dialog switch from the PhSparkle glyph to the real logo. Redundant name text next to the logo is removed across the board. - About dialog: logo spans the full dialog width, the bottom Close button is gone (close via header X stays). - Vertex bezier for polygons: each vertex can be Corner / Curve / Split curve via right-click. Curve mirrors tangent handles symmetrically, split curve keeps them independent. Tangents are fully animatable per keyframe — lerp mirrored across all four render pipelines. - Stage path overlay: selecting a keyframe draws the tween arc between the previous and selected keyframe with draggable per-frame dots that curve the path via quadratic bezier control points. Other segments on the same track render as dashed ghost lines so the full curve is visible. Path dots grow + show grab cursor on hover. - Vertex box-select: click-drag on empty stage while a polygon is selected boxes vertices. Shift-click toggles individual vertices into/out of the selection. Multi-vertex drag moves all selected at once; right-click on a selected vertex applies Corner/Curve/Split to all. Tangent handles only render when exactly one vertex is selected. - Welcome dialog focus: opens with the most recent project focused. Enter opens it; Tab cycles only through the dialog's real buttons (delete icons pulled out of tab order, scrollable body removed from tab order). Export-current removed from welcome (not useful on startup). - Modal focus trap: all modals now keep Tab inside the dialog. - Fill / stroke transparency toggle restores the last real color when flipped back on, with a layer-base / asset fallback chain so there's always a visible color to restore to. A watcher keeps the restore cache in sync with the sampled value. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
677 lines
17 KiB
Vue
677 lines
17 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
import {
|
|
PhArrowClockwise,
|
|
PhArrowCounterClockwise,
|
|
PhArrowLeft,
|
|
PhExport,
|
|
PhFilmStrip,
|
|
PhFolderOpen,
|
|
PhFloppyDisk,
|
|
PhGearSix,
|
|
PhPause,
|
|
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 { 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 history = useHistory()
|
|
|
|
function rewind() {
|
|
store.setPlayhead(0)
|
|
}
|
|
|
|
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 }))
|
|
}
|
|
|
|
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 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 (e.code !== 'Space') return
|
|
if (isEditable(e.target)) return
|
|
e.preventDefault()
|
|
if (e.shiftKey) {
|
|
store.pause()
|
|
fullscreenOpen.value = true
|
|
return
|
|
}
|
|
store.togglePlay()
|
|
}
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('keydown', onKeyDown)
|
|
document.addEventListener('contextmenu', onDocumentContextMenu)
|
|
window.addEventListener('resize', onWindowResize)
|
|
bootWelcome()
|
|
})
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('keydown', onKeyDown)
|
|
document.removeEventListener('contextmenu', onDocumentContextMenu)
|
|
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>
|
|
</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">
|
|
<span v-if="currentProjectName" class="project-name" :title="currentProjectName">
|
|
{{ currentProjectName }}
|
|
</span>
|
|
<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>
|
|
</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="() => { currentProjectId = null; currentProjectName = null }"
|
|
@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 {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
max-width: 220px;
|
|
padding: 4px 10px;
|
|
font-size: 11px;
|
|
color: var(--text-dim-strong);
|
|
background: var(--panel-alt);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
margin-right: 4px;
|
|
}
|
|
</style>
|