diff --git a/package.json b/package.json
index 596ed7b..4a3eff2 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
- "version": "1.1.3",
+ "version": "1.1.4",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/App.vue b/src/App.vue
index 5f0db0e..ed9613f 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -45,11 +45,13 @@ import { ProjectFileError, pickProjectFile } from './lib/projectFile'
import { readAnyProjectFile } from './lib/ruwFile'
import {
getLastOpenedId,
+ getStoredProject,
listRecentProjects,
saveProjectToStore,
setLastOpenedId,
type StoredProject,
} from './lib/projectStore'
+import { showWelcomeOnStart } from './composables/useShowWelcomeOnStart'
import { importImageFile } from './lib/fileLoader'
import { sampleLayer } from './lib/interpolate'
import { useClipartStore } from './stores/clipart'
@@ -270,18 +272,27 @@ function showSavedToast(msg: string) {
}, 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() {
- const recents = await listRecentProjects()
- if (recents.length > 0) {
- welcomeOpen.value = true
- const lastId = getLastOpenedId()
- if (lastId) {
- const last = recents.find((p) => p.id === lastId)
- if (last) {
- currentProjectName.value = last.name
- }
+ const lastId = getLastOpenedId()
+ if (lastId) {
+ const stored = await getStoredProject(lastId)
+ if (stored?.project) {
+ await openFromRecent(stored)
}
}
+ const recents = await listRecentProjects()
+ if (recents.length === 0) {
+ welcomeOpen.value = true
+ return
+ }
+ if (showWelcomeOnStart.value) welcomeOpen.value = true
}
function isEditable(el: EventTarget | null): boolean {
diff --git a/src/components/DocumentSettings.vue b/src/components/DocumentSettings.vue
index 6b0ecb1..6b1eba3 100644
--- a/src/components/DocumentSettings.vue
+++ b/src/components/DocumentSettings.vue
@@ -11,6 +11,7 @@ import { useTheme } from '@/composables/useTheme'
import type { Theme } from '@/composables/useTheme'
import { useTooltipPref } from '@/composables/useTooltipPref'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
+import { useShowWelcomeOnStart } from '@/composables/useShowWelcomeOnStart'
import { showLocked } from '@/composables/useShowLocked'
import { Modal } from '@/components/ui'
import { setLocale, t, useI18n } from '@/i18n'
@@ -23,6 +24,7 @@ const clipart = useClipartStore()
const { theme, setTheme } = useTheme()
const { enabled: tooltipsEnabled } = useTooltipPref()
const { enabled: thumbsEnabled } = useAssetThumbsPref()
+const { enabled: welcomeOnStart } = useShowWelcomeOnStart()
const { locale } = useI18n()
// Re-check the stored clipart folder whenever settings opens — covers the
@@ -175,6 +177,16 @@ function applyTheme(t: Theme) {
+
+
{{ t('settings.appearanceTitle') }}
{{ t('settings.themeHint') }}
diff --git a/src/components/ExportDialog.vue b/src/components/ExportDialog.vue
index fffc157..3fd5f49 100644
--- a/src/components/ExportDialog.vue
+++ b/src/components/ExportDialog.vue
@@ -381,7 +381,7 @@ function onUpdate(v: boolean) {
-
+
+
+ {{ t('export.colProject') }}
-
+
-
+
-
+
-
+
+
-
+
+ {{ t('export.colMedia') }}
-
-
{{ t('export.targetSize') }}
-
-
×
-
- {{ targetHeight }}
- H
-
-
+
+
{{ t('export.targetSize') }}
+
+
×
+
+ {{ targetHeight }}
+ H
+
+
+
+
+
+
{{ t('export.targetFps') }}
+
+
{{ t('export.targetFpsHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
{{ t('export.targetFps') }}
-
-
{{ t('export.targetFpsHint') }}
-
-
-
-
-
-
-
-
@@ -596,6 +606,42 @@ function onUpdate(v: boolean) {
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 {
display: flex;
align-items: center;
diff --git a/src/components/WelcomeDialog.vue b/src/components/WelcomeDialog.vue
index 301351d..6a1a19c 100644
--- a/src/components/WelcomeDialog.vue
+++ b/src/components/WelcomeDialog.vue
@@ -22,6 +22,7 @@ import {
readBackupFile,
} from '@/lib/backupFile'
import { confirmDialog, Modal } from '@/components/ui'
+import { showWelcomeOnStart } from '@/composables/useShowWelcomeOnStart'
import { t } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>()
@@ -33,14 +34,25 @@ const emit = defineEmits<{
}>()
const recents = ref([])
+// 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([])
const loading = ref(false)
const recentRefs = ref([])
+const archiveRefs = ref([])
const newBtnRef = ref(null)
async function reload() {
loading.value = true
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 {
loading.value = false
}
@@ -261,7 +273,7 @@ watch(
@@ -271,49 +283,95 @@ watch(
-
{{ t('welcome.loading') }}
-
- {{ t('welcome.empty') }}
-
-
- - {{ t('welcome.loading') }}
+
+ {{ t('welcome.empty') }}
+
+
+
+ {{ t('welcome.recentHeading') }}
+ {{ t('welcome.noRecents') }}
+
+ -
+
+
![]()
+
+
+
+
-
+
+
+
+
+
+
+
+ {{ t('welcome.archiveHeading') }}
+ {{ t('welcome.noArchive') }}
+
+ -
+ {{ p.name }}
+ {{ formatDate(p.savedAt) }}
+
+
+
+
+
+
+
{{ backupError }}
+
+
{{ t('welcome.newProject') }}
@@ -322,7 +380,6 @@ watch(
{{ t('welcome.importFile') }}
-
diff --git a/src/composables/useShowWelcomeOnStart.ts b/src/composables/useShowWelcomeOnStart.ts
new file mode 100644
index 0000000..1675d2a
--- /dev/null
+++ b/src/composables/useShowWelcomeOnStart.ts
@@ -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(read())
+
+watch(showWelcomeOnStart, (v) => {
+ try {
+ localStorage.setItem(STORAGE_KEY, String(v))
+ } catch {
+ /* ignore */
+ }
+})
+
+export function useShowWelcomeOnStart() {
+ return { enabled: showWelcomeOnStart }
+}
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index fd57f2d..2eb7429 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -1461,6 +1461,34 @@ export function registerAllTests(): void {
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 () => {
makeFreshStore()
const baseline = snapshotDialogs()
@@ -2939,8 +2967,8 @@ export function registerAllTests(): void {
expect(back).toBeNull()
})
- test('listAllProjects returns every record — no 8-recents cap', async () => {
- // Backup flow needs the full dump, not just the top 8 that
+ test('listAllProjects returns every record — no recents cap', async () => {
+ // Backup flow needs the full dump, not just the top N that
// the welcome dialog shows.
const { listAllProjects } = await import('@/lib/projectStore')
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 () => {
// Seed two recents via the real IDB path so the dialog's own
// `listRecentProjects` call finds them.
@@ -3246,11 +3342,12 @@ export function registerAllTests(): void {
) as HTMLButtonElement
expect(confirmBtn).toBeTruthy()
confirmBtn.click()
- // Assert by seed name rather than count — MAX_RECENT=8 caps the
- // visible list, so deleting the top seed may just unmask a
- // hidden record instead of dropping the total. Also wait for
- // the list to rerender: reload() briefly flips loading=true,
- // which hides .recents behind the "loading…" placeholder.
+ // Assert by seed name rather than count — MAX_RECENT caps the
+ // visible recents list, so deleting the top seed may just
+ // unmask a hidden record instead of dropping the total. Also
+ // wait for the list to rerender: reload() briefly flips
+ // loading=true, which hides .recents behind the "loading…"
+ // placeholder.
await waitUntil(
() =>
!welcome.textContent?.includes('DropMe') &&
@@ -5895,6 +5992,25 @@ export function registerAllTests(): void {
a.enabled.value = initial
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', () => {
diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts
index 4fdb0dd..af2c2aa 100644
--- a/src/i18n/messages.ts
+++ b/src/i18n/messages.ts
@@ -58,6 +58,12 @@ export const en = {
message: '"{name}" will be permanently removed from browser storage.',
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…',
backupAllHelp: 'Save every project in browser storage into a single .ruwbak file.',
backupEmpty: 'No saved projects yet — nothing to back up.',
@@ -382,6 +388,9 @@ export const en = {
assetThumbsLabel: 'Asset thumbnails',
assetThumbsDesc:
'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',
bgVideoNone: 'No background video',
bgVideoPickFile: 'Pick video file…',
@@ -470,6 +479,8 @@ export const en = {
ruwTitle: 'RUWIO project (.ruw)',
ruwBadge: 'Reimportable',
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: {
diff --git a/src/lib/projectStore.ts b/src/lib/projectStore.ts
index 8293049..3c88337 100644
--- a/src/lib/projectStore.ts
+++ b/src/lib/projectStore.ts
@@ -4,7 +4,10 @@ import { projectFromLegacyScene } from '@/stores/scene'
const DB_NAME = 'animatr'
const DB_VERSION = 1
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 {
id: string
diff --git a/src/version.ts b/src/version.ts
index 0e2e81f..bfae65f 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,2 +1,2 @@
-export const APP_VERSION = '1.1.3'
+export const APP_VERSION = '1.1.4'
export const APP_NAME = 'RUWIO'