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

+21 -29
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch, watchEffect } from 'vue'
import { computed, ref, watchEffect } from 'vue'
import {
PhCircle,
PhCursorClick,
@@ -17,6 +17,7 @@ import {
PhUploadSimple,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { importImageFile } from '@/lib/fileLoader'
import type { Asset } from '@/types/scene'
import { confirmDialog, promptDialog } from '@/components/ui'
@@ -28,16 +29,7 @@ const store = useSceneStore()
const fileInput = ref<HTMLInputElement | null>(null)
const uploadError = ref<string | null>(null)
const STORAGE_KEY = 'animatr.assetThumbs'
const storedThumbs = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
const showThumbs = ref(storedThumbs !== 'false')
watch(showThumbs, (v) => {
try {
localStorage.setItem(STORAGE_KEY, String(v))
} catch {
/* ignore */
}
})
const { enabled: showThumbs } = useAssetThumbsPref()
function addSampleRect() {
const asset = store.addAsset({
@@ -311,7 +303,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<div
class="panel"
:class="{ 'hit-disabled': store.activeButtonEditMode === 'hit' }"
:title="store.activeButtonEditMode === 'hit' ? t('sidebar.hitModeDisabled') : undefined"
v-tooltip="store.activeButtonEditMode === 'hit' ? t('sidebar.hitModeDisabled') : undefined"
>
<h3>Assets</h3>
@@ -321,27 +313,27 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
</button>
<div class="actions">
<button class="shape-btn" :title="t('sidebar.shape.rect')" @click="addSampleRect">
<button class="shape-btn" v-tooltip="t('sidebar.shape.rect')" @click="addSampleRect">
<PhSquare :size="16" />
</button>
<button class="shape-btn" :title="t('sidebar.shape.circle')" @click="addSampleCircle">
<button class="shape-btn" v-tooltip="t('sidebar.shape.circle')" @click="addSampleCircle">
<PhCircle :size="16" />
</button>
<button class="shape-btn" :title="t('sidebar.shape.text')" @click="addSampleText">
<button class="shape-btn" v-tooltip="t('sidebar.shape.text')" @click="addSampleText">
<PhTextT :size="16" />
</button>
<button class="shape-btn" :title="t('sidebar.shape.polygon')" @click="addSamplePolygon">
<button class="shape-btn" v-tooltip="t('sidebar.shape.polygon')" @click="addSamplePolygon">
<PhPolygon :size="16" />
</button>
<button
class="shape-btn"
:class="{ active: store.drawingMode === 'freeform' }"
:title="store.drawingMode === 'freeform' ? t('sidebar.shape.freeformActive') : t('sidebar.shape.freeform')"
v-tooltip="store.drawingMode === 'freeform' ? t('sidebar.shape.freeformActive') : t('sidebar.shape.freeform')"
@click="startFreeformDrawing"
>
<PhPencilSimpleLine :size="16" />
</button>
<button class="shape-btn" :title="t('sidebar.shape.button')" @click="addButton">
<button class="shape-btn" v-tooltip="t('sidebar.shape.button')" @click="addButton">
<PhCursorClick :size="16" />
</button>
</div>
@@ -360,7 +352,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<h4>{{ t('sidebar.library') }}</h4>
<button
class="thumb-toggle"
:title="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
v-tooltip="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
@click="showThumbs = !showThumbs"
>
<PhImageSquare v-if="showThumbs" :size="13" />
@@ -383,12 +375,12 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<component :is="assetIconMap[asset.kind]" :size="14" class="asset-icon" />
</template>
<div class="meta">
<span class="name" :title="asset.name">{{ asset.name }}</span>
<span class="name" v-tooltip="asset.name">{{ asset.name }}</span>
<span v-if="showThumbs" class="sub">{{ t('sidebar.imageLabel') }}</span>
</div>
<button
class="add-layer"
:title="t('sidebar.addLayerTooltip')"
v-tooltip="t('sidebar.addLayerTooltip')"
@click="store.addLayer(asset.id)"
>
<PhPlus :size="12" weight="bold" />
@@ -399,7 +391,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<div class="lib-header clips-header">
<h4>{{ t('sidebar.clips') }}</h4>
<button class="thumb-toggle" :title="t('sidebar.createClipTooltip')" @click="addClip">
<button class="thumb-toggle" v-tooltip="t('sidebar.createClipTooltip')" @click="addClip">
<PhPlus :size="12" weight="bold" />
</button>
</div>
@@ -417,7 +409,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<PhFilmStrip :size="16" />
</div>
<div class="clip-meta">
<span class="clip-name" :title="clip.name">{{ clip.name }}</span>
<span class="clip-name" v-tooltip="clip.name">{{ clip.name }}</span>
<span class="clip-sub">
{{ t('sidebar.clipLayerCount', { n: clip.layers.length }) }} · {{ clip.duration.toFixed(1) }}s
</span>
@@ -425,12 +417,12 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<div class="clip-actions">
<button
class="clip-btn"
:title="t('sidebar.renameTooltip')"
v-tooltip="t('sidebar.renameTooltip')"
@click.stop="renameClipPrompt(clip.id, clip.name)"
>
<PhPencilSimple :size="11" />
</button>
<button class="clip-btn danger" :title="t('sidebar.deleteTooltip')" @click.stop="deleteClip(clip.id)">
<button class="clip-btn danger" v-tooltip="t('sidebar.deleteTooltip')" @click.stop="deleteClip(clip.id)">
<PhTrash :size="11" />
</button>
</div>
@@ -440,7 +432,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<div class="lib-header clips-header">
<h4>{{ t('sidebar.buttons') }}</h4>
<button class="thumb-toggle" :title="t('sidebar.createButtonTooltip')" @click="addButton">
<button class="thumb-toggle" v-tooltip="t('sidebar.createButtonTooltip')" @click="addButton">
<PhPlus :size="12" weight="bold" />
</button>
</div>
@@ -468,7 +460,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<PhCursorClick :size="14" class="asset-icon" />
</template>
<div class="clip-meta">
<span class="clip-name" :title="ba.name">{{ ba.name }}</span>
<span class="clip-name" v-tooltip="ba.name">{{ ba.name }}</span>
<span v-if="showThumbs" class="clip-sub">
{{ ba.canvas.width }} × {{ ba.canvas.height }}
</span>
@@ -476,14 +468,14 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<div class="clip-actions">
<button
class="clip-btn"
:title="t('sidebar.renameTooltip')"
v-tooltip="t('sidebar.renameTooltip')"
@click.stop="renameButtonAssetPrompt(ba.id, ba.name)"
>
<PhPencilSimple :size="11" />
</button>
<button
class="clip-btn danger"
:title="t('sidebar.deleteTooltip')"
v-tooltip="t('sidebar.deleteTooltip')"
@click.stop="deleteButtonAsset(ba.id)"
>
<PhTrash :size="11" />