diff --git a/.gitignore b/.gitignore
index acd4d89..0593bc3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
node_modules
dist
dist-ssr
+dist-archive
*.local
.vite
diff --git a/index.html b/index.html
index 686d998..66c89ee 100644
--- a/index.html
+++ b/index.html
@@ -2,6 +2,7 @@
+
RUWIO
diff --git a/package.json b/package.json
index 5d32ba0..b09b768 100644
--- a/package.json
+++ b/package.json
@@ -1,13 +1,14 @@
{
"name": "animatr",
"private": true,
- "version": "0.8.2",
+ "version": "0.8.3",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
- "type-check": "vue-tsc --noEmit"
+ "type-check": "vue-tsc --noEmit",
+ "pack": "bash scripts/pack.sh"
},
"dependencies": {
"@phosphor-icons/vue": "^2.2.1",
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..97d4cbf
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/scripts/pack.sh b/scripts/pack.sh
new file mode 100755
index 0000000..b7cff2c
--- /dev/null
+++ b/scripts/pack.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+# Pack everything needed to install + run the Vue frontend into a
+# single tarball: sources, entry HTML, Vite/TS config, and the lock
+# file. Recipient runs:
+#
+# tar -xzf ruwio-.tar.gz
+# cd ruwio-
+# npm install
+# npm run dev # or: npm run build
+#
+# Deliberately excluded: node_modules, dist, the render server,
+# docker-compose, logo working files, personal notes, screenshots,
+# .claude/, .git/.
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+VERSION=$(node -p "require('./package.json').version")
+NAME="ruwio-${VERSION}"
+OUT_DIR="dist-archive"
+ARCHIVE="${OUT_DIR}/${NAME}.tar.gz"
+
+mkdir -p "$OUT_DIR"
+rm -f "$ARCHIVE"
+
+# Explicit allowlist — everything else stays out. If you add a new
+# top-level file the Vue app depends on (e.g. a separate env file or
+# a PostCSS config), extend this list.
+tar --transform "s,^,${NAME}/," -czf "$ARCHIVE" \
+ index.html \
+ package.json \
+ package-lock.json \
+ tsconfig.json \
+ vite.config.ts \
+ .gitignore \
+ public \
+ src
+
+echo "Created $ARCHIVE"
+ls -lh "$ARCHIVE"
diff --git a/src/App.vue b/src/App.vue
index ca7509b..e594168 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -26,6 +26,7 @@ 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 TooltipHost from './components/TooltipHost.vue'
import WelcomeDialog from './components/WelcomeDialog.vue'
import TestOverlay from './devtools/TestOverlay.vue'
import ruwioLogo from './assets/logo.png'
@@ -418,13 +419,16 @@ onBeforeUnmount(() => {
-
-
+
-
+
-
+
{{ t('app.export') }}
@@ -504,7 +515,7 @@ onBeforeUnmount(() => {
{{ t('app.mainTimeline') }}
-
+
{{ store.activeClip?.name ?? '' }}
@@ -514,7 +525,7 @@ onBeforeUnmount(() => {
{{ t('app.mainTimeline') }}
-
+
{{ store.activeButtonAsset?.name ?? '' }}
@@ -526,7 +537,7 @@ onBeforeUnmount(() => {
{
+
diff --git a/src/components/AboutDialog.vue b/src/components/AboutDialog.vue
index e94452f..7d5ae2e 100644
--- a/src/components/AboutDialog.vue
+++ b/src/components/AboutDialog.vue
@@ -46,6 +46,8 @@ const shortcuts: Shortcut[] = [
+
+ {{ t('about.credit') }}
@@ -128,4 +130,11 @@ kbd {
color: var(--text-dim-strong);
}
+.credit {
+ margin: 6px 0 0;
+ font-size: 11px;
+ text-align: center;
+ color: var(--text-dim);
+}
+
diff --git a/src/components/ActionEditor.vue b/src/components/ActionEditor.vue
index 8c71b4e..4542115 100644
--- a/src/components/ActionEditor.vue
+++ b/src/components/ActionEditor.vue
@@ -106,13 +106,13 @@ function onTypeChange(index: number, newType: Action['type']) {
{{ actionTypeLabel(type) }}
-
+
-
+
-
+
diff --git a/src/components/AssetSidebar.vue b/src/components/AssetSidebar.vue
index 65f74f0..eaa5f36 100644
--- a/src/components/AssetSidebar.vue
+++ b/src/components/AssetSidebar.vue
@@ -1,5 +1,5 @@
+
+
+
+
+
{{ content.label }}
+
{{ content.help }}
+
+
+
+
+
diff --git a/src/components/WelcomeDialog.vue b/src/components/WelcomeDialog.vue
index 4560a5f..8f3fdbf 100644
--- a/src/components/WelcomeDialog.vue
+++ b/src/components/WelcomeDialog.vue
@@ -161,7 +161,7 @@ watch(
diff --git a/src/components/ui/IconButton.vue b/src/components/ui/IconButton.vue
index 43cf51b..f2abba5 100644
--- a/src/components/ui/IconButton.vue
+++ b/src/components/ui/IconButton.vue
@@ -1,5 +1,6 @@
@@ -35,7 +45,7 @@ const iconPx: Record = { xs: 12, sm: 14, md: 15 }
class="icon-btn"
:class="[`size-${size}`, `variant-${variant}`, { active, rounded }]"
:disabled="disabled"
- :title="title"
+ v-tooltip="tooltipBinding"
>
document.removeEventListener('keydown', onKeyDown))
v-if="dismissable"
class="close"
:disabled="!dismissable"
- :title="t('dialog.close')"
+ v-tooltip="t('dialog.close')"
@click="close"
>
diff --git a/src/composables/useAssetThumbsPref.ts b/src/composables/useAssetThumbsPref.ts
new file mode 100644
index 0000000..5d181a6
--- /dev/null
+++ b/src/composables/useAssetThumbsPref.ts
@@ -0,0 +1,28 @@
+import { ref, watch } from 'vue'
+
+const STORAGE_KEY = 'animatr.assetThumbs'
+
+function read(): boolean {
+ try {
+ const v = localStorage.getItem(STORAGE_KEY)
+ return v !== 'false'
+ } catch {
+ return true
+ }
+}
+
+// Module-level shared ref so AssetSidebar and the settings dialog
+// both write to the same preference.
+export const assetThumbsEnabled = ref(read())
+
+watch(assetThumbsEnabled, (v) => {
+ try {
+ localStorage.setItem(STORAGE_KEY, String(v))
+ } catch {
+ /* ignore */
+ }
+})
+
+export function useAssetThumbsPref() {
+ return { enabled: assetThumbsEnabled }
+}
diff --git a/src/composables/useTooltipPref.ts b/src/composables/useTooltipPref.ts
new file mode 100644
index 0000000..7501661
--- /dev/null
+++ b/src/composables/useTooltipPref.ts
@@ -0,0 +1,25 @@
+import { ref, watch } from 'vue'
+
+const STORAGE_KEY = 'ruwio:tooltipsEnabled'
+
+function readInitial(): boolean {
+ try {
+ return localStorage.getItem(STORAGE_KEY) !== '0'
+ } catch {
+ return true
+ }
+}
+
+const enabled = ref(readInitial())
+
+watch(enabled, (v) => {
+ try {
+ localStorage.setItem(STORAGE_KEY, v ? '1' : '0')
+ } catch {
+ /* storage disabled — no persistence, behavior stays in-memory */
+ }
+})
+
+export function useTooltipPref() {
+ return { enabled }
+}
diff --git a/src/devtools/TestOverlay.vue b/src/devtools/TestOverlay.vue
index 373df23..32d6884 100644
--- a/src/devtools/TestOverlay.vue
+++ b/src/devtools/TestOverlay.vue
@@ -141,7 +141,7 @@ async function run() {
startedAt.value = performance.now()
const snapshot = JSON.parse(JSON.stringify(store.$state))
try {
- results.value = await runAll(registerAllTests, {
+ await runAll(registerAllTests, {
onProgress: (d, t) => {
done.value = d
total.value = t
@@ -150,6 +150,12 @@ async function run() {
currentSuite.value = suiteName
currentTest.value = testName
},
+ // Push each result as it arrives so `failedSoFar` updates live
+ // — that's what drives the progress bar turning red the moment
+ // any test fails, instead of only at the end of the run.
+ onResult: (r) => {
+ results.value.push(r)
+ },
interTestDelayMs: 80,
})
} finally {
@@ -230,16 +236,16 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
-
+
-
+
diff --git a/src/devtools/testRunner.ts b/src/devtools/testRunner.ts
index 548081f..a08d567 100644
--- a/src/devtools/testRunner.ts
+++ b/src/devtools/testRunner.ts
@@ -131,6 +131,7 @@ function cleanStack(stack: string | undefined): string | undefined {
export interface RunOptions {
onProgress?: (done: number, total: number) => void
onTestStart?: (suite: string, name: string) => void
+ onResult?: (result: TestResult) => void
interTestDelayMs?: number
}
@@ -149,16 +150,17 @@ export async function runAll(
opts.onTestStart?.(s.name, t.name)
await new Promise((r) => requestAnimationFrame(() => r(null)))
const start = performance.now()
+ let pushed: TestResult
try {
if (s.beforeEach) await s.beforeEach()
await t.fn()
if (s.afterEach) await s.afterEach()
- results.push({
+ pushed = {
suite: s.name,
name: t.name,
ok: true,
durationMs: performance.now() - start,
- })
+ }
} catch (err) {
try {
if (s.afterEach) await s.afterEach()
@@ -166,15 +168,21 @@ export async function runAll(
/* ignore cleanup errors if test already failed */
}
const e = err as Error
- results.push({
+ pushed = {
suite: s.name,
name: t.name,
ok: false,
error: e.message || String(err),
stack: cleanStack(e.stack),
durationMs: performance.now() - start,
- })
+ }
}
+ results.push(pushed)
+ // Per-result callback fires before the progress tick so the UI
+ // can paint a failing-run indicator (e.g. flip the progress bar
+ // to red) the moment a test fails instead of only once runAll
+ // resolves.
+ opts.onResult?.(pushed)
done++
opts.onProgress?.(done, total)
if (delay > 0) await new Promise((r) => setTimeout(r, delay))
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index 87c2ebd..051bfdf 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -36,6 +36,7 @@ import IconButton from '@/components/ui/IconButton.vue'
import Toast from '@/components/ui/Toast.vue'
import PropertiesPanel from '@/components/PropertiesPanel.vue'
import ClipartDrawer from '@/components/ClipartDrawer.vue'
+import { vTooltip } from '@/directives/tooltip'
import { expect, suite, test } from './testRunner'
interface MountedHandle {
@@ -88,6 +89,11 @@ function mountComponent(
return h(component, props, slots)
},
})
+ // Register the same global directives the real app registers in
+ // main.ts — without this, anything using `v-tooltip` (IconButton,
+ // the toolbar buttons, …) no-ops inside the test harness because
+ // each `mountComponent` spins up an isolated app instance.
+ app.directive('tooltip', vTooltip)
app.mount(host)
return {
host,
@@ -319,6 +325,68 @@ export function registerAllTests(): void {
expect(dup.time).toBeGreaterThan(1)
})
+ test('duplicateKeyframeAt copies props + easing + hold to an explicit time', () => {
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'Src', width: 50, height: 50, fill: '#abcdef' })
+ const l = s.addLayer(a.id)!
+ s.upsertKeyframe(l.id, 1, { x: 123, y: 45 })
+ const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!
+ src.easing = 'easeInOut'
+ s.setKeyframeHold(l.id, src.id, true)
+
+ const newId = s.duplicateKeyframeAt(l.id, src.id, 3)
+ expect(newId).toBeTruthy()
+ const dup = l.keyframes.find((k) => k.id === newId)!
+ expect(dup.time).toBe(3)
+ expect(dup.props.x).toBe(123)
+ expect(dup.props.y).toBe(45)
+ expect(dup.easing).toBe('easeInOut')
+ expect(dup.hold).toBe(true)
+ // props must be a clone — mutating the duplicate must not touch the source.
+ dup.props.x = 999
+ expect(src.props.x).toBe(123)
+ })
+
+ test('duplicateKeyframeAt refuses to overwrite an existing keyframe', () => {
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'Col', width: 10, height: 10, fill: '#fff' })
+ const l = s.addLayer(a.id)!
+ s.upsertKeyframe(l.id, 1, { x: 10 })
+ s.upsertKeyframe(l.id, 2, { x: 20 })
+ const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!
+ const countBefore = l.keyframes.length
+ const newId = s.duplicateKeyframeAt(l.id, src.id, 2)
+ expect(newId).toBe(null)
+ expect(l.keyframes.length).toBe(countBefore)
+ })
+
+ test('duplicateKeyframeAt returns null for unknown layer or keyframe ids', () => {
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
+ const l = s.addLayer(a.id)!
+ s.upsertKeyframe(l.id, 1, { x: 10 })
+ const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!
+ expect(s.duplicateKeyframeAt('nope-layer', src.id, 2)).toBe(null)
+ expect(s.duplicateKeyframeAt(l.id, 'nope-kf', 2)).toBe(null)
+ })
+
+ test('duplicateKeyframeAt targets the source keyframe\'s own track (position vs main)', () => {
+ // Separated layers have independent main / position / rotation
+ // arrays. The duplicate must land in the same array the source
+ // came from, not accidentally jump to another.
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'Sep', width: 10, height: 10, fill: '#fff' })
+ const l = s.addLayer(a.id)!
+ s.setLayerSeparated(l.id, true)
+ s.upsertKeyframe(l.id, 1, { x: 50, y: 60 })
+ const posKf = l.positionKeyframes!.find((k) => Math.abs(k.time - 1) < 1e-6)!
+ s.duplicateKeyframeAt(l.id, posKf.id, 2)
+ const dup = l.positionKeyframes!.find((k) => Math.abs(k.time - 2) < 1e-6)
+ expect(dup).toBeTruthy()
+ // Nothing should have been added to the main array.
+ expect(l.keyframes.some((k) => Math.abs(k.time - 2) < 1e-6)).toBe(false)
+ })
+
test('setKeyframeHold toggles hold flag', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Hold', width: 50, height: 50, fill: '#ddaa44' })
@@ -1085,11 +1153,15 @@ export function registerAllTests(): void {
suite('ui — IconButton', () => {
test('renders a button element', async () => {
+ // IconButton now uses the custom v-tooltip directive instead of
+ // the native `title` attribute. The directive still sets an
+ // `aria-label` so screen readers get the tooltip text — that's
+ // the stable contract this test pins.
const handle = mountComponent(IconButton, { title: 'hi' })
await nextTick()
const btn = handle.host.querySelector('button')
expect(btn).toBeTruthy()
- expect(btn!.getAttribute('title')).toBe('hi')
+ expect(btn!.getAttribute('aria-label')).toBe('hi')
handle.unmount()
})
@@ -1772,8 +1844,11 @@ export function registerAllTests(): void {
})
test('topbar has save & recents buttons', () => {
- const saveBtn = document.querySelector('button[title^="Save project"]')
- const recentsBtn = document.querySelector('button[title="Recent projects"]')
+ // Tooltips live in `aria-label` now (custom v-tooltip directive
+ // replaced the native `title` attribute on every interactive
+ // element).
+ const saveBtn = document.querySelector('button[aria-label^="Save project"]')
+ const recentsBtn = document.querySelector('button[aria-label="Recent projects"]')
expect(saveBtn).toBeTruthy()
expect(recentsBtn).toBeTruthy()
})
@@ -1798,7 +1873,7 @@ export function registerAllTests(): void {
})
test('timeline has add-group button', () => {
- const btn = document.querySelector('button[title="Create a new track group"]')
+ const btn = document.querySelector('button[aria-label="Create a new track group"]')
expect(btn).toBeTruthy()
})
})
@@ -2710,6 +2785,45 @@ export function registerAllTests(): void {
})
})
+ suite('interface prefs — shared composables', () => {
+ // Regression: the tooltip + asset-thumbs prefs are shared module-
+ // level refs so the Settings dialog and the sidebar/timeline
+ // toolbars stay in lock-step. The same refs also have to persist
+ // to localStorage. The thumbs pref was originally an inline ref
+ // inside AssetSidebar.vue — the settings tab couldn't change it
+ // without this extraction.
+
+ test('useTooltipPref persists to localStorage and shares the ref', async () => {
+ const { useTooltipPref } = await import('@/composables/useTooltipPref')
+ const a = useTooltipPref()
+ const b = useTooltipPref()
+ expect(a.enabled).toBe(b.enabled) // same ref instance
+ const initial = a.enabled.value
+ a.enabled.value = !initial
+ await nextTick()
+ expect(localStorage.getItem('ruwio:tooltipsEnabled')).toBe(initial ? '0' : '1')
+ // Both consumers see the flip because the ref is shared.
+ expect(b.enabled.value).toBe(!initial)
+ // Restore so other tests don't inherit a toggled state.
+ a.enabled.value = initial
+ await nextTick()
+ })
+
+ test('useAssetThumbsPref persists to localStorage and shares the ref', async () => {
+ const { useAssetThumbsPref } = await import('@/composables/useAssetThumbsPref')
+ const a = useAssetThumbsPref()
+ const b = useAssetThumbsPref()
+ expect(a.enabled).toBe(b.enabled)
+ const initial = a.enabled.value
+ a.enabled.value = !initial
+ await nextTick()
+ expect(localStorage.getItem('animatr.assetThumbs')).toBe(String(!initial))
+ expect(b.enabled.value).toBe(!initial)
+ a.enabled.value = initial
+ await nextTick()
+ })
+ })
+
suite('clipart drawer — grid pre-layout', () => {
// Regression: the drawer's `.drawer-panel` used to be `v-if`-gated on
// clipart.open and given `flex: 1; min-width: 0` — which meant the
diff --git a/src/directives/tooltip.ts b/src/directives/tooltip.ts
new file mode 100644
index 0000000..aa43e74
--- /dev/null
+++ b/src/directives/tooltip.ts
@@ -0,0 +1,173 @@
+import { ref, type Directive, type DirectiveBinding } from 'vue'
+import { useTooltipPref } from '@/composables/useTooltipPref'
+
+/**
+ * Custom tooltip system. `v-tooltip` replaces the native `title` attribute
+ * with a styled floating panel that's positioned over its target. Accepts
+ * either a plain string (label only) or an object with both a `label` and
+ * a longer `help` line for inline documentation.
+ *
+ * …
+ * …
+ *
+ * TooltipHost.vue renders the currently-active tooltip from `activeTooltip`.
+ * When the user disables tooltips in Settings → Interface, the directive
+ * silently no-ops (show is never called) — but `aria-label` stays set so
+ * screen readers still announce the label.
+ */
+
+export interface TooltipContent {
+ label: string
+ help?: string
+}
+
+export interface ActiveTooltip {
+ target: HTMLElement
+ content: TooltipContent
+}
+
+export const activeTooltip = ref(null)
+
+const SHOW_DELAY_MS = 350
+let showTimer: ReturnType | null = null
+let pendingTarget: HTMLElement | null = null
+
+function cancelShow() {
+ if (showTimer) {
+ clearTimeout(showTimer)
+ showTimer = null
+ }
+ pendingTarget = null
+}
+
+function scheduleShow(target: HTMLElement, content: TooltipContent) {
+ cancelShow()
+ pendingTarget = target
+ showTimer = setTimeout(() => {
+ showTimer = null
+ pendingTarget = null
+ activeTooltip.value = { target, content }
+ }, SHOW_DELAY_MS)
+}
+
+function hideIfFor(target: HTMLElement) {
+ if (pendingTarget === target) cancelShow()
+ if (activeTooltip.value?.target === target) activeTooltip.value = null
+}
+
+function hideAll() {
+ cancelShow()
+ activeTooltip.value = null
+}
+
+function normalize(value: unknown): TooltipContent | null {
+ if (value == null) return null
+ if (typeof value === 'string') {
+ const trimmed = value.trim()
+ return trimmed ? { label: trimmed } : null
+ }
+ if (typeof value === 'object') {
+ const label = (value as TooltipContent).label
+ if (typeof label !== 'string' || !label.trim()) return null
+ const help = (value as TooltipContent).help
+ return {
+ label: label.trim(),
+ help: typeof help === 'string' && help.trim() ? help.trim() : undefined,
+ }
+ }
+ return null
+}
+
+interface AttachedState {
+ content: TooltipContent | null
+ onEnter: (e: Event) => void
+ onLeave: (e: Event) => void
+ onFocus: (e: Event) => void
+ onBlur: (e: Event) => void
+ onPointerDown: (e: Event) => void
+}
+
+const attached = new WeakMap()
+const { enabled } = useTooltipPref()
+
+function attach(el: HTMLElement, content: TooltipContent | null) {
+ const state: AttachedState = {
+ content,
+ onEnter: () => {
+ if (!enabled.value) return
+ if (!state.content) return
+ scheduleShow(el, state.content)
+ },
+ onLeave: () => hideIfFor(el),
+ onFocus: () => {
+ if (!enabled.value) return
+ if (!state.content) return
+ // Keyboard focus should show the tooltip immediately — the user
+ // already indicated intent by tabbing to this control.
+ activeTooltip.value = { target: el, content: state.content }
+ },
+ onBlur: () => hideIfFor(el),
+ // A pointerdown on the element (click, drag-start, etc.) is a
+ // clear "I'm using this, stop hovering" signal — dismiss.
+ onPointerDown: () => hideIfFor(el),
+ }
+ el.addEventListener('mouseenter', state.onEnter)
+ el.addEventListener('mouseleave', state.onLeave)
+ el.addEventListener('focusin', state.onFocus)
+ el.addEventListener('focusout', state.onBlur)
+ el.addEventListener('pointerdown', state.onPointerDown)
+ attached.set(el, state)
+ syncAria(el, content)
+}
+
+function syncAria(el: HTMLElement, content: TooltipContent | null) {
+ if (content) el.setAttribute('aria-label', content.label)
+ else el.removeAttribute('aria-label')
+}
+
+function update(el: HTMLElement, content: TooltipContent | null) {
+ const state = attached.get(el)
+ if (!state) return attach(el, content)
+ state.content = content
+ syncAria(el, content)
+ // If this element is currently showing, update live.
+ if (activeTooltip.value?.target === el) {
+ if (content) activeTooltip.value = { target: el, content }
+ else activeTooltip.value = null
+ }
+}
+
+function detach(el: HTMLElement) {
+ const state = attached.get(el)
+ if (!state) return
+ el.removeEventListener('mouseenter', state.onEnter)
+ el.removeEventListener('mouseleave', state.onLeave)
+ el.removeEventListener('focusin', state.onFocus)
+ el.removeEventListener('focusout', state.onBlur)
+ el.removeEventListener('pointerdown', state.onPointerDown)
+ attached.delete(el)
+ hideIfFor(el)
+}
+
+export const vTooltip: Directive = {
+ mounted(el, binding: DirectiveBinding) {
+ attach(el, normalize(binding.value))
+ },
+ updated(el, binding: DirectiveBinding) {
+ update(el, normalize(binding.value))
+ },
+ beforeUnmount(el) {
+ detach(el)
+ },
+}
+
+export function hideActiveTooltip() {
+ hideAll()
+}
+
+// Dismiss the active tooltip whenever the window loses focus or the page
+// scrolls — both can leave a stale floating panel behind.
+if (typeof window !== 'undefined') {
+ window.addEventListener('blur', hideAll)
+ window.addEventListener('scroll', hideAll, true)
+}
diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts
index 0752a8b..246e5fd 100644
--- a/src/i18n/messages.ts
+++ b/src/i18n/messages.ts
@@ -22,12 +22,18 @@ export const en = {
defaultName: 'Project {date}',
},
renameProject: 'Rename project',
+ renameProjectHelp:
+ 'Click to rename. Saved projects update in place; new names apply at the next save.',
renameProjectDialog: {
title: 'Rename project',
message: 'Choose a new name for this project.',
confirm: 'Rename',
},
untitledProject: 'Untitled project',
+ saveProjectDirtyHelp:
+ 'Your project has unsaved changes. Ctrl/Cmd+S writes them to browser storage.',
+ exportHelp:
+ 'Save as runtime script, HTML, or video. MP4 needs the render server; WebM records in real time in the browser.',
replaceDialog: {
title: 'Replace project?',
message:
@@ -235,6 +241,7 @@ export const en = {
renameMarkerRetry: 'Try again',
stillFrameTooltip: 'Still frame {time} — holds until the next keyframe',
keyframeTooltip: 'Keyframe {time} — drag to move, right-click for options',
+ addKfFromPrev: 'Fill with nearest keyframe — copies the previous state\'s pose into this slot',
trimTooltip: '{name} · {duration}s{trims} · Drag edges to trim, double-click to open',
trimStart: 'Start -{value}s',
trimEnd: 'End -{value}s',
@@ -250,6 +257,8 @@ export const en = {
color: 'Color',
lock: 'Lock',
unlock: 'Unlock',
+ hideInEditor: 'Hide in editor (still exported)',
+ showInEditor: 'Show in editor',
deleteLayer: 'Delete layer',
deleteClipTrack: 'Delete clip track',
deleteScriptTrack: 'Delete script track',
@@ -290,10 +299,20 @@ export const en = {
title: 'Settings',
tab: {
document: 'Document',
- timeline: 'Timeline',
clipart: 'Clipart',
+ interface: 'Interface',
theme: 'UI Theme',
},
+ timing: 'Timing',
+ tooltipsLabel: 'Show tooltips',
+ tooltipsDesc:
+ 'Floating help text when you hover over a button or control. Turn off if you know the app well and find them distracting — screen readers still get the label.',
+ showLockedLabel: 'Show locked tracks',
+ showLockedDesc:
+ 'When off, locked tracks disappear from the timeline and the stage. Use to focus on the layers you\'re still editing. Also togglable from the timeline toolbar.',
+ assetThumbsLabel: 'Asset thumbnails',
+ assetThumbsDesc:
+ 'Render small previews in the Assets / Clips / Buttons panels. Turn off for a compact list when working with lots of items.',
bgVideoLabel: 'Video',
bgVideoNone: 'No background video',
bgVideoPickFile: 'Pick video file…',
@@ -346,6 +365,9 @@ export const en = {
back: 'Back',
labelWebm: 'Rendering WebM (browser, realtime)…',
labelMp4: 'Rendering MP4 (server)…',
+ skipHidden: 'Skip hidden tracks',
+ skipHiddenDesc: 'Drop layers toggled off in the editor from every format below.',
+ skipHiddenNone: 'No editor-hidden tracks in the scene.',
},
tests: {
@@ -389,12 +411,13 @@ export const en = {
},
about: {
- openTooltip: 'About Animatr',
+ openTooltip: 'About RUWIO',
title: 'About {name}',
tagline: 'A timeline-based animation editor that runs in your browser.',
versionLabel: 'Version',
buildLabel: 'Build',
close: 'Close',
+ credit: 'Made with love by Torsten Hoeltge',
shortcutsHeading: 'Shortcuts',
shortcut: {
undo: 'Ctrl+Z',
diff --git a/src/lib/render.ts b/src/lib/render.ts
index ba00645..e001f76 100644
--- a/src/lib/render.ts
+++ b/src/lib/render.ts
@@ -136,6 +136,12 @@ function drawLayers(
for (let i = layers.length - 1; i >= 0; i--) {
const layer = layers[i]
if (!layer.visible) continue
+ // Editor-only hide: respected by the mask-preview overlay and
+ // sidebar thumbnails so toggling a layer off really removes it
+ // from the in-editor render. The runtime in exportPlayer.ts does
+ // NOT read this flag, so fullscreen preview and exports keep
+ // rendering the layer.
+ if (layer.editorHidden) continue
if (!alwaysActive && !isLayerActiveAt(layer, time)) continue
const asset = assetMap.get(layer.assetId)
if (!asset) continue
diff --git a/src/main.ts b/src/main.ts
index a73c994..2c57109 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -4,8 +4,10 @@ import VueKonva from 'vue-konva'
import App from './App.vue'
import './composables/useTheme'
import './styles/global.css'
+import { vTooltip } from './directives/tooltip'
const app = createApp(App)
app.use(createPinia())
app.use(VueKonva)
+app.directive('tooltip', vTooltip)
app.mount('#app')
diff --git a/src/stores/scene.ts b/src/stores/scene.ts
index 4793f9c..07fac85 100644
--- a/src/stores/scene.ts
+++ b/src/stores/scene.ts
@@ -641,6 +641,37 @@ export const useSceneStore = defineStore('scene', {
shift(layer.keyframes)
shift(layer.positionKeyframes)
},
+ /** Copy the referenced keyframe to a specific time. Carries easing,
+ * hold, and control-point handles along; refuses if the target
+ * time is already occupied in the same array. Used by the
+ * button-editor's + buttons to fill empty state slots with the
+ * previous (or next) keyframe's values. */
+ duplicateKeyframeAt(
+ layerId: string,
+ keyframeId: string,
+ targetTime: number,
+ ): string | null {
+ const layer = this.activeLayers.find((l) => l.id === layerId)
+ if (!layer) return null
+ const found = findKf(layer, keyframeId)
+ if (!found) return null
+ const { kf: source, arr } = found
+ if (arr.some((k) => Math.abs(k.time - targetTime) < KEYFRAME_EPSILON)) return null
+ const newKf: Keyframe = {
+ id: uid(),
+ time: targetTime,
+ props: { ...source.props },
+ easing: source.easing,
+ }
+ if (source.hold) newKf.hold = true
+ if (source.ctrlX !== undefined && source.ctrlY !== undefined) {
+ newKf.ctrlX = source.ctrlX
+ newKf.ctrlY = source.ctrlY
+ }
+ arr.push(newKf)
+ sortKfs(arr)
+ return newKf.id
+ },
duplicateKeyframe(layerId: string, keyframeId: string): string | null {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return null
@@ -1225,6 +1256,12 @@ export const useSceneStore = defineStore('scene', {
else delete group.locked
}
},
+ setLayerEditorHidden(layerId: string, hidden: boolean) {
+ const layer = this.activeLayers.find((l) => l.id === layerId)
+ if (!layer) return
+ if (hidden) layer.editorHidden = true
+ else delete layer.editorHidden
+ },
// -------- Markers --------------------------------------------------
diff --git a/src/types/scene.ts b/src/types/scene.ts
index 032c5ec..b659079 100644
--- a/src/types/scene.ts
+++ b/src/types/scene.ts
@@ -166,6 +166,15 @@ export interface Layer {
*/
colorOverridden?: boolean
locked?: boolean
+ /**
+ * Editor-only hide. Skipped by the Konva stage and the in-editor
+ * mask-preview / thumbnail canvas paths, but NOT by the export
+ * runtime — a hidden layer still shows up in fullscreen preview,
+ * exports, and the rendered server output. Lets the user mute a
+ * track temporarily without touching `visible`, which IS honoured
+ * by every render path.
+ */
+ editorHidden?: boolean
isMask?: boolean
maskMode?: MaskMode
/** Only meaningful when `assetId` points to a ButtonAsset: fires on click. */
diff --git a/src/version.ts b/src/version.ts
index 004d6d9..11c9249 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,2 +1,2 @@
-export const APP_VERSION = '0.8.2'
+export const APP_VERSION = '0.8.3'
export const APP_NAME = 'RUWIO'
diff --git a/todo b/todo
index 6ba40ea..53bb42d 100644
--- a/todo
+++ b/todo
@@ -136,14 +136,26 @@ die inline hilfe texte in den settings sind zu klein. das kann man nicht lesen
warum ist eigentlich still frame easing disabled? man muss doch zu einem still-frame hin easen können. verstehe den grund nicht
-
-
-
-
-
-
ich würde an dieser stelle mal ein paar regression-tests hinzufügen und dann committen
wenn man den leeren teil des content-views zieht (außerhalb der canvas constraints) muss canvas move auch ohne control key gehen
-export dateien heißen noch animatr. wir haben in RUWIO umbenannt
\ No newline at end of file
+export dateien heißen noch animatr. wir haben in RUWIO umbenannt
+
+die tooltips sind hässlich. bau ein schöneres custom tooltip system ein. nimm dir zeit und such alle tooltips raus die wir haben. wenn es sinnvoll ist eine funktion besser zu erklären kann ein tooltip auch eine inline hilfe sein. das muss man unter settings -> interface abstellen können
+
+
+
+----
+
+
+buttons müssen einen pointer cursor anzeigen
+
+die track-lanes der nicht aktiven traks zeigen durchsichtige keyframes. das muss genauso aussehen wie bei den aktiven - nur etwas ausgefaded
+
+
+bei den button timelines zeigen wir + icons an auf leeren frames. da wird dann der vorrige hin dupliziert
+
+element auf gruppe ziehen muss auch in der track label spalte gehen
+
+der bereich wo man den timeline-bereich größer ziehen kann ist nur noch 1px. da hatten wir 15px vorher. man trifft diesen anfasser jetzt nicht mehr
\ No newline at end of file