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

+123 -7
View File
@@ -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', () => {