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

+146 -21
View File
@@ -46,6 +46,7 @@ import type {
Layer,
TrackGroup,
} from '@/types/scene'
import { BUTTON_STATE_TIMES } from '@/types/scene'
interface TimelineLayerTrack {
type: 'layer'
@@ -1127,6 +1128,41 @@ function segmentStyle(seg: KfSegment) {
}
}
// Button-editor only: list the 4 state-slot times that this track has
// NO keyframe on. The template renders a "+" placeholder over each
// empty slot; clicking it duplicates the nearest keyframe (prev by
// preference, next if there's nothing earlier) onto that slot.
const BUTTON_SLOT_TIMES = [
BUTTON_STATE_TIMES.normal,
BUTTON_STATE_TIMES.hover,
BUTTON_STATE_TIMES.pressed,
BUTTON_STATE_TIMES.disabled,
] as const
function emptyButtonSlots(kfs: Keyframe[]): number[] {
if (!store.activeButtonAssetId) return []
if (kfs.length === 0) return []
const EPS = 1e-3
const has = (t: number) => kfs.some((k) => Math.abs(k.time - t) < EPS)
return BUTTON_SLOT_TIMES.filter((t) => !has(t))
}
function addKfFromPrev(layerId: string, kfs: Keyframe[], time: number) {
if (!kfs.length) return
let prev: Keyframe | null = null
let next: Keyframe | null = null
for (const k of kfs) {
if (k.time < time) {
if (!prev || k.time > prev.time) prev = k
} else if (k.time > time) {
if (!next || k.time < next.time) next = k
}
}
const source = prev ?? next
if (!source) return
store.duplicateKeyframeAt(layerId, source.id, time)
}
const EASING_OPTIONS: { value: Easing; label: string }[] = [
{ value: 'linear', label: t('timeline.easingLinear') },
{ value: 'easeIn', label: t('timeline.easingEaseIn') },
@@ -2145,13 +2181,13 @@ onBeforeUnmount(() => {
<button
class="mode-btn"
:class="{ active: store.activeButtonEditMode === 'states' }"
:title="t('timeline.buttonModeStates')"
v-tooltip="t('timeline.buttonModeStates')"
@click="store.setButtonEditMode('states')"
>{{ t('timeline.buttonModeStatesShort') }}</button>
<button
class="mode-btn hit"
:class="{ active: store.activeButtonEditMode === 'hit' }"
:title="t('timeline.buttonModeHit')"
v-tooltip="t('timeline.buttonModeHit')"
@click="store.setButtonEditMode('hit')"
>{{ t('timeline.buttonModeHitShort') }}</button>
</div>
@@ -2192,7 +2228,7 @@ onBeforeUnmount(() => {
</div>
<div v-else class="scrub-bar">
<div class="name-spacer">
<div class="mode-toggle" :title="t('timeline.toggleTimeUnit')">
<div class="mode-toggle" v-tooltip="t('timeline.toggleTimeUnit')">
<button
class="mode-btn"
:class="{ active: displayMode === 'seconds' }"
@@ -2207,7 +2243,7 @@ onBeforeUnmount(() => {
<button
class="snap-btn"
:class="{ active: snapEnabled }"
:title="snapEnabled ? t('timeline.snapOn') : t('timeline.snapOff')"
v-tooltip="snapEnabled ? t('timeline.snapOn') : t('timeline.snapOff')"
@click="snapEnabled = !snapEnabled"
>
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
@@ -2215,7 +2251,7 @@ onBeforeUnmount(() => {
<button
class="snap-btn"
:class="{ active: !showLocked }"
:title="showLocked ? t('timeline.hideLocked') : t('timeline.showLocked')"
v-tooltip="showLocked ? t('timeline.hideLocked') : t('timeline.showLocked')"
@click="showLocked = !showLocked"
>
<component
@@ -2226,7 +2262,7 @@ onBeforeUnmount(() => {
<button
v-if="!inClipEdit"
class="snap-btn"
:title="t('timeline.addGroupTooltip')"
v-tooltip="t('timeline.addGroupTooltip')"
@click="addGroup"
>
<PhFolderPlus :size="12" />
@@ -2234,14 +2270,14 @@ onBeforeUnmount(() => {
<button
v-if="!inClipEdit"
class="snap-btn"
:title="t('timeline.addScriptTrackTooltip')"
v-tooltip="t('timeline.addScriptTrackTooltip')"
@click="store.addScriptTrack()"
>
<PhCode :size="12" />
</button>
<button
class="snap-btn add-kf-btn"
:title="t('timeline.addKeyframeTooltip')"
v-tooltip="t('timeline.addKeyframeTooltip')"
:disabled="!canCreateKf"
@click="addKeyframeForSelected"
>
@@ -2281,7 +2317,7 @@ onBeforeUnmount(() => {
></span>
<span
class="range-bracket bracket-in"
:title="t('timeline.inPoint')"
v-tooltip="t('timeline.inPoint')"
:style="{ left: pxFromTime(store.playbackIn) + 'px' }"
@pointerdown="onInBracketPointerDown"
@pointermove="onInBracketPointerMove"
@@ -2290,7 +2326,7 @@ onBeforeUnmount(() => {
></span>
<span
class="range-bracket bracket-out"
:title="t('timeline.outPoint')"
v-tooltip="t('timeline.outPoint')"
:style="{ left: pxFromTime(store.playbackOut) + 'px' }"
@pointerdown="onOutBracketPointerDown"
@pointermove="onOutBracketPointerMove"
@@ -2306,7 +2342,7 @@ onBeforeUnmount(() => {
left: pxFromTime(marker.time) + 'px',
...(marker.color ? { '--marker-color': marker.color } : {}),
}"
:title="marker.name"
v-tooltip="marker.name"
@pointerdown="onMarkerPointerDown($event, marker.id)"
@pointermove="onMarkerPointerMove($event, marker.id)"
@pointerup="onMarkerPointerUp($event, marker.id)"
@@ -2413,11 +2449,22 @@ onBeforeUnmount(() => {
/>
<span v-if="!track.isParent" class="sub-indent">↳</span>
<span class="track-label">{{ track.label }}</span>
<button
v-if="track.isParent"
class="eye-btn"
:class="{ hidden: track.layer.editorHidden }"
v-tooltip="track.layer.editorHidden ? t('timeline.showInEditor') : t('timeline.hideInEditor')"
@click.stop="store.setLayerEditorHidden(track.layerId, !track.layer.editorHidden)"
@mousedown.stop
@dragstart.prevent.stop
>
<component :is="track.layer.editorHidden ? PhEyeSlash : PhEye" :size="12" />
</button>
<button
v-if="track.isParent && !lockedGroupChildIds.has(track.layerId)"
class="lock-btn"
:class="{ locked: track.layer.locked }"
:title="track.layer.locked ? t('timeline.unlock') : t('timeline.lock')"
v-tooltip="track.layer.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="store.setTrackLocked(track.layerId, !track.layer.locked)"
@mousedown.stop
@dragstart.prevent.stop
@@ -2460,7 +2507,7 @@ onBeforeUnmount(() => {
:data-layer-id="track.layerId"
:data-kind="track.kind"
:data-kf-id="kf.id"
:title="
v-tooltip="
kf.hold
? t('timeline.stillFrameTooltip', { time: formatKfTime(kf.time) })
: t('timeline.keyframeTooltip', { time: formatKfTime(kf.time) })
@@ -2473,6 +2520,19 @@ onBeforeUnmount(() => {
@dblclick="onKeyframeDblClick($event, track.layerId, kf.id)"
@contextmenu="onKeyframeContextMenu($event, track.layerId, kf.id, track.kfs)"
/>
<button
v-for="emptyT in emptyButtonSlots(track.kfs)"
:key="`empty-${track.layerId}-${track.kind}-${emptyT}`"
type="button"
class="kf-add"
:style="{ left: pxFromTime(emptyT) + 'px' }"
v-tooltip="t('timeline.addKfFromPrev')"
@mousedown.stop
@pointerdown.stop
@click.stop="addKfFromPrev(track.layerId, track.kfs, emptyT)"
>
<PhPlus :size="10" weight="bold" />
</button>
</div>
</div>
@@ -2525,7 +2585,7 @@ onBeforeUnmount(() => {
v-if="!lockedGroupChildIds.has(track.clipTrackId)"
class="lock-btn"
:class="{ locked: !!clipTrackById.get(track.clipTrackId)?.locked }"
:title="clipTrackById.get(track.clipTrackId)?.locked ? t('timeline.unlock') : t('timeline.lock')"
v-tooltip="clipTrackById.get(track.clipTrackId)?.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="
store.setTrackLocked(
track.clipTrackId,
@@ -2568,7 +2628,7 @@ onBeforeUnmount(() => {
moving: clipMove?.instanceId === ci.id,
}"
:style="clipBlockStyle(ci)"
:title="trimTooltip(ci)"
v-tooltip="trimTooltip(ci)"
@pointerdown="onClipBlockPointerDown($event, ci)"
@pointermove="onClipBlockPointerMove"
@pointerup="onClipBlockPointerUp"
@@ -2634,7 +2694,7 @@ onBeforeUnmount(() => {
<button
class="lock-btn"
:class="{ locked: track.locked }"
:title="track.locked ? t('timeline.unlock') : t('timeline.lock')"
v-tooltip="track.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="store.setTrackLocked(track.scriptTrackId, !track.locked)"
@mousedown.stop
@dragstart.prevent.stop
@@ -2662,7 +2722,7 @@ onBeforeUnmount(() => {
:key="frame.id"
class="script-frame"
:style="{ left: pxFromTime(frame.time) + 'px' }"
:title="frame.label ?? t('timeline.scriptFrameTooltip', { n: frame.actions.length })"
v-tooltip="frame.label ?? t('timeline.scriptFrameTooltip', { n: frame.actions.length })"
@pointerdown="onScriptFramePointerDown($event, frame.id)"
@pointermove="onScriptFramePointerMove"
@pointerup="onScriptFramePointerUp"
@@ -2713,7 +2773,7 @@ onBeforeUnmount(() => {
<button
class="lock-btn"
:class="{ locked: track.locked }"
:title="track.locked ? t('timeline.unlock') : t('timeline.lock')"
v-tooltip="track.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="store.setTrackLocked(track.groupId, !track.locked)"
@mousedown.stop
@dragstart.prevent.stop
@@ -2768,7 +2828,7 @@ onBeforeUnmount(() => {
<span class="hit-track-label">{{ hitShapeLabel(shape, idx) }}</span>
<button
class="hit-track-delete"
:title="t('timeline.hitTrackDelete')"
v-tooltip="t('timeline.hitTrackDelete')"
@pointerdown.stop
@click.stop="onDeleteHitShape(idx)"
>×</button>
@@ -2806,7 +2866,7 @@ onBeforeUnmount(() => {
class="name-col-resize"
:class="{ resizing: nameResizing }"
:style="{ left: trackNameWidth + 'px' }"
:title="t('timeline.resizeTrackColumn')"
v-tooltip="t('timeline.resizeTrackColumn')"
@pointerdown="onNameResizeStart"
@pointermove="onNameResizeMove"
@pointerup="onNameResizeEnd"
@@ -2880,7 +2940,7 @@ onBeforeUnmount(() => {
class="color-tile"
:class="{ selected: trackMenu.currentColor === c.value, none: c.value === null }"
:style="c.value ? { background: c.value } : undefined"
:title="c.label"
v-tooltip="c.label"
@click="applyTrackColor(c.value)"
>
<PhCheck
@@ -3631,6 +3691,40 @@ onBeforeUnmount(() => {
transition: opacity 140ms var(--ease), color 140ms var(--ease);
}
/* Editor-only show/hide toggle. Sits to the left of the lock button.
Stays visible when the layer is hidden so the state is obvious; on
hover it fades in on every track so you can reach it without the
layer first being hidden. */
.eye-btn {
margin-left: auto;
margin-right: 2px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: none;
color: var(--text-dim);
cursor: pointer;
flex-shrink: 0;
opacity: 0;
transition: opacity 140ms var(--ease), color 140ms var(--ease);
}
.track-name:hover .eye-btn {
opacity: 0.7;
}
.eye-btn:hover {
color: var(--text);
opacity: 1;
}
.eye-btn.hidden {
opacity: 1;
color: var(--accent);
}
.track-name:hover .lock-btn {
opacity: 0.7;
}
@@ -3905,6 +3999,37 @@ onBeforeUnmount(() => {
box-shadow: none;
}
/* Button-editor empty-slot placeholder. Outlined accent-coloured
circle that stays fully opaque (no fade) so it's obvious the slot
is interactive; on hover the fill solidifies into the accent so
the user sees "I'm about to drop a keyframe here". */
.kf-add {
position: absolute;
top: 50%;
width: 18px;
height: 18px;
margin-left: -9px;
margin-top: -9px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--panel);
border: 1.5px solid var(--track-color, var(--accent));
border-radius: 50%;
color: var(--track-color, var(--accent));
cursor: pointer;
transition: background 140ms var(--ease), color 140ms var(--ease),
transform 140ms var(--ease);
z-index: 2;
}
.kf-add:hover {
background: var(--track-color, var(--accent));
color: #fff;
transform: scale(1.1);
}
.kf.hold:hover {
background: var(--panel);
border-color: #fff;