1.1.4: 2-column export + welcome rework with auto-load + archive

- Export dialog goes wide (xl modal) and splits into two columns:
  Project & code on the left (.ruw, runtime.js, animation.js,
  example.html), Media on the right with the per-export size + fps
  inputs followed by MP4 / WebM / GIF / APNG / APNG preview. Skip-
  hidden toggle still spans the full width up top. Single-column
  fallback below 720px.

- Welcome dialog reworked:
  - Always auto-loads the last edited project on boot. The welcome
    dialog opens on top only when the new "Show on start"
    preference is enabled, or unconditionally when there are no
    recents (first-run flow).
  - Two-column body: Recent (max 5 thumbnail tiles, was 8) on the
    left, Archive (compact list view, no thumbnails) on the right.
  - Footer toggle binds to the new useShowWelcomeOnStart shared
    ref so the user can opt out of the boot dialog inline.
- Document settings → Interface tab grows a matching "Show welcome
  screen on start" toggle so users who turned it off can flip it
  back on without diving into localStorage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-25 09:02:52 +02:00
1 parent 1f2812c64d
commit 3005d9310d
10 files changed
+652 -211

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ruwio", "name": "ruwio",
"private": true, "private": true,
"version": "1.1.3", "version": "1.1.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+20 -9
View File
@@ -45,11 +45,13 @@ import { ProjectFileError, pickProjectFile } from './lib/projectFile'
import { readAnyProjectFile } from './lib/ruwFile' import { readAnyProjectFile } from './lib/ruwFile'
import { import {
getLastOpenedId, getLastOpenedId,
getStoredProject,
listRecentProjects, listRecentProjects,
saveProjectToStore, saveProjectToStore,
setLastOpenedId, setLastOpenedId,
type StoredProject, type StoredProject,
} from './lib/projectStore' } from './lib/projectStore'
import { showWelcomeOnStart } from './composables/useShowWelcomeOnStart'
import { importImageFile } from './lib/fileLoader' import { importImageFile } from './lib/fileLoader'
import { sampleLayer } from './lib/interpolate' import { sampleLayer } from './lib/interpolate'
import { useClipartStore } from './stores/clipart' import { useClipartStore } from './stores/clipart'
@@ -270,18 +272,27 @@ function showSavedToast(msg: string) {
}, 2500) }, 2500)
} }
/** Boot policy: always restore the last edited project so the user
* picks up where they left off, regardless of the show-welcome
* preference. The welcome dialog still opens on top when the user
* has the flag enabled — picking a different project there just
* re-loads it through openFromRecent. With NO recents present,
* the dialog opens unconditionally so first-run users see the
* Import / New options. */
async function bootWelcome() { async function bootWelcome() {
const recents = await listRecentProjects() const lastId = getLastOpenedId()
if (recents.length > 0) { if (lastId) {
welcomeOpen.value = true const stored = await getStoredProject(lastId)
const lastId = getLastOpenedId() if (stored?.project) {
if (lastId) { await openFromRecent(stored)
const last = recents.find((p) => p.id === lastId)
if (last) {
currentProjectName.value = last.name
}
} }
} }
const recents = await listRecentProjects()
if (recents.length === 0) {
welcomeOpen.value = true
return
}
if (showWelcomeOnStart.value) welcomeOpen.value = true
} }
function isEditable(el: EventTarget | null): boolean { function isEditable(el: EventTarget | null): boolean {
+12
View File
@@ -11,6 +11,7 @@ import { useTheme } from '@/composables/useTheme'
import type { Theme } from '@/composables/useTheme' import type { Theme } from '@/composables/useTheme'
import { useTooltipPref } from '@/composables/useTooltipPref' import { useTooltipPref } from '@/composables/useTooltipPref'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref' import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { useShowWelcomeOnStart } from '@/composables/useShowWelcomeOnStart'
import { showLocked } from '@/composables/useShowLocked' import { showLocked } from '@/composables/useShowLocked'
import { Modal } from '@/components/ui' import { Modal } from '@/components/ui'
import { setLocale, t, useI18n } from '@/i18n' import { setLocale, t, useI18n } from '@/i18n'
@@ -23,6 +24,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 { enabled: welcomeOnStart } = useShowWelcomeOnStart()
const { locale } = useI18n() 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
@@ -175,6 +177,16 @@ function applyTheme(t: Theme) {
</div> </div>
</label> </label>
<label class="row-toggle">
<span class="switch" :class="{ on: welcomeOnStart }">
<input type="checkbox" v-model="welcomeOnStart" />
</span>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.welcomeOnStartLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.welcomeOnStartDesc') }}</div>
</div>
</label>
<div class="setting-block"> <div class="setting-block">
<div class="setting-heading">{{ t('settings.appearanceTitle') }}</div> <div class="setting-heading">{{ t('settings.appearanceTitle') }}</div>
<div class="setting-desc">{{ t('settings.themeHint') }}</div> <div class="setting-desc">{{ t('settings.themeHint') }}</div>
+196 -150
View File
@@ -381,7 +381,7 @@ function onUpdate(v: boolean) {
<Modal <Modal
:model-value="modelValue" :model-value="modelValue"
:title="t('export.title')" :title="t('export.title')"
size="md" size="xl"
:dismissable="!isLocked" :dismissable="!isLocked"
:close-on-backdrop="!isLocked" :close-on-backdrop="!isLocked"
:close-on-esc="!isLocked" :close-on-esc="!isLocked"
@@ -406,162 +406,172 @@ function onUpdate(v: boolean) {
</div> </div>
</label> </label>
<button class="opt" @click="exportRuwBundle"> <div class="export-cols">
<div class="opt-icon"><PhFileArchive :size="20" /></div> <section class="export-col">
<div class="opt-text"> <h4 class="col-heading">{{ t('export.colProject') }}</h4>
<div class="opt-title">
{{ t('export.ruwTitle') }}
<span class="badge accent">{{ t('export.ruwBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.ruwDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportRuntime"> <button class="opt" @click="exportRuwBundle">
<div class="opt-icon"><PhEngine :size="20" /></div> <div class="opt-icon"><PhFileArchive :size="20" /></div>
<div class="opt-text"> <div class="opt-text">
<div class="opt-title"> <div class="opt-title">
ruwio-runtime.js {{ t('export.ruwTitle') }}
<span class="badge">{{ t('export.runtimeBadge') }}</span> <span class="badge accent">{{ t('export.ruwBadge') }}</span>
</div> </div>
<div class="opt-desc">{{ t('export.runtimeDesc') }}</div> <div class="opt-desc">{{ t('export.ruwDesc') }}</div>
</div> </div>
<PhDownloadSimple :size="14" class="opt-arrow" /> <PhDownloadSimple :size="14" class="opt-arrow" />
</button> </button>
<button class="opt" @click="exportScene"> <button class="opt" @click="exportRuntime">
<div class="opt-icon"><PhCode :size="20" /></div> <div class="opt-icon"><PhEngine :size="20" /></div>
<div class="opt-text"> <div class="opt-text">
<div class="opt-title"> <div class="opt-title">
animation.js ruwio-runtime.js
<span class="badge">{{ t('export.sceneBadge') }}</span> <span class="badge">{{ t('export.runtimeBadge') }}</span>
</div> </div>
<div class="opt-desc">{{ t('export.sceneDesc') }}</div> <div class="opt-desc">{{ t('export.runtimeDesc') }}</div>
</div> </div>
<PhDownloadSimple :size="14" class="opt-arrow" /> <PhDownloadSimple :size="14" class="opt-arrow" />
</button> </button>
<button class="opt" @click="exportHtml"> <button class="opt" @click="exportScene">
<div class="opt-icon"><PhFileHtml :size="20" /></div> <div class="opt-icon"><PhCode :size="20" /></div>
<div class="opt-text"> <div class="opt-text">
<div class="opt-title">{{ t('export.exampleTitle') }}</div> <div class="opt-title">
<div class="opt-desc">{{ t('export.exampleDesc') }}</div> animation.js
</div> <span class="badge">{{ t('export.sceneBadge') }}</span>
<PhDownloadSimple :size="14" class="opt-arrow" /> </div>
</button> <div class="opt-desc">{{ t('export.sceneDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportMp4Server"> <button class="opt" @click="exportHtml">
<div class="opt-icon"><PhFilmSlate :size="20" /></div> <div class="opt-icon"><PhFileHtml :size="20" /></div>
<div class="opt-text"> <div class="opt-text">
<div class="opt-title"> <div class="opt-title">{{ t('export.exampleTitle') }}</div>
MP4 Video <div class="opt-desc">{{ t('export.exampleDesc') }}</div>
<span class="badge accent">{{ t('export.mp4Badge') }}</span> </div>
</div> <PhDownloadSimple :size="14" class="opt-arrow" />
<div class="opt-desc">{{ t('export.mp4Desc') }}</div> </button>
</div> </section>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" :disabled="!videoSupported" @click="exportWebm"> <section class="export-col">
<div class="opt-icon"><PhFilmSlate :size="20" /></div> <h4 class="col-heading">{{ t('export.colMedia') }}</h4>
<div class="opt-text">
<div class="opt-title">
WebM Video
<span class="badge">{{ t('export.webmBadge') }}</span>
</div>
<div class="opt-desc">
{{ videoSupported ? t('export.webmDesc') : t('export.webmNotSupported') }}
</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<div class="target-size"> <div class="target-size">
<div class="target-size-label">{{ t('export.targetSize') }}</div> <div class="target-size-label">{{ t('export.targetSize') }}</div>
<label class="target-size-input"> <label class="target-size-input">
<span class="target-size-tag">W</span> <span class="target-size-tag">W</span>
<input <input
type="number" type="number"
min="1" min="1"
step="1" step="1"
:value="targetWidth" :value="targetWidth"
@input="targetWidth = Math.max(1, Math.round(Number(($event.target as HTMLInputElement).value) || 1))" @input="targetWidth = Math.max(1, Math.round(Number(($event.target as HTMLInputElement).value) || 1))"
/> />
</label> </label>
<span class="target-size-times">×</span> <span class="target-size-times">×</span>
<div class="target-size-derived"> <div class="target-size-derived">
{{ targetHeight }} {{ targetHeight }}
<span class="target-size-tag">H</span> <span class="target-size-tag">H</span>
</div> </div>
<button <button
v-if="targetSizeChanged" v-if="targetSizeChanged"
class="target-size-reset" class="target-size-reset"
v-tooltip="t('export.targetSizeReset')" v-tooltip="t('export.targetSizeReset')"
@click="resetTargetSize" @click="resetTargetSize"
> >
{{ t('export.targetSizeResetLabel') }} {{ t('export.targetSizeResetLabel') }}
</button> </button>
</div>
<div class="target-size">
<div class="target-size-label">{{ t('export.targetFps') }}</div>
<label class="target-size-input">
<input
type="number"
min="1"
step="1"
:value="targetFps"
@input="targetFps = Math.max(1, Math.round(Number(($event.target as HTMLInputElement).value) || 1))"
/>
<span class="target-size-tag">fps</span>
</label>
<span class="target-size-hint">{{ t('export.targetFpsHint') }}</span>
<button
v-if="targetFpsChanged"
class="target-size-reset"
v-tooltip="t('export.targetFpsReset')"
@click="resetTargetFps"
>
{{ t('export.targetSizeResetLabel') }}
</button>
</div>
<button class="opt" @click="exportMp4Server">
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
MP4 Video
<span class="badge accent">{{ t('export.mp4Badge') }}</span>
</div>
<div class="opt-desc">{{ t('export.mp4Desc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" :disabled="!videoSupported" @click="exportWebm">
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
WebM Video
<span class="badge">{{ t('export.webmBadge') }}</span>
</div>
<div class="opt-desc">
{{ videoSupported ? t('export.webmDesc') : t('export.webmNotSupported') }}
</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportGif">
<div class="opt-icon"><PhGif :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
Animated GIF
<span class="badge">{{ t('export.gifBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.gifDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportApng">
<div class="opt-icon"><PhFileImage :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
Animated PNG
<span class="badge">{{ t('export.apngBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.apngDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportApngPreview">
<div class="opt-icon"><PhFileHtml :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
{{ t('export.apngPreviewTitle') }}
<span class="badge">{{ t('export.apngPreviewBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.apngPreviewDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
</section>
</div> </div>
<div class="target-size">
<div class="target-size-label">{{ t('export.targetFps') }}</div>
<label class="target-size-input">
<input
type="number"
min="1"
step="1"
:value="targetFps"
@input="targetFps = Math.max(1, Math.round(Number(($event.target as HTMLInputElement).value) || 1))"
/>
<span class="target-size-tag">fps</span>
</label>
<span class="target-size-hint">{{ t('export.targetFpsHint') }}</span>
<button
v-if="targetFpsChanged"
class="target-size-reset"
v-tooltip="t('export.targetFpsReset')"
@click="resetTargetFps"
>
{{ t('export.targetSizeResetLabel') }}
</button>
</div>
<button class="opt" @click="exportGif">
<div class="opt-icon"><PhGif :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
Animated GIF
<span class="badge">{{ t('export.gifBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.gifDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportApng">
<div class="opt-icon"><PhFileImage :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
Animated PNG
<span class="badge">{{ t('export.apngBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.apngDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportApngPreview">
<div class="opt-icon"><PhFileHtml :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
{{ t('export.apngPreviewTitle') }}
<span class="badge">{{ t('export.apngPreviewBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.apngPreviewDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
</div> </div>
<div v-else-if="!progress.error" class="rendering"> <div v-else-if="!progress.error" class="rendering">
@@ -596,6 +606,42 @@ function onUpdate(v: boolean) {
padding: 12px; padding: 12px;
} }
/* Two-column layout. The skip-hidden toggle still spans the full
width above; project / data exports live left, media exports
(with the per-export size + fps inputs) live right. Each column
stacks vertically and gaps follow the same 6px rhythm as the
single-column flow used to. */
.export-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
align-items: start;
}
.export-col {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.col-heading {
margin: 0 0 4px;
font-size: 11px;
font-weight: 500;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.06em;
}
@media (max-width: 720px) {
/* Stage tight viewports: collapse to a single column so the
options stay readable instead of cramming. */
.export-cols {
grid-template-columns: 1fr;
}
}
.export-toggle { .export-toggle {
display: flex; display: flex;
align-items: center; align-items: center;
+253 -42
View File
@@ -22,6 +22,7 @@ import {
readBackupFile, readBackupFile,
} from '@/lib/backupFile' } from '@/lib/backupFile'
import { confirmDialog, Modal } from '@/components/ui' import { confirmDialog, Modal } from '@/components/ui'
import { showWelcomeOnStart } from '@/composables/useShowWelcomeOnStart'
import { t } from '@/i18n' import { t } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>() const props = defineProps<{ modelValue: boolean }>()
@@ -33,14 +34,25 @@ const emit = defineEmits<{
}>() }>()
const recents = ref<StoredProject[]>([]) const recents = ref<StoredProject[]>([])
// Archive = the rest of the IDB store beyond the recent cap.
// Rendered as a flat list (no thumbnails) so the user can find old
// projects without scrolling through tile previews.
const archive = ref<StoredProject[]>([])
const loading = ref(false) const loading = ref(false)
const recentRefs = ref<HTMLElement[]>([]) const recentRefs = ref<HTMLElement[]>([])
const archiveRefs = ref<HTMLElement[]>([])
const newBtnRef = ref<HTMLButtonElement | null>(null) const newBtnRef = ref<HTMLButtonElement | null>(null)
async function reload() { async function reload() {
loading.value = true loading.value = true
try { try {
recents.value = await listRecentProjects() const [topRecent, all] = await Promise.all([
listRecentProjects(),
listAllProjects(),
])
recents.value = topRecent
const recentIds = new Set(topRecent.map((p) => p.id))
archive.value = all.filter((p) => !recentIds.has(p.id))
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -261,7 +273,7 @@ watch(
<template> <template>
<Modal <Modal
:model-value="modelValue" :model-value="modelValue"
size="md" size="lg"
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<template #header> <template #header>
@@ -271,49 +283,95 @@ watch(
</div> </div>
</template> </template>
<div class="body"> <div class="body">
<div v-if="loading" class="empty">{{ t('welcome.loading') }}</div> <div v-if="loading" class="empty">{{ t('welcome.loading') }}</div>
<div v-else-if="!recents.length" class="empty"> <div v-else-if="!recents.length && !archive.length" class="empty">
{{ t('welcome.empty') }} {{ t('welcome.empty') }}
</div> </div>
<ul v-else class="recents"> <div v-else class="cols">
<li <section class="col">
v-for="(p, i) in recents" <h4 class="col-heading">{{ t('welcome.recentHeading') }}</h4>
:key="p.id" <p v-if="!recents.length" class="col-empty">{{ t('welcome.noRecents') }}</p>
:ref=" <ul v-else class="recents">
(el) => { <li
if (el) recentRefs[i] = el as HTMLElement v-for="(p, i) in recents"
} :key="p.id"
" :ref="
class="recent" (el) => {
tabindex="0" if (el) recentRefs[i] = el as HTMLElement
@click="openProject(p)" }
@keydown.enter.prevent="openProject(p)" "
@keydown.space.prevent="openProject(p)" class="recent"
@keydown="onRecentKeyDown($event, i)" tabindex="0"
@click="openProject(p)"
@keydown.enter.prevent="openProject(p)"
@keydown.space.prevent="openProject(p)"
@keydown="onRecentKeyDown($event, i)"
>
<div class="thumb">
<img v-if="p.thumbnail" :src="p.thumbnail" alt="" />
<PhFolderOpen v-else :size="18" />
</div>
<div class="meta">
<div class="name">{{ p.name }}</div>
<div class="sub-info">
{{ t('welcome.layerCount', { n: layerCount(p) }) }} · {{ formatDate(p.savedAt) }}
</div>
</div>
<button
class="del"
tabindex="-1"
v-tooltip="t('welcome.deleteTooltip')"
@click="removeProject($event, p)"
> >
<div class="thumb"> <PhTrash :size="13" />
<img v-if="p.thumbnail" :src="p.thumbnail" alt="" /> </button>
<PhFolderOpen v-else :size="18" /> </li>
</div> </ul>
<div class="meta"> </section>
<div class="name">{{ p.name }}</div>
<div class="sub-info"> <section class="col">
{{ t('welcome.layerCount', { n: layerCount(p) }) }} · {{ formatDate(p.savedAt) }} <h4 class="col-heading">{{ t('welcome.archiveHeading') }}</h4>
</div> <p v-if="!archive.length" class="col-empty">{{ t('welcome.noArchive') }}</p>
</div> <ul v-else class="archive">
<button <li
class="del" v-for="(p, i) in archive"
tabindex="-1" :key="p.id"
v-tooltip="t('welcome.deleteTooltip')" :ref="
@click="removeProject($event, p)" (el) => {
> if (el) archiveRefs[i] = el as HTMLElement
<PhTrash :size="13" /> }
</button> "
</li> class="archive-row"
</ul> tabindex="0"
v-tooltip="formatDate(p.savedAt)"
@click="openProject(p)"
@keydown.enter.prevent="openProject(p)"
@keydown.space.prevent="openProject(p)"
>
<span class="archive-name">{{ p.name }}</span>
<span class="archive-date">{{ formatDate(p.savedAt) }}</span>
<button
class="del"
tabindex="-1"
v-tooltip="t('welcome.deleteTooltip')"
@click="removeProject($event, p)"
>
<PhTrash :size="13" />
</button>
</li>
</ul>
</section>
</div>
</div> </div>
<div v-if="backupError" class="backup-error">{{ backupError }}</div> <div v-if="backupError" class="backup-error">{{ backupError }}</div>
<template #footer> <template #footer>
<label class="ftr-toggle" v-tooltip="t('welcome.showOnStartHelp')">
<span class="switch sm" :class="{ on: showWelcomeOnStart }">
<input type="checkbox" v-model="showWelcomeOnStart" />
</span>
<span class="ftr-toggle-label">{{ t('welcome.showOnStart') }}</span>
</label>
<span class="ftr-sep"></span>
<button ref="newBtnRef" class="ftr-btn" @click="startFresh"> <button ref="newBtnRef" class="ftr-btn" @click="startFresh">
<PhPlus :size="13" weight="bold" /> <PhPlus :size="13" weight="bold" />
{{ t('welcome.newProject') }} {{ t('welcome.newProject') }}
@@ -322,7 +380,6 @@ watch(
<PhFileArrowUp :size="13" /> <PhFileArrowUp :size="13" />
{{ t('welcome.importFile') }} {{ t('welcome.importFile') }}
</button> </button>
<span class="ftr-sep"></span>
<button <button
class="ftr-btn" class="ftr-btn"
:disabled="backupBusy" :disabled="backupBusy"
@@ -384,6 +441,104 @@ watch(
font-style: italic; font-style: italic;
} }
/* Two-column layout: recent (with thumbs) on the left, archive
(list view, no thumbs) on the right. Collapses to single column
on narrow viewports so the dialog stays usable. */
.cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
align-items: start;
}
.col {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.col-heading {
margin: 0 0 4px;
font-size: 11px;
font-weight: 500;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.col-empty {
padding: 16px 4px;
color: var(--text-dim);
font-size: 12px;
font-style: italic;
}
@media (max-width: 720px) {
.cols {
grid-template-columns: 1fr;
}
}
.archive {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1px;
max-height: 320px;
overflow-y: auto;
}
.archive-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 12px;
}
.archive-row:hover {
background: var(--panel-hover);
border-color: var(--border);
}
.archive-row:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.archive-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
}
.archive-date {
flex-shrink: 0;
color: var(--text-dim);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.archive-row .del {
opacity: 0;
}
.archive-row:hover .del,
.archive-row:focus-visible .del {
opacity: 1;
}
.recents { .recents {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -520,4 +675,60 @@ watch(
color: var(--danger); color: var(--danger);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
/* Compact footer toggle — same visual language as the other toggles
in the app but sized for the row. */
.ftr-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-dim);
cursor: pointer;
user-select: none;
}
.ftr-toggle-label {
white-space: nowrap;
}
.switch.sm {
position: relative;
display: inline-block;
width: 28px;
height: 16px;
background: var(--border);
border-radius: 999px;
flex-shrink: 0;
transition: background 140ms var(--ease);
}
.switch.sm input {
opacity: 0;
width: 100%;
height: 100%;
margin: 0;
cursor: pointer;
}
.switch.sm::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: transform 140ms var(--ease);
}
.switch.sm.on {
background: var(--accent);
}
.switch.sm.on::after {
transform: translateX(12px);
}
</style> </style>
+31
View File
@@ -0,0 +1,31 @@
import { ref, watch } from 'vue'
const STORAGE_KEY = 'animatr.showWelcomeOnStart'
function read(): boolean {
try {
const v = localStorage.getItem(STORAGE_KEY)
// Default true — first-run users still see the welcome dialog.
// Stored values are 'true' / 'false'.
return v !== 'false'
} catch {
return true
}
}
// Module-level shared ref so App.vue (boot decision), the welcome
// dialog (small toggle inside the modal) and the document settings
// dialog (Interface tab) all read + write the same flag.
export const showWelcomeOnStart = ref<boolean>(read())
watch(showWelcomeOnStart, (v) => {
try {
localStorage.setItem(STORAGE_KEY, String(v))
} catch {
/* ignore */
}
})
export function useShowWelcomeOnStart() {
return { enabled: showWelcomeOnStart }
}
+123 -7
View File
@@ -1461,6 +1461,34 @@ export function registerAllTests(): void {
return null return null
} }
test('renders two columns with the project + media headings', async () => {
makeFreshStore()
const baseline = snapshotDialogs()
const handle = mountComponent(ExportDialog, { modelValue: true })
try {
await nextTick()
const dialog = dialogsAddedSince(baseline)[0]
const cols = dialog.querySelectorAll('.export-col')
expect(cols.length).toBe(2)
const headings = Array.from(dialog.querySelectorAll('.col-heading')).map(
(el) => el.textContent?.trim() ?? '',
)
expect(headings).toContain(i18nT('export.colProject'))
expect(headings).toContain(i18nT('export.colMedia'))
// Media-only controls (target-size + fps) live in the
// media column, not the project column.
const mediaCol = Array.from(cols).find((c) =>
c.querySelector('.col-heading')?.textContent?.includes(
i18nT('export.colMedia'),
),
)
expect(mediaCol).toBeTruthy()
expect(mediaCol!.querySelector('.target-size')).toBeTruthy()
} finally {
handle.unmount()
}
})
test('renders a GIF, APNG, and APNG preview option row', async () => { test('renders a GIF, APNG, and APNG preview option row', async () => {
makeFreshStore() makeFreshStore()
const baseline = snapshotDialogs() const baseline = snapshotDialogs()
@@ -2939,8 +2967,8 @@ export function registerAllTests(): void {
expect(back).toBeNull() expect(back).toBeNull()
}) })
test('listAllProjects returns every record — no 8-recents cap', async () => { test('listAllProjects returns every record — no recents cap', async () => {
// Backup flow needs the full dump, not just the top 8 that // Backup flow needs the full dump, not just the top N that
// the welcome dialog shows. // the welcome dialog shows.
const { listAllProjects } = await import('@/lib/projectStore') const { listAllProjects } = await import('@/lib/projectStore')
const s = makeFreshStore() const s = makeFreshStore()
@@ -2983,6 +3011,74 @@ export function registerAllTests(): void {
} }
} }
test('renders both Recent and Archive columns; archive shows projects past the 8-item recents cap', async () => {
const s = makeFreshStore()
const prefix = `test-welcome-cols-${Date.now()}-`
const ids: string[] = []
// Need MORE than MAX_RECENT (5) so the spillover lands in the
// archive column. Save 10 to be safe.
for (let i = 0; i < 10; i++) {
const id = `${prefix}${i}`
await saveProjectToStore(s.project, { id, name: `Proj ${i}` })
await tick(2)
ids.push(id)
}
const baseline = snapshotDialogs()
const handle = mountComponent(WelcomeDialog, { modelValue: true })
try {
await waitUntil(() => {
const d = dialogsAddedSince(baseline)[0]
return !!d && d.querySelectorAll('.archive-row').length >= 1
})
const dialog = dialogsAddedSince(baseline)[0]
// Two columns rendered with the new headings.
const cols = dialog.querySelectorAll('.col')
expect(cols.length).toBe(2)
// Recent column gets the .recent thumb tiles, archive column
// gets the bare list rows — at least one of each from our
// seeded set.
expect(dialog.querySelectorAll('.recent').length).toBeGreaterThan(0)
expect(dialog.querySelectorAll('.archive-row').length).toBeGreaterThan(0)
} finally {
for (const id of ids) await deleteStoredProject(id).catch(() => {})
handle.unmount()
}
})
test('footer "show on start" toggle flips the persisted preference', async () => {
const baseline = snapshotDialogs()
const handle = mountComponent(WelcomeDialog, { modelValue: true })
try {
await waitUntil(
() => !!dialogsAddedSince(baseline)[0]?.querySelector('.ftr-toggle'),
)
const dialog = dialogsAddedSince(baseline)[0]
const checkbox = dialog.querySelector(
'.ftr-toggle input[type="checkbox"]',
) as HTMLInputElement
expect(checkbox).toBeTruthy()
const before = checkbox.checked
const { useShowWelcomeOnStart } = await import(
'@/composables/useShowWelcomeOnStart'
)
const pref = useShowWelcomeOnStart()
// Drive a change through the input and verify the shared
// ref + localStorage flip.
checkbox.checked = !before
checkbox.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
expect(pref.enabled.value).toBe(!before)
expect(localStorage.getItem('animatr.showWelcomeOnStart')).toBe(
String(!before),
)
// Restore so other tests aren't affected.
pref.enabled.value = before
await nextTick()
} finally {
handle.unmount()
}
})
test('ArrowDown / ArrowUp move focus through the recents list', async () => { test('ArrowDown / ArrowUp move focus through the recents list', async () => {
// Seed two recents via the real IDB path so the dialog's own // Seed two recents via the real IDB path so the dialog's own
// `listRecentProjects` call finds them. // `listRecentProjects` call finds them.
@@ -3246,11 +3342,12 @@ export function registerAllTests(): void {
) as HTMLButtonElement ) as HTMLButtonElement
expect(confirmBtn).toBeTruthy() expect(confirmBtn).toBeTruthy()
confirmBtn.click() confirmBtn.click()
// Assert by seed name rather than count — MAX_RECENT=8 caps the // Assert by seed name rather than count — MAX_RECENT caps the
// visible list, so deleting the top seed may just unmask a // visible recents list, so deleting the top seed may just
// hidden record instead of dropping the total. Also wait for // unmask a hidden record instead of dropping the total. Also
// the list to rerender: reload() briefly flips loading=true, // wait for the list to rerender: reload() briefly flips
// which hides .recents behind the "loading…" placeholder. // loading=true, which hides .recents behind the "loading…"
// placeholder.
await waitUntil( await waitUntil(
() => () =>
!welcome.textContent?.includes('DropMe') && !welcome.textContent?.includes('DropMe') &&
@@ -5895,6 +5992,25 @@ export function registerAllTests(): void {
a.enabled.value = initial a.enabled.value = initial
await nextTick() await nextTick()
}) })
test('useShowWelcomeOnStart defaults true and persists toggles', async () => {
const { useShowWelcomeOnStart } = await import(
'@/composables/useShowWelcomeOnStart'
)
const a = useShowWelcomeOnStart()
const b = useShowWelcomeOnStart()
expect(a.enabled).toBe(b.enabled)
// Default is true (first-run users see the welcome screen).
a.enabled.value = true
await nextTick()
a.enabled.value = false
await nextTick()
expect(localStorage.getItem('animatr.showWelcomeOnStart')).toBe('false')
expect(b.enabled.value).toBe(false)
a.enabled.value = true
await nextTick()
expect(localStorage.getItem('animatr.showWelcomeOnStart')).toBe('true')
})
}) })
suite('clipart drawer — grid pre-layout', () => { suite('clipart drawer — grid pre-layout', () => {
+11
View File
@@ -58,6 +58,12 @@ export const en = {
message: '"{name}" will be permanently removed from browser storage.', message: '"{name}" will be permanently removed from browser storage.',
confirm: 'Delete', confirm: 'Delete',
}, },
recentHeading: 'Recent',
archiveHeading: 'Archive',
noRecents: 'No recent projects yet.',
noArchive: 'Older projects appear here once your recents fill up.',
showOnStart: 'Show on start',
showOnStartHelp: 'Open this dialog when RUWIO launches. Off → app boots straight into the last edited project.',
backupAll: 'Backup all…', backupAll: 'Backup all…',
backupAllHelp: 'Save every project in browser storage into a single .ruwbak file.', backupAllHelp: 'Save every project in browser storage into a single .ruwbak file.',
backupEmpty: 'No saved projects yet — nothing to back up.', backupEmpty: 'No saved projects yet — nothing to back up.',
@@ -382,6 +388,9 @@ export const en = {
assetThumbsLabel: 'Asset thumbnails', assetThumbsLabel: 'Asset thumbnails',
assetThumbsDesc: assetThumbsDesc:
'Render small previews in the Assets / Clips / Buttons panels. Turn off for a compact list when working with lots of items.', 'Render small previews in the Assets / Clips / Buttons panels. Turn off for a compact list when working with lots of items.',
welcomeOnStartLabel: 'Show welcome screen on start',
welcomeOnStartDesc:
'Open the welcome / recents dialog when RUWIO launches. Off → boot straight into the last edited project. The last file is always restored either way; the welcome dialog just gives you a chance to switch.',
bgVideoLabel: 'Video', bgVideoLabel: 'Video',
bgVideoNone: 'No background video', bgVideoNone: 'No background video',
bgVideoPickFile: 'Pick video file…', bgVideoPickFile: 'Pick video file…',
@@ -470,6 +479,8 @@ export const en = {
ruwTitle: 'RUWIO project (.ruw)', ruwTitle: 'RUWIO project (.ruw)',
ruwBadge: 'Reimportable', ruwBadge: 'Reimportable',
ruwDesc: 'Zipped bundle — project JSON + every image / audio asset as a separate file. Drop it onto the welcome screen to reopen.', ruwDesc: 'Zipped bundle — project JSON + every image / audio asset as a separate file. Drop it onto the welcome screen to reopen.',
colProject: 'Project & code',
colMedia: 'Media',
}, },
tests: { tests: {
+4 -1
View File
@@ -4,7 +4,10 @@ import { projectFromLegacyScene } from '@/stores/scene'
const DB_NAME = 'animatr' const DB_NAME = 'animatr'
const DB_VERSION = 1 const DB_VERSION = 1
const STORE = 'projects' const STORE = 'projects'
const MAX_RECENT = 8 // Recent column on the welcome dialog caps at this many tiles —
// older projects spill into the archive column (list view, no
// thumbnail). Keep small so the grid stays scan-friendly.
const MAX_RECENT = 5
export interface StoredProject { export interface StoredProject {
id: string id: string
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.1.3' export const APP_VERSION = '1.1.4'
export const APP_NAME = 'RUWIO' export const APP_NAME = 'RUWIO'