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:
1 parent
a2c8e5998a
commit
1d84c34c15
33 files changed
+1185
-128
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user