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

+135 -1
View File
@@ -11,6 +11,7 @@ import { useSceneStore } from '@/stores/scene'
import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadFromServer } from '@/lib/serverExport'
import type { ButtonAsset, Layer } from '@/types/scene'
import { Modal } from '@/components/ui'
import { t } from '@/i18n'
@@ -29,8 +30,40 @@ const progress = ref<ProgressState | null>(null)
const videoSupported = pickMimeType() !== null
// Opt-in: strip layers that are hidden in the editor (`editorHidden`)
// from the exported scene. Default off — editor-hide is a workflow
// aid and shouldn't silently drop content on export unless the user
// explicitly asks for it. Applies to every format below.
const skipHidden = ref(false)
const hasHiddenLayers = computed(() => {
if (store.scene.layers.some((l) => l.editorHidden)) return true
for (const clip of store.scene.clips ?? []) {
if (clip.layers.some((l) => l.editorHidden)) return true
}
for (const asset of store.scene.assets) {
if (asset.kind === 'button' && asset.layers.some((l) => l.editorHidden)) return true
}
return false
})
function sceneClone() {
return JSON.parse(JSON.stringify(store.scene))
const scene = JSON.parse(JSON.stringify(store.scene))
if (!skipHidden.value) return scene
scene.layers = scene.layers.filter((l: Layer) => !l.editorHidden)
if (Array.isArray(scene.clips)) {
for (const clip of scene.clips) {
clip.layers = clip.layers.filter((l: Layer) => !l.editorHidden)
}
}
if (Array.isArray(scene.assets)) {
for (const asset of scene.assets as ButtonAsset[]) {
if (asset && asset.kind === 'button' && Array.isArray(asset.layers)) {
asset.layers = asset.layers.filter((l: Layer) => !l.editorHidden)
}
}
}
return scene
}
function exportScene() {
@@ -131,6 +164,24 @@ function onUpdate(v: boolean) {
@update:model-value="onUpdate"
>
<div v-if="!progress" class="options">
<label
class="export-toggle"
:class="{ disabled: !hasHiddenLayers }"
v-tooltip="hasHiddenLayers ? undefined : t('export.skipHiddenNone')"
>
<span class="switch" :class="{ on: skipHidden && hasHiddenLayers }">
<input
type="checkbox"
v-model="skipHidden"
:disabled="!hasHiddenLayers"
/>
</span>
<div class="export-toggle-text">
<div class="export-toggle-label">{{ t('export.skipHidden') }}</div>
<div class="export-toggle-desc">{{ t('export.skipHiddenDesc') }}</div>
</div>
</label>
<button class="opt" @click="exportRuntime">
<div class="opt-icon"><PhEngine :size="20" /></div>
<div class="opt-text">
@@ -223,6 +274,89 @@ function onUpdate(v: boolean) {
padding: 12px;
}
.export-toggle {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
margin-bottom: 6px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel-alt);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
}
.export-toggle.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.export-toggle-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.export-toggle-label {
font-size: 12px;
font-weight: 500;
color: var(--text);
}
.export-toggle-desc {
font-size: 11px;
color: var(--text-dim);
line-height: 1.35;
}
/* iOS-style switch. The <input> is a transparent overlay that still
handles keyboard focus and click targeting; the visual is drawn by
the span + ::after pill. */
.switch {
position: relative;
width: 32px;
height: 18px;
flex-shrink: 0;
background: var(--border);
border-radius: 999px;
transition: background 140ms var(--ease);
}
.switch.on {
background: var(--accent);
}
.switch input {
position: absolute;
inset: 0;
margin: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: inherit;
}
.switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: transform 140ms var(--ease);
}
.switch.on::after {
transform: translateX(14px);
}
.opt {
display: flex;
align-items: center;