Help Modal

This commit is contained in:
th committed 2026-04-23 19:33:22 +02:00
1 parent 8e8aa5f27c
commit 8d987d1526
11 files changed
+2405 -58

No files matched your search

+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "animatr", "name": "ruwio",
"private": true, "private": true,
"version": "0.10.7", "version": "0.11.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Bump the version in package.json. Usage:
#
# bash scripts/bump.sh patch # 0.10.7 -> 0.10.8
# bash scripts/bump.sh minor # 0.10.7 -> 0.11.0
# bash scripts/bump.sh major # 0.10.7 -> 1.0.0
set -euo pipefail
cd "$(dirname "$0")/.."
KIND="${1:-}"
case "$KIND" in
patch|minor|major) ;;
*)
echo "Usage: $0 {patch|minor|major}" >&2
exit 1
;;
esac
NEW_VERSION=$(node -e "
const fs = require('fs');
const pkgPath = './package.json';
const verPath = './src/version.ts';
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const m = /^(\d+)\.(\d+)\.(\d+)\$/.exec(pkg.version);
if (!m) { console.error('unparseable version:', pkg.version); process.exit(1); }
let [_, a, b, c] = m.map((x, i) => i === 0 ? x : Number(x));
if (process.argv[1] === 'patch') c += 1;
else if (process.argv[1] === 'minor') { b += 1; c = 0; }
else if (process.argv[1] === 'major') { a += 1; b = 0; c = 0; }
const next = a + '.' + b + '.' + c;
pkg.version = next;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
const ver = fs.readFileSync(verPath, 'utf8');
const updated = ver.replace(
/(APP_VERSION\s*=\s*)(['\"])[^'\"]*\2/,
(_m, lhs, q) => lhs + q + next + q,
);
if (updated === ver) { console.error('APP_VERSION not found in', verPath); process.exit(1); }
fs.writeFileSync(verPath, updated);
process.stdout.write(next);
" "$KIND")
echo "Bumped to $NEW_VERSION"
+7
View File
@@ -13,12 +13,14 @@ import {
PhPause, PhPause,
PhPencilSimpleLine, PhPencilSimpleLine,
PhPlay, PhPlay,
PhQuestion,
PhSkipBack, PhSkipBack,
PhSparkle, PhSparkle,
PhStop, PhStop,
} from '@phosphor-icons/vue' } from '@phosphor-icons/vue'
import AboutDialog from './components/AboutDialog.vue' import AboutDialog from './components/AboutDialog.vue'
import AssetSidebar from './components/AssetSidebar.vue' import AssetSidebar from './components/AssetSidebar.vue'
import HelpDialog from './components/HelpDialog.vue'
import CanvasStage from './components/CanvasStage.vue' import CanvasStage from './components/CanvasStage.vue'
import ClipartDrawer from './components/ClipartDrawer.vue' import ClipartDrawer from './components/ClipartDrawer.vue'
import SceneDrawer from './components/SceneDrawer.vue' import SceneDrawer from './components/SceneDrawer.vue'
@@ -59,6 +61,7 @@ const testsOpen = ref(false)
const welcomeOpen = ref(false) const welcomeOpen = ref(false)
const fullscreenOpen = ref(false) const fullscreenOpen = ref(false)
const aboutOpen = ref(false) const aboutOpen = ref(false)
const helpOpen = ref(false)
const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight' const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight'
const DEFAULT_TIMELINE_HEIGHT = 220 const DEFAULT_TIMELINE_HEIGHT = 220
const MIN_TIMELINE_HEIGHT = 120 const MIN_TIMELINE_HEIGHT = 120
@@ -501,6 +504,9 @@ onBeforeUnmount(() => {
<PhFloppyDisk :size="16" /> <PhFloppyDisk :size="16" />
<span v-if="isDirty" class="dirty-dot" aria-hidden="true"></span> <span v-if="isDirty" class="dirty-dot" aria-hidden="true"></span>
</button> </button>
<button class="icon-btn" v-tooltip="t('help.openTooltip')" @click="helpOpen = true">
<PhQuestion :size="16" />
</button>
<button class="icon-btn" v-tooltip="t('app.documentSettings')" @click="settingsOpen = true"> <button class="icon-btn" v-tooltip="t('app.documentSettings')" @click="settingsOpen = true">
<PhGearSix :size="16" /> <PhGearSix :size="16" />
</button> </button>
@@ -556,6 +562,7 @@ onBeforeUnmount(() => {
<TestOverlay v-model="testsOpen" /> <TestOverlay v-model="testsOpen" />
<FullscreenPreview v-model="fullscreenOpen" /> <FullscreenPreview v-model="fullscreenOpen" />
<AboutDialog v-model="aboutOpen" /> <AboutDialog v-model="aboutOpen" />
<HelpDialog v-model="helpOpen" />
<DialogsHost /> <DialogsHost />
<WelcomeDialog <WelcomeDialog
v-model="welcomeOpen" v-model="welcomeOpen"
+122 -43
View File
@@ -5,7 +5,6 @@ import {
PhFolder, PhFolder,
PhFrameCorners, PhFrameCorners,
PhMoon, PhMoon,
PhPalette,
PhSlidersHorizontal, PhSlidersHorizontal,
PhSun, PhSun,
PhX, PhX,
@@ -18,7 +17,8 @@ import { useTooltipPref } from '@/composables/useTooltipPref'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref' import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { showLocked } from '@/composables/useShowLocked' import { showLocked } from '@/composables/useShowLocked'
import { Modal, confirmDialog, promptDialog } from '@/components/ui' import { Modal, confirmDialog, promptDialog } from '@/components/ui'
import { t } from '@/i18n' import { setLocale, t, useI18n } from '@/i18n'
import type { Locale } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>() const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>() const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -28,6 +28,7 @@ const clipart = useClipartStore()
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const { enabled: tooltipsEnabled } = useTooltipPref() const { enabled: tooltipsEnabled } = useTooltipPref()
const { enabled: thumbsEnabled } = useAssetThumbsPref() const { enabled: thumbsEnabled } = useAssetThumbsPref()
const { locale } = useI18n()
// Re-check the stored clipart folder whenever settings opens — covers the // Re-check the stored clipart folder whenever settings opens — covers the
// case where initial App.vue init raced ahead of IDB or the user opens // case where initial App.vue init raced ahead of IDB or the user opens
@@ -39,13 +40,12 @@ watch(
}, },
) )
type TabId = 'document' | 'clipart' | 'interface' | 'theme' type TabId = 'document' | 'clipart' | 'interface'
const tabs: { id: TabId; label: string; icon: typeof PhFrameCorners }[] = [ const tabs: { id: TabId; labelKey: string; icon: typeof PhFrameCorners }[] = [
{ id: 'document', label: t('settings.tab.document'), icon: PhFrameCorners }, { id: 'document', labelKey: 'settings.tab.document', icon: PhFrameCorners },
{ id: 'clipart', label: t('settings.tab.clipart'), icon: PhFolder }, { id: 'clipart', labelKey: 'settings.tab.clipart', icon: PhFolder },
{ id: 'interface', label: t('settings.tab.interface'), icon: PhSlidersHorizontal }, { id: 'interface', labelKey: 'settings.tab.interface', icon: PhSlidersHorizontal },
{ id: 'theme', label: t('settings.tab.theme'), icon: PhPalette },
] ]
const activeTab = ref<TabId>('document') const activeTab = ref<TabId>('document')
@@ -195,6 +195,11 @@ const fps = computed({
set: (v: number) => store.setFps(v), set: (v: number) => store.setFps(v),
}) })
const selectedLocale = computed({
get: () => locale.value,
set: (value: Locale) => setLocale(value),
})
const presets: { label: string; w: number; h: number }[] = [ const presets: { label: string; w: number; h: number }[] = [
{ label: '16:9 · 1280×720', w: 1280, h: 720 }, { label: '16:9 · 1280×720', w: 1280, h: 720 },
{ label: '16:9 · 1920×1080', w: 1920, h: 1080 }, { label: '16:9 · 1920×1080', w: 1920, h: 1080 },
@@ -221,6 +226,15 @@ function applyTheme(t: Theme) {
:model-value="modelValue" :model-value="modelValue"
:title="t('settings.title')" :title="t('settings.title')"
size="lg" size="lg"
:dialog-style="{
width: 'min(920px, calc(100% - 32px))',
height: 'min(620px, calc(100vh - 48px))',
}"
:body-style="{
display: 'flex',
overflow: 'hidden',
minHeight: 0,
}"
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<div class="body"> <div class="body">
@@ -233,7 +247,7 @@ function applyTheme(t: Theme) {
@click="activeTab = tab.id" @click="activeTab = tab.id"
> >
<component :is="tab.icon" :size="14" /> <component :is="tab.icon" :size="14" />
{{ tab.label }} {{ t(tab.labelKey) }}
</button> </button>
</nav> </nav>
@@ -362,6 +376,18 @@ function applyTheme(t: Theme) {
<section v-else-if="activeTab === 'interface'"> <section v-else-if="activeTab === 'interface'">
<h3>{{ t('settings.tab.interface') }}</h3> <h3>{{ t('settings.tab.interface') }}</h3>
<div class="setting-block">
<div class="setting-heading">{{ t('settings.languageTitle') }}</div>
<div class="setting-desc">{{ t('settings.languageDesc') }}</div>
<label class="setting-select">
<span class="setting-label">{{ t('settings.languageLabel') }}</span>
<select v-model="selectedLocale">
<option value="en">{{ t('settings.languageEnglish') }}</option>
<option value="de">{{ t('settings.languageGerman') }}</option>
</select>
</label>
</div>
<label class="row-toggle"> <label class="row-toggle">
<span class="switch" :class="{ on: tooltipsEnabled }"> <span class="switch" :class="{ on: tooltipsEnabled }">
<input type="checkbox" v-model="tooltipsEnabled" /> <input type="checkbox" v-model="tooltipsEnabled" />
@@ -391,41 +417,41 @@ function applyTheme(t: Theme) {
<div class="row-toggle-desc">{{ t('settings.assetThumbsDesc') }}</div> <div class="row-toggle-desc">{{ t('settings.assetThumbsDesc') }}</div>
</div> </div>
</label> </label>
</section>
<section v-else-if="activeTab === 'theme'"> <div class="setting-block">
<h3>{{ t('settings.tab.theme') }}</h3> <div class="setting-heading">{{ t('settings.appearanceTitle') }}</div>
<div class="theme-grid"> <div class="setting-desc">{{ t('settings.themeHint') }}</div>
<button <div class="theme-grid">
class="theme-card" <button
:class="{ active: theme === 'dark' }" class="theme-card"
@click="applyTheme('dark')" :class="{ active: theme === 'dark' }"
> @click="applyTheme('dark')"
<div class="theme-preview theme-preview-dark"> >
<div class="tp-panel"></div> <div class="theme-preview theme-preview-dark">
<div class="tp-accent"></div> <div class="tp-panel"></div>
</div> <div class="tp-accent"></div>
<div class="theme-label"> </div>
<PhMoon :size="14" /> <div class="theme-label">
<span>{{ t('settings.themeDark') }}</span> <PhMoon :size="14" />
</div> <span>{{ t('settings.themeDark') }}</span>
</button> </div>
<button </button>
class="theme-card" <button
:class="{ active: theme === 'light' }" class="theme-card"
@click="applyTheme('light')" :class="{ active: theme === 'light' }"
> @click="applyTheme('light')"
<div class="theme-preview theme-preview-light"> >
<div class="tp-panel"></div> <div class="theme-preview theme-preview-light">
<div class="tp-accent"></div> <div class="tp-panel"></div>
</div> <div class="tp-accent"></div>
<div class="theme-label"> </div>
<PhSun :size="14" /> <div class="theme-label">
<span>{{ t('settings.themeLight') }}</span> <PhSun :size="14" />
</div> <span>{{ t('settings.themeLight') }}</span>
</button> </div>
</button>
</div>
</div> </div>
<p class="hint">{{ t('settings.themeHint') }}</p>
</section> </section>
</div> </div>
</div> </div>
@@ -438,7 +464,8 @@ function applyTheme(t: Theme) {
<style scoped> <style scoped>
.body { .body {
display: flex; display: flex;
min-height: 340px; flex: 1 1 auto;
min-height: 0;
} }
.tabs { .tabs {
@@ -450,6 +477,7 @@ function applyTheme(t: Theme) {
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
background: var(--panel-alt); background: var(--panel-alt);
min-height: 0;
} }
.tab { .tab {
@@ -478,6 +506,7 @@ function applyTheme(t: Theme) {
padding: 16px 20px; padding: 16px 20px;
overflow: auto; overflow: auto;
min-width: 0; min-width: 0;
min-height: 0;
} }
section { section {
@@ -605,6 +634,56 @@ input[type='number'] {
margin: 0; margin: 0;
} }
.setting-block {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel-alt);
}
.setting-heading {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.setting-desc {
font-size: 12px;
line-height: 1.45;
color: var(--text-dim);
}
.setting-select {
display: flex;
flex-direction: column;
gap: 6px;
flex: 0 0 auto;
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.setting-label {
color: var(--text-dim);
}
.setting-select select {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 10px;
border-radius: var(--radius-sm);
font: inherit;
font-size: 13px;
text-transform: none;
letter-spacing: normal;
}
/* Switch + two-line label. Defensive resets defeat the scoped /* Switch + two-line label. Defensive resets defeat the scoped
blanket `label { flex-direction: column; text-transform: uppercase; blanket `label { flex-direction: column; text-transform: uppercase;
font-size: 10px; flex: 1 1 0; }` that form-row labels in this file font-size: 10px; flex: 1 1 0; }` that form-row labels in this file
+918
View File
@@ -0,0 +1,918 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import type { Component } from 'vue'
import {
PhCode,
PhCursorClick,
PhExport,
PhFilmStrip,
PhFrameCorners,
PhImageSquare,
PhListBullets,
PhMusicNote,
PhPencilSimpleLine,
PhQuestion,
PhSlidersHorizontal,
PhSparkle,
} from '@phosphor-icons/vue'
import { Modal } from '@/components/ui'
import { t } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
type ChapterId =
| 'getting-started'
| 'stage'
| 'assets'
| 'layers-timeline'
| 'clips'
| 'buttons'
| 'scenes'
| 'audio'
| 'markers'
| 'scripts-actions'
| 'script-language'
| 'export'
| 'shortcuts'
interface HelpListItem {
bodyKey: string
icon?: Component
labelKey?: string
}
interface HelpTableRow {
effectKey: string
syntaxKey: string
}
type HelpBlock =
| { kind: 'paragraph'; bodyKey: string; titleKey?: string }
| { items: HelpListItem[]; kind: 'list'; titleKey?: string }
| { items: string[]; kind: 'steps'; titleKey: string }
| { kind: 'table'; rows: HelpTableRow[]; titleKey: string }
| { bodyKey: string; kind: 'code'; titleKey: string }
| { kind: 'shortcuts' }
interface Chapter {
blocks: HelpBlock[]
icon: Component
id: ChapterId
introKey: string
titleKey: string
}
const CHAPTERS: Chapter[] = [
{
id: 'getting-started',
titleKey: 'help.chapters.gettingStarted',
icon: PhSparkle,
introKey: 'help.content.gettingStarted.intro',
blocks: [
{
kind: 'paragraph',
titleKey: 'help.content.gettingStarted.modelTitle',
bodyKey: 'help.content.gettingStarted.modelBody',
},
{
kind: 'list',
titleKey: 'help.content.gettingStarted.whereTitle',
items: [
{
icon: PhQuestion,
labelKey: 'help.content.gettingStarted.where.topBar.label',
bodyKey: 'help.content.gettingStarted.where.topBar.body',
},
{
icon: PhImageSquare,
labelKey: 'help.content.gettingStarted.where.assets.label',
bodyKey: 'help.content.gettingStarted.where.assets.body',
},
{
icon: PhFrameCorners,
labelKey: 'help.content.gettingStarted.where.stage.label',
bodyKey: 'help.content.gettingStarted.where.stage.body',
},
{
icon: PhSlidersHorizontal,
labelKey: 'help.content.gettingStarted.where.properties.label',
bodyKey: 'help.content.gettingStarted.where.properties.body',
},
{
icon: PhFilmStrip,
labelKey: 'help.content.gettingStarted.where.timeline.label',
bodyKey: 'help.content.gettingStarted.where.timeline.body',
},
{
icon: PhListBullets,
labelKey: 'help.content.gettingStarted.where.scenes.label',
bodyKey: 'help.content.gettingStarted.where.scenes.body',
},
],
},
{
kind: 'steps',
titleKey: 'help.content.gettingStarted.quickStartTitle',
items: [
'help.content.gettingStarted.quickStart.step1',
'help.content.gettingStarted.quickStart.step2',
'help.content.gettingStarted.quickStart.step3',
'help.content.gettingStarted.quickStart.step4',
],
},
],
},
{
id: 'stage',
titleKey: 'help.chapters.stage',
icon: PhFrameCorners,
introKey: 'help.content.stage.intro',
blocks: [
{
kind: 'list',
titleKey: 'help.content.stage.viewTitle',
items: [
{ bodyKey: 'help.content.stage.view.zoom' },
{ bodyKey: 'help.content.stage.view.pan' },
{ bodyKey: 'help.content.stage.view.fit' },
],
},
{
kind: 'list',
titleKey: 'help.content.stage.selectionTitle',
items: [
{ bodyKey: 'help.content.stage.selection.pick' },
{ bodyKey: 'help.content.stage.selection.clear' },
{ bodyKey: 'help.content.stage.selection.handles' },
],
},
{
kind: 'paragraph',
titleKey: 'help.content.stage.lockingTitle',
bodyKey: 'help.content.stage.lockingBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.stage.drawingTitle',
bodyKey: 'help.content.stage.drawingBody',
},
],
},
{
id: 'assets',
titleKey: 'help.chapters.assets',
icon: PhImageSquare,
introKey: 'help.content.assets.intro',
blocks: [
{
kind: 'list',
titleKey: 'help.content.assets.sectionsTitle',
items: [
{ bodyKey: 'help.content.assets.sections.shapes', icon: PhSparkle },
{ bodyKey: 'help.content.assets.sections.images', icon: PhImageSquare },
{ bodyKey: 'help.content.assets.sections.clips', icon: PhFilmStrip },
{ bodyKey: 'help.content.assets.sections.buttons', icon: PhCursorClick },
{ bodyKey: 'help.content.assets.sections.audio', icon: PhMusicNote },
],
},
{
kind: 'paragraph',
titleKey: 'help.content.assets.thumbsTitle',
bodyKey: 'help.content.assets.thumbsBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.assets.foldTitle',
bodyKey: 'help.content.assets.foldBody',
},
],
},
{
id: 'layers-timeline',
titleKey: 'help.chapters.layersTimeline',
icon: PhFilmStrip,
introKey: 'help.content.layersTimeline.intro',
blocks: [
{
kind: 'list',
titleKey: 'help.content.layersTimeline.keyframesTitle',
items: [
{ bodyKey: 'help.content.layersTimeline.keyframes.add' },
{ bodyKey: 'help.content.layersTimeline.keyframes.drag' },
{ bodyKey: 'help.content.layersTimeline.keyframes.jump' },
{ bodyKey: 'help.content.layersTimeline.keyframes.menu' },
],
},
{
kind: 'paragraph',
titleKey: 'help.content.layersTimeline.holdsTitle',
bodyKey: 'help.content.layersTimeline.holdsBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.layersTimeline.separateTitle',
bodyKey: 'help.content.layersTimeline.separateBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.layersTimeline.groupsTitle',
bodyKey: 'help.content.layersTimeline.groupsBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.layersTimeline.snappingTitle',
bodyKey: 'help.content.layersTimeline.snappingBody',
},
],
},
{
id: 'clips',
titleKey: 'help.chapters.clips',
icon: PhFilmStrip,
introKey: 'help.content.clips.intro',
blocks: [
{
kind: 'steps',
titleKey: 'help.content.clips.createTitle',
items: [
'help.content.clips.create.step1',
'help.content.clips.create.step2',
'help.content.clips.create.step3',
],
},
{
kind: 'paragraph',
titleKey: 'help.content.clips.editTitle',
bodyKey: 'help.content.clips.editBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.clips.trimTitle',
bodyKey: 'help.content.clips.trimBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.clips.transformTitle',
bodyKey: 'help.content.clips.transformBody',
},
],
},
{
id: 'buttons',
titleKey: 'help.chapters.buttons',
icon: PhCursorClick,
introKey: 'help.content.buttons.intro',
blocks: [
{
kind: 'paragraph',
titleKey: 'help.content.buttons.editingTitle',
bodyKey: 'help.content.buttons.editingBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.buttons.stateKeyframesTitle',
bodyKey: 'help.content.buttons.stateKeyframesBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.buttons.stillFramesTitle',
bodyKey: 'help.content.buttons.stillFramesBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.buttons.hitShapesTitle',
bodyKey: 'help.content.buttons.hitShapesBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.buttons.actionsTitle',
bodyKey: 'help.content.buttons.actionsBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.buttons.disabledTitle',
bodyKey: 'help.content.buttons.disabledBody',
},
],
},
{
id: 'scenes',
titleKey: 'help.chapters.scenes',
icon: PhListBullets,
introKey: 'help.content.scenes.intro',
blocks: [
{
kind: 'paragraph',
titleKey: 'help.content.scenes.drawerTitle',
bodyKey: 'help.content.scenes.drawerBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.scenes.switchingTitle',
bodyKey: 'help.content.scenes.switchingBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.scenes.referencesTitle',
bodyKey: 'help.content.scenes.referencesBody',
},
],
},
{
id: 'audio',
titleKey: 'help.chapters.audio',
icon: PhMusicNote,
introKey: 'help.content.audio.intro',
blocks: [
{
kind: 'steps',
titleKey: 'help.content.audio.addTitle',
items: [
'help.content.audio.add.step1',
'help.content.audio.add.step2',
'help.content.audio.add.step3',
],
},
{
kind: 'paragraph',
titleKey: 'help.content.audio.tracksTitle',
bodyKey: 'help.content.audio.tracksBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.audio.movingTitle',
bodyKey: 'help.content.audio.movingBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.audio.waveformsTitle',
bodyKey: 'help.content.audio.waveformsBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.audio.urlsTitle',
bodyKey: 'help.content.audio.urlsBody',
},
],
},
{
id: 'markers',
titleKey: 'help.chapters.markers',
icon: PhPencilSimpleLine,
introKey: 'help.content.markers.intro',
blocks: [
{
kind: 'list',
titleKey: 'help.content.markers.editingTitle',
items: [
{ bodyKey: 'help.content.markers.editing.add' },
{ bodyKey: 'help.content.markers.editing.drag' },
{ bodyKey: 'help.content.markers.editing.rename' },
{ bodyKey: 'help.content.markers.editing.delete' },
{ bodyKey: 'help.content.markers.editing.clear' },
],
},
{
kind: 'paragraph',
titleKey: 'help.content.markers.sceneTitle',
bodyKey: 'help.content.markers.sceneBody',
},
],
},
{
id: 'scripts-actions',
titleKey: 'help.chapters.scriptsActions',
icon: PhCode,
introKey: 'help.content.scriptsActions.intro',
blocks: [
{
kind: 'steps',
titleKey: 'help.content.scriptsActions.addTitle',
items: [
'help.content.scriptsActions.add.step1',
'help.content.scriptsActions.add.step2',
'help.content.scriptsActions.add.step3',
],
},
{
kind: 'list',
titleKey: 'help.content.scriptsActions.firingTitle',
items: [
{ bodyKey: 'help.content.scriptsActions.firing.forward' },
{ bodyKey: 'help.content.scriptsActions.firing.exact' },
{ bodyKey: 'help.content.scriptsActions.firing.order' },
{ bodyKey: 'help.content.scriptsActions.firing.replay' },
],
},
{
kind: 'paragraph',
titleKey: 'help.content.scriptsActions.menuTitle',
bodyKey: 'help.content.scriptsActions.menuBody',
},
{
kind: 'list',
titleKey: 'help.content.scriptsActions.typesTitle',
items: [
{ bodyKey: 'help.content.scriptsActions.types.transport' },
{ bodyKey: 'help.content.scriptsActions.types.jump' },
{ bodyKey: 'help.content.scriptsActions.types.scene' },
{ bodyKey: 'help.content.scriptsActions.types.button' },
{ bodyKey: 'help.content.scriptsActions.types.script' },
],
},
],
},
{
id: 'script-language',
titleKey: 'help.chapters.scriptLanguage',
icon: PhCode,
introKey: 'help.content.scriptLanguage.intro',
blocks: [
{
kind: 'list',
titleKey: 'help.content.scriptLanguage.syntaxTitle',
items: [
{ bodyKey: 'help.content.scriptLanguage.syntax.onePerLine' },
{ bodyKey: 'help.content.scriptLanguage.syntax.comments' },
{ bodyKey: 'help.content.scriptLanguage.syntax.tokens' },
{ bodyKey: 'help.content.scriptLanguage.syntax.case' },
],
},
{
kind: 'table',
titleKey: 'help.content.scriptLanguage.commandsTitle',
rows: [
{
syntaxKey: 'help.content.scriptLanguage.commands.play.syntax',
effectKey: 'help.content.scriptLanguage.commands.play.effect',
},
{
syntaxKey: 'help.content.scriptLanguage.commands.pause.syntax',
effectKey: 'help.content.scriptLanguage.commands.pause.effect',
},
{
syntaxKey: 'help.content.scriptLanguage.commands.rewind.syntax',
effectKey: 'help.content.scriptLanguage.commands.rewind.effect',
},
{
syntaxKey: 'help.content.scriptLanguage.commands.jumpSeconds.syntax',
effectKey: 'help.content.scriptLanguage.commands.jumpSeconds.effect',
},
{
syntaxKey: 'help.content.scriptLanguage.commands.jumpFrame.syntax',
effectKey: 'help.content.scriptLanguage.commands.jumpFrame.effect',
},
{
syntaxKey: 'help.content.scriptLanguage.commands.jumpMarker.syntax',
effectKey: 'help.content.scriptLanguage.commands.jumpMarker.effect',
},
],
},
{
kind: 'code',
titleKey: 'help.content.scriptLanguage.exampleTitle',
bodyKey: 'help.content.scriptLanguage.exampleBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.scriptLanguage.errorsTitle',
bodyKey: 'help.content.scriptLanguage.errorsBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.scriptLanguage.notInDslTitle',
bodyKey: 'help.content.scriptLanguage.notInDslBody',
},
],
},
{
id: 'export',
titleKey: 'help.chapters.export',
icon: PhExport,
introKey: 'help.content.export.intro',
blocks: [
{
kind: 'paragraph',
titleKey: 'help.content.export.editorPreviewTitle',
bodyKey: 'help.content.export.editorPreviewBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.export.fullscreenPreviewTitle',
bodyKey: 'help.content.export.fullscreenPreviewBody',
},
{
kind: 'list',
titleKey: 'help.content.export.formatsTitle',
items: [
{ bodyKey: 'help.content.export.formats.runtime' },
{ bodyKey: 'help.content.export.formats.scene' },
{ bodyKey: 'help.content.export.formats.html' },
{ bodyKey: 'help.content.export.formats.mp4' },
{ bodyKey: 'help.content.export.formats.webm' },
],
},
{
kind: 'paragraph',
titleKey: 'help.content.export.skipHiddenTitle',
bodyKey: 'help.content.export.skipHiddenBody',
},
{
kind: 'paragraph',
titleKey: 'help.content.export.assetsTitle',
bodyKey: 'help.content.export.assetsBody',
},
],
},
{
id: 'shortcuts',
titleKey: 'help.chapters.shortcuts',
icon: PhQuestion,
introKey: 'help.content.shortcuts.intro',
blocks: [{ kind: 'shortcuts' }],
},
]
const activeChapter = ref<ChapterId>('getting-started')
const contentRef = ref<HTMLElement | null>(null)
const activeChapterDef = computed<Chapter>(
() => CHAPTERS.find((chapter) => chapter.id === activeChapter.value) ?? CHAPTERS[0],
)
watch(
() => props.modelValue,
async (open) => {
if (!open) return
activeChapter.value = 'getting-started'
await nextTick()
if (contentRef.value) contentRef.value.scrollTop = 0
},
)
function selectChapter(id: ChapterId) {
activeChapter.value = id
if (contentRef.value) contentRef.value.scrollTop = 0
}
const APP_SHORTCUTS = [
{ labelKey: 'help.shortcut.playPause', keys: 'Space' },
{ labelKey: 'help.shortcut.fullscreen', keys: 'Shift+Space' },
{ labelKey: 'help.shortcut.undo', keys: 'Ctrl/Cmd+Z' },
{ labelKey: 'help.shortcut.redo', keys: 'Ctrl/Cmd+Shift+Z' },
{ labelKey: 'help.shortcut.save', keys: 'Ctrl/Cmd+S' },
{ labelKey: 'help.shortcut.inPoint', keys: 'I' },
{ labelKey: 'help.shortcut.outPoint', keys: 'O' },
{ labelKey: 'help.shortcut.rewind', keys: 'R' },
{ labelKey: 'help.shortcut.addMarker', keys: 'M' },
{ labelKey: 'help.shortcut.deleteSelected', keys: 'Backspace / Delete' },
{ labelKey: 'help.shortcut.clearSelection', keys: 'Escape' },
{ labelKey: 'help.shortcut.panCanvas', keys: 'Middle-click / Ctrl+drag' },
{ labelKey: 'help.shortcut.zoomCanvas', keys: 'Ctrl/Cmd+wheel' },
{ labelKey: 'help.shortcut.pasteImage', keys: 'Ctrl/Cmd+V' },
{ labelKey: 'help.shortcut.tests', keys: 'Ctrl+Shift+Alt+T' },
]
</script>
<template>
<Modal
:model-value="modelValue"
size="xl"
:title="t('help.title')"
:dialog-style="{
width: 'min(1100px, calc(100% - 32px))',
height: 'min(760px, calc(100vh - 48px))',
}"
:body-style="{
display: 'flex',
overflow: 'hidden',
minHeight: 0,
}"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="help">
<aside class="toc" :aria-label="t('help.tocLabel')">
<button
v-for="chapter in CHAPTERS"
:key="chapter.id"
class="toc-item"
:class="{ active: activeChapter === chapter.id }"
@click="selectChapter(chapter.id)"
>
<component :is="chapter.icon" :size="15" class="toc-icon" />
<span>{{ t(chapter.titleKey) }}</span>
</button>
</aside>
<div ref="contentRef" class="content">
<section class="chapter">
<h3 class="chapter-title">{{ t(activeChapterDef.titleKey) }}</h3>
<p class="chapter-intro" v-html="t(activeChapterDef.introKey)" />
<template v-for="block in activeChapterDef.blocks" :key="JSON.stringify(block)">
<div v-if="block.kind === 'paragraph'" class="chapter-block">
<h4 v-if="block.titleKey">{{ t(block.titleKey) }}</h4>
<p v-html="t(block.bodyKey)" />
</div>
<div v-else-if="block.kind === 'list'" class="chapter-block">
<h4 v-if="block.titleKey">{{ t(block.titleKey) }}</h4>
<ul
class="bullets"
:class="block.items.some((i) => i.icon) ? 'bullets-icon' : 'bullets-disc'"
>
<li v-for="item in block.items" :key="item.bodyKey" class="bullet-item">
<span v-if="item.icon" class="bullet-icon">
<component :is="item.icon" :size="14" />
</span>
<div class="bullet-copy">
<strong v-if="item.labelKey">{{ t(item.labelKey) }}:</strong>
<span v-html="t(item.bodyKey)" />
</div>
</li>
</ul>
</div>
<div v-else-if="block.kind === 'steps'" class="chapter-block">
<h4>{{ t(block.titleKey) }}</h4>
<ol class="bullets bullets-steps">
<li v-for="item in block.items" :key="item" v-html="t(item)" />
</ol>
</div>
<div v-else-if="block.kind === 'table'" class="chapter-block">
<h4>{{ t(block.titleKey) }}</h4>
<table class="cmd-table">
<thead>
<tr>
<th>{{ t('help.table.syntax') }}</th>
<th>{{ t('help.table.effect') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in block.rows" :key="row.syntaxKey">
<td v-html="t(row.syntaxKey)" />
<td v-html="t(row.effectKey)" />
</tr>
</tbody>
</table>
</div>
<div v-else-if="block.kind === 'code'" class="chapter-block">
<h4>{{ t(block.titleKey) }}</h4>
<pre class="code">{{ t(block.bodyKey) }}</pre>
</div>
<div v-else-if="block.kind === 'shortcuts'" class="chapter-block">
<ul class="shortcuts">
<li v-for="shortcut in APP_SHORTCUTS" :key="shortcut.keys + shortcut.labelKey">
<span class="shortcut-label">{{ t(shortcut.labelKey) }}</span>
<kbd>{{ shortcut.keys }}</kbd>
</li>
</ul>
</div>
</template>
</section>
</div>
</div>
</Modal>
</template>
<style scoped>
.help {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
.toc {
flex: 0 0 220px;
border-right: 1px solid var(--border);
padding: 10px 8px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
background: var(--panel-alt);
min-height: 0;
}
.toc-item {
display: flex;
align-items: center;
gap: 8px;
text-align: left;
padding: 8px 10px;
font-size: 12px;
color: var(--text-dim);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
}
.toc-icon {
flex-shrink: 0;
}
.toc-item:hover:not(.active) {
background: var(--panel-hover);
color: var(--text);
}
.toc-item.active {
background: var(--accent);
color: #fff;
font-weight: 600;
}
.content {
flex: 1;
overflow-y: auto;
padding: 18px 22px 22px;
min-width: 0;
min-height: 0;
}
.chapter {
display: flex;
flex-direction: column;
gap: 14px;
}
.chapter-title {
margin: 0;
font-size: 16px;
}
.chapter-intro {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--text);
}
.chapter-block {
display: flex;
flex-direction: column;
gap: 8px;
}
.chapter h4 {
margin: 0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
}
.chapter p {
margin: 0;
font-size: 13px;
line-height: 1.55;
color: var(--text);
}
.bullets {
margin: 0;
font-size: 13px;
line-height: 1.55;
}
.bullets-disc {
list-style: disc;
padding-left: 22px;
display: flex;
flex-direction: column;
gap: 4px;
}
.bullets-disc::marker,
.bullets-disc li::marker {
color: var(--text-dim);
}
.bullets-icon {
list-style: none;
padding-left: 4px;
display: flex;
flex-direction: column;
gap: 6px;
}
.bullets-steps {
padding-left: 22px;
display: flex;
flex-direction: column;
gap: 4px;
}
.bullets-icon .bullet-item {
display: flex;
gap: 10px;
align-items: flex-start;
}
.bullet-icon {
width: 18px;
color: var(--text-dim);
display: inline-flex;
align-items: center;
justify-content: flex-start;
flex-shrink: 0;
margin-top: 2px;
}
.bullet-copy {
min-width: 0;
}
.bullet-copy strong {
color: var(--text);
margin-right: 4px;
}
.chapter code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
background: var(--panel-alt);
padding: 1px 5px;
border-radius: 3px;
color: var(--text);
}
.chapter em {
font-style: italic;
color: var(--text);
}
.code {
margin: 0;
padding: 10px 12px;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
color: var(--text);
}
.cmd-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.cmd-table th,
.cmd-table td {
text-align: left;
padding: 6px 8px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.cmd-table th {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
}
.shortcuts {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 12px;
}
.shortcuts li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 5px 8px;
background: var(--panel-alt);
border-radius: var(--radius-sm);
font-size: 12px;
}
.shortcut-label {
color: var(--text);
}
kbd {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 10px;
color: var(--text-dim-strong);
}
</style>
+19 -3
View File
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue' import { onBeforeUnmount, onMounted, ref } from 'vue'
import type { StyleValue } from 'vue'
import { PhX } from '@phosphor-icons/vue' import { PhX } from '@phosphor-icons/vue'
import { t } from '@/i18n' import { t } from '@/i18n'
type Size = 'sm' | 'md' | 'lg' type Size = 'sm' | 'md' | 'lg' | 'xl'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -13,12 +14,16 @@ const props = withDefaults(
closeOnBackdrop?: boolean closeOnBackdrop?: boolean
closeOnEsc?: boolean closeOnEsc?: boolean
dismissable?: boolean dismissable?: boolean
dialogStyle?: StyleValue
bodyStyle?: StyleValue
}>(), }>(),
{ {
size: 'md', size: 'md',
closeOnBackdrop: true, closeOnBackdrop: true,
closeOnEsc: true, closeOnEsc: true,
dismissable: true, dismissable: true,
dialogStyle: undefined,
bodyStyle: undefined,
}, },
) )
@@ -99,7 +104,14 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<Teleport to="body"> <Teleport to="body">
<Transition name="modal-fade"> <Transition name="modal-fade">
<div v-if="modelValue" class="backdrop" @mousedown="onBackdropMouseDown"> <div v-if="modelValue" class="backdrop" @mousedown="onBackdropMouseDown">
<div ref="dialogRef" class="dialog" :class="`size-${size}`" role="dialog" aria-modal="true"> <div
ref="dialogRef"
class="dialog"
:class="`size-${size}`"
:style="dialogStyle"
role="dialog"
aria-modal="true"
>
<header v-if="title || $slots.header" class="hdr"> <header v-if="title || $slots.header" class="hdr">
<slot name="header"> <slot name="header">
<h2>{{ title }}</h2> <h2>{{ title }}</h2>
@@ -114,7 +126,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<PhX :size="14" weight="bold" /> <PhX :size="14" weight="bold" />
</button> </button>
</header> </header>
<div class="body" tabindex="-1"> <div class="body" :style="bodyStyle" tabindex="-1">
<slot /> <slot />
</div> </div>
<footer v-if="$slots.footer" class="ftr"> <footer v-if="$slots.footer" class="ftr">
@@ -161,6 +173,10 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
width: min(720px, calc(100% - 32px)); width: min(720px, calc(100% - 32px));
} }
.dialog.size-xl {
width: min(960px, calc(100% - 32px));
}
.hdr { .hdr {
display: flex; display: flex;
align-items: center; align-items: center;
+9 -1
View File
@@ -2,7 +2,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhArrowClockwise, PhCheck, PhClipboardText, PhWarning, PhX } from '@phosphor-icons/vue' import { PhArrowClockwise, PhCheck, PhClipboardText, PhWarning, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene' import { useSceneStore } from '@/stores/scene'
import { t } from '@/i18n' import { t, useI18n } from '@/i18n'
import { runAll, type TestResult } from './testRunner' import { runAll, type TestResult } from './testRunner'
import { registerAllTests } from './tests' import { registerAllTests } from './tests'
@@ -140,6 +140,13 @@ async function run() {
clipboardStatus.value = 'idle' clipboardStatus.value = 'idle'
startedAt.value = performance.now() startedAt.value = performance.now()
const snapshot = JSON.parse(JSON.stringify(store.$state)) const snapshot = JSON.parse(JSON.stringify(store.$state))
// The test harness asserts against English UI strings (aria-labels,
// section titles, help dialog headings, "yet"/"unsaved" substrings).
// Pin the locale to 'en' for the duration of the run so a German
// browser default doesn't flip them all red. Restore on finally.
const { locale } = useI18n()
const prevLocale = locale.value
locale.value = 'en'
try { try {
await runAll(registerAllTests, { await runAll(registerAllTests, {
onProgress: (d, t) => { onProgress: (d, t) => {
@@ -160,6 +167,7 @@ async function run() {
}) })
} finally { } finally {
store.$state = snapshot store.$state = snapshot
locale.value = prevLocale
finishedAt.value = performance.now() finishedAt.value = performance.now()
running.value = false running.value = false
} }
+116 -1
View File
@@ -1,4 +1,4 @@
import { createApp, h, nextTick, type Component, type Slot } from 'vue' import { createApp, defineComponent, h, nextTick, ref, type Component, type Slot } from 'vue'
import { import {
createBlankProject, createBlankProject,
createBlankScene, createBlankScene,
@@ -46,6 +46,7 @@ import ActionEditor from '@/components/ActionEditor.vue'
import Timeline from '@/components/Timeline.vue' import Timeline from '@/components/Timeline.vue'
import AssetSidebar from '@/components/AssetSidebar.vue' import AssetSidebar from '@/components/AssetSidebar.vue'
import FullscreenPreview from '@/components/FullscreenPreview.vue' import FullscreenPreview from '@/components/FullscreenPreview.vue'
import HelpDialog from '@/components/HelpDialog.vue'
import { createAudioRuntime } from '@/composables/useAudioRuntime' import { createAudioRuntime } from '@/composables/useAudioRuntime'
import { import {
clearWaveformCache, clearWaveformCache,
@@ -1813,6 +1814,120 @@ export function registerAllTests(): void {
}) })
}) })
suite('ui — HelpDialog', () => {
const snapshotDialogs = () =>
new Set(Array.from(document.querySelectorAll('[role="dialog"]')))
const dialogsAddedSince = (baseline: Set<Element>) =>
Array.from(document.querySelectorAll('[role="dialog"]')).filter(
(el) => !baseline.has(el),
) as HTMLElement[]
test('opens on modelValue=true and renders a TOC with all 13 chapters', async () => {
const baseline = snapshotDialogs()
const handle = mountComponent(HelpDialog, { modelValue: true })
await nextTick()
const added = dialogsAddedSince(baseline)
expect(added.length).toBe(1)
const tocItems = added[0].querySelectorAll('.toc-item')
expect(tocItems.length).toBe(13)
handle.unmount()
})
test('defaults to the Getting started chapter', async () => {
const baseline = snapshotDialogs()
const handle = mountComponent(HelpDialog, { modelValue: true })
await nextTick()
const dialog = dialogsAddedSince(baseline)[0]
const activeToc = dialog.querySelector('.toc-item.active') as HTMLElement
expect(activeToc).toBeTruthy()
expect(activeToc.textContent?.trim()).toBe('Getting started')
// Content heading matches.
const heading = dialog.querySelector('.chapter h3')
expect(heading?.textContent?.trim()).toBe('Getting started')
handle.unmount()
})
test('clicking a TOC item swaps the right-pane content', async () => {
const baseline = snapshotDialogs()
const handle = mountComponent(HelpDialog, { modelValue: true })
await nextTick()
const dialog = dialogsAddedSince(baseline)[0]
const items = Array.from(dialog.querySelectorAll('.toc-item')) as HTMLButtonElement[]
const scripting = items.find((el) => el.textContent?.includes('Script language'))
expect(scripting).toBeTruthy()
scripting!.click()
await nextTick()
const heading = dialog.querySelector('.chapter h3')
expect(heading?.textContent?.trim()).toBe('Script language')
// Active highlight moved.
const active = dialog.querySelector('.toc-item.active')
expect(active?.textContent?.trim()).toBe('Script language')
handle.unmount()
})
test('Script language chapter documents every DSL command', async () => {
// Pin the parser/help alignment — if a new command ships in
// scriptDsl.ts, this test reminds us to document it here too.
const baseline = snapshotDialogs()
const handle = mountComponent(HelpDialog, { modelValue: true })
await nextTick()
const dialog = dialogsAddedSince(baseline)[0]
const items = Array.from(dialog.querySelectorAll('.toc-item')) as HTMLButtonElement[]
items.find((el) => el.textContent?.includes('Script language'))!.click()
await nextTick()
const body = dialog.querySelector('.chapter')!.textContent ?? ''
expect(body).toContain('play')
expect(body).toContain('pause')
expect(body).toContain('rewind')
expect(body).toContain('jump')
expect(body).toContain('#')
expect(body).toContain('@')
expect(body).toContain('//')
handle.unmount()
})
test('Shortcuts chapter renders one row per shortcut with a kbd key', async () => {
const baseline = snapshotDialogs()
const handle = mountComponent(HelpDialog, { modelValue: true })
await nextTick()
const dialog = dialogsAddedSince(baseline)[0]
const items = Array.from(dialog.querySelectorAll('.toc-item')) as HTMLButtonElement[]
items.find((el) => el.textContent?.includes('Keyboard shortcuts'))!.click()
await nextTick()
const rows = dialog.querySelectorAll('.shortcuts li')
expect(rows.length).toBeGreaterThan(9)
// Every row ends with a kbd element.
for (const li of Array.from(rows)) {
expect(li.querySelector('kbd')).toBeTruthy()
}
handle.unmount()
})
test('reopening resets to the first chapter (no stale selection)', async () => {
// Regression: a user who leaves the dialog on "Script language"
// and reopens it expects to land on the intro, not mid-content.
const open = ref(true)
const Wrapper = defineComponent({
render: () => h(HelpDialog, { modelValue: open.value }),
})
const handle = mountComponent(Wrapper, {})
await nextTick()
const items = Array.from(
document.querySelectorAll('.toc-item'),
) as HTMLButtonElement[]
items.find((el) => el.textContent?.includes('Script language'))!.click()
await nextTick()
open.value = false
await nextTick()
open.value = true
await nextTick()
await nextTick()
const heading = document.querySelector('.chapter h3')
expect(heading?.textContent?.trim()).toBe('Getting started')
handle.unmount()
})
})
suite('ui — IconButton', () => { suite('ui — IconButton', () => {
test('renders a button element', async () => { test('renders a button element', async () => {
// IconButton now uses the custom v-tooltip directive instead of // IconButton now uses the custom v-tooltip directive instead of
+28 -6
View File
@@ -1,10 +1,25 @@
import { ref } from 'vue' import { ref } from 'vue'
import { en, type Messages } from './messages' import { de, en } from './messages'
export type Locale = 'en' export type Locale = 'en' | 'de'
const activeLocale = ref<Locale>('en') const LOCALE_KEY = 'animatr:locale'
const messagesByLocale: Record<Locale, Messages> = { en } const messagesByLocale: Record<Locale, unknown> = { en, de }
function readInitialLocale(): Locale {
try {
const stored = localStorage.getItem(LOCALE_KEY)
if (stored === 'en' || stored === 'de') return stored
} catch {
/* noop */
}
if (typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('de')) {
return 'de'
}
return 'en'
}
const activeLocale = ref<Locale>(readInitialLocale())
function resolveAt(obj: unknown, path: string): unknown { function resolveAt(obj: unknown, path: string): unknown {
let cur = obj let cur = obj
@@ -28,11 +43,13 @@ function interpolate(template: string, params?: Record<string, string | number>)
export function t(key: string, params?: Record<string, string | number>): string { export function t(key: string, params?: Record<string, string | number>): string {
const raw = resolveAt(messagesByLocale[activeLocale.value], key) const raw = resolveAt(messagesByLocale[activeLocale.value], key)
if (typeof raw !== 'string') { if (typeof raw === 'string') return interpolate(raw, params)
const fallback = resolveAt(en, key)
if (typeof fallback !== 'string') {
if (import.meta.env.DEV) console.warn(`[i18n] Missing key: ${key}`) if (import.meta.env.DEV) console.warn(`[i18n] Missing key: ${key}`)
return key return key
} }
return interpolate(raw, params) return interpolate(fallback, params)
} }
export function useI18n() { export function useI18n() {
@@ -41,4 +58,9 @@ export function useI18n() {
export function setLocale(l: Locale): void { export function setLocale(l: Locale): void {
activeLocale.value = l activeLocale.value = l
try {
localStorage.setItem(LOCALE_KEY, l)
} catch {
/* noop */
}
} }
+1139 -1
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.8.5' export const APP_VERSION = '0.11.0'
export const APP_NAME = 'RUWIO' export const APP_NAME = 'RUWIO'