0.8.3: custom tooltips, editor-hide, pack script, settings reshuffle

Tooltips
- New v-tooltip directive + TooltipHost floating panel replace the
  native title attribute across 95+ sites (topbar, timeline, sidebar,
  properties, stage overlays, fullscreen preview, export dialog,
  clipart drawer, modals). Styled dark card with a configurable
  label + optional help line for inline documentation; screen
  readers still get an aria-label when the panel is disabled.
- Settings → Interface toggle (`ruwio:tooltipsEnabled`) hides the
  visual panel for users who know the app. IconButton now routes
  its title prop through v-tooltip so every existing call site is
  tooltip-aware without churn.

Editor-hide + export filter
- New `Layer.editorHidden` flag, eye toggle per layer row, skipped
  by the Konva stage and the mask-preview canvas path but honoured
  by exports and fullscreen preview (runtime untouched).
- Export dialog gets a "Skip hidden tracks" switch that strips
  editor-hidden layers from every format (scene JS, runtime,
  example HTML, MP4, WebM) on opt-in. Disabled when the scene has
  no hidden layers.

Button editor
- `+` buttons appear on empty state slots in button-timeline rows
  and duplicate the nearest keyframe (prev by preference, next
  otherwise) onto that slot. New `duplicateKeyframeAt` store
  action carries props, easing, hold, and bezier handles across.

Stage / interaction
- Plain left-click on the empty content view pans the viewport,
  but only when the pointer isn't over a Konva shape — transformer
  handles, vertex dots, and selection boxes that extend past the
  canvas edge keep their drag.

Settings reshuffle
- Timeline tab merged into Document (Duration + FPS sit under a
  new "Timing" sub-heading). New Interface tab gathers three
  global prefs: Show tooltips, Show locked tracks, Asset
  thumbnails — driven by shared composables so the existing
  toolbar toggles stay in sync. Interface-tab CSS defensively
  resets the scoped label flex-column + uppercase rule that was
  making the toggle row look like an accident.

Project identity + UX polish
- Favicon wired up (moved to public/favicon.ico, linked from
  index.html); scripts/pack.sh packs the minimum Vue project
  (src, index.html, configs, public) into a tarball with the
  right version in the filename, and `npm run pack` drives it.
- About dialog now credits Torsten Hoeltge.

Test infra
- Progress bar turns red mid-run on the first failure: runAll
  gets an onResult callback that TestOverlay uses to push each
  result into the reactive array incrementally.
- mountComponent in the test harness registers v-tooltip on the
  isolated app instance so IconButton's aria-label contract
  actually fires under test.

Regression tests
- duplicateKeyframeAt: copy fidelity (props, easing, hold),
  collision refusal, unknown-id guards, separated-track targeting.
- Shared composables: useTooltipPref and useAssetThumbsPref both
  return the same ref across calls and persist to localStorage.
- Tooltip contract: IconButton ships `aria-label` (not title);
  live-DOM smoke tests match on aria-label selectors now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-21 23:57:35 +02:00
1 parent a2c8e5998a
commit 1d84c34c15
33 files changed
+1185 -128

No files matched your search

+10 -4
View File
@@ -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))
<button
class="tbtn"
:disabled="!results.length"
:title="clipboardStatus === 'copied' ? t('tests.copiedTooltip') : t('tests.copyTooltip')"
v-tooltip="clipboardStatus === 'copied' ? t('tests.copiedTooltip') : t('tests.copyTooltip')"
:class="{ copied: clipboardStatus === 'copied', failed: clipboardStatus === 'failed' }"
@click="copyReport"
>
<PhClipboardText :size="14" />
</button>
<button class="tbtn" :title="t('tests.runAgain')" @click="run">
<button class="tbtn" v-tooltip="t('tests.runAgain')" @click="run">
<PhArrowClockwise :size="14" />
</button>
<button class="tbtn" :title="t('tests.close')" @click="close">
<button class="tbtn" v-tooltip="t('tests.close')" @click="close">
<PhX :size="14" weight="bold" />
</button>
</div>
+12 -4
View File
@@ -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))
+118 -4
View File
@@ -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