From aa3fe2062dc0fbc8df499c75a81693f144b8f51b Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 21:41:34 +0100 Subject: [PATCH] feat(ui-lab): add first-run and update scenarios Add separate UI Lab scenarios for true first-run onboarding and post-update What's New checks. Keep empty-library fixtures reusable across mock data, tagger readiness, worker pause defaults, and launch-time version state so browser verification can exercise the real flows. --- docs/ui-lab.md | 7 ++++++- src/dev/mockBackend.ts | 23 ++++++++++++++++------- src/dev/mockFixtures.ts | 10 +++++----- src/dev/mockScenarios.ts | 11 +++++++++++ 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/docs/ui-lab.md b/docs/ui-lab.md index 8b8a8f6..df08170 100644 --- a/docs/ui-lab.md +++ b/docs/ui-lab.md @@ -40,7 +40,9 @@ UI Lab reads `?scenario=` from the URL. If no scenario is provided, it uses | URL | Purpose | | --- | --- | | `/?scenario=rich` | Default realistic library with folders, albums, ratings, favorites, tags, images, and videos | -| `/?scenario=empty` | First-run state with no folders or media | +| `/?scenario=empty` | Empty library with no folders or media (onboarding already completed) | +| `/?scenario=new-user` | True first run: onboarding tour open, empty library, no tagger model downloaded | +| `/?scenario=just-updated` | Rich library that has just been updated — the "What's new" toast fires on launch | | `/?scenario=busy` | Background workers with pending thumbnail, metadata, embedding, caption, and tagging jobs | | `/?scenario=duplicates` | Duplicate Finder opened with duplicate groups already available | | `/?scenario=album` | Gallery opened directly into an album | @@ -69,6 +71,9 @@ for small releases, a two-pane section rail for large ones). UI Lab reads Open the modal via the demo panel: `Ctrl+Shift+D` → Open "What's new" modal. +Combine with the scenario above to walk the whole post-update greeting for the +next release: `/?scenario=just-updated&changelog=unreleased`. + ## How It Works `src/main.tsx` bootstraps the app asynchronously. In `ui` mode it imports diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts index 1358778..557d9c8 100644 --- a/src/dev/mockBackend.ts +++ b/src/dev/mockBackend.ts @@ -11,14 +11,18 @@ import type { TaggerRuntimeProbe, } from '../store' import { compareImages, createMockDb, mockExif } from './mockFixtures' -import { getMockScenario } from './mockScenarios' +import { getMockScenario, isEmptyLibraryScenario } from './mockScenarios' const db = createMockDb(getMockScenario()) let nextFolderId = 100 let nextAlbumId = 100 let nextTagId = 10_000 +// A true first run ('new-user') has no tagger model downloaded yet, so the +// onboarding AI step offers the real model-choice + download flow. +const taggerStartsUnready = (scenario: typeof db.scenario) => + scenario === 'unready' || scenario === 'joytag-unready' || scenario === 'new-user' let mockTaggerModel: TaggerModel = db.scenario === 'joytag-unready' ? 'joytag' : 'wd' -let mockTaggerReady = db.scenario !== 'unready' && db.scenario !== 'joytag-unready' +let mockTaggerReady = !taggerStartsUnready(db.scenario) const mockTaggerThresholds: Record = { wd: 0.35, joytag: 0.4, @@ -405,9 +409,9 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise case 'find_similar_by_region': return similarImages(payload) case 'get_visual_clusters': - return db.scenario === 'empty' ? [] : db.visualClusters + return isEmptyLibraryScenario(db.scenario) ? [] : db.visualClusters case 'get_explore_tags': - return db.scenario === 'empty' + return isEmptyLibraryScenario(db.scenario) ? [] : db.scenario === 'extreme' ? db.exploreTags @@ -607,7 +611,7 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise return mockTaggerModel case 'set_tagger_model': mockTaggerModel = p.model ?? 'wd' - mockTaggerReady = db.scenario !== 'unready' && db.scenario !== 'joytag-unready' + mockTaggerReady = !taggerStartsUnready(db.scenario) return mockTaggerModel case 'prepare_tagger_model': mockTaggerReady = true @@ -644,8 +648,11 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise return null case 'get_notifications_paused': case 'get_worker_pauses_persist': + return !isEmptyLibraryScenario(db.scenario) + // Only 'new-user' greets with the onboarding tour; 'empty' is a bare + // library that has already been through it. case 'get_onboarding_completed': - return db.scenario !== 'empty' + return db.scenario !== 'new-user' case 'set_onboarding_completed': case 'set_last_seen_version': return null @@ -659,8 +666,10 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise })) case 'get_build_variant': return 'cpu' + // 'just-updated' reports an older last-seen version, so the real + // initWhatsNew logic fires the post-update "What's new" toast on launch. case 'get_last_seen_version': - return '0.1.1-ui' + return db.scenario === 'just-updated' ? '0.1.0' : '0.1.1-ui' case 'get_ffmpeg_status': return { installed: true, downloading: false, failed: false } case 'retry_ffmpeg_download': diff --git a/src/dev/mockFixtures.ts b/src/dev/mockFixtures.ts index 3a1b26c..44b0aa4 100644 --- a/src/dev/mockFixtures.ts +++ b/src/dev/mockFixtures.ts @@ -11,7 +11,7 @@ import type { SortOrder, VisualClusterEntry, } from '../store' -import type { MockScenario } from './mockScenarios' +import { isEmptyLibraryScenario, type MockScenario } from './mockScenarios' import { fixtureMediaPath } from './mockMedia' export interface MockDb { @@ -210,7 +210,7 @@ function makeImage(index: number, folderId: number, scenario: MockScenario): Ima } function makeFolders(scenario: MockScenario): Folder[] { - if (scenario === 'empty') return [] + if (isEmptyLibraryScenario(scenario)) return [] const names = scenario === 'extreme' ? extremeFolderNames : folderNames return names.map((name, index) => ({ id: index + 1, @@ -224,7 +224,7 @@ function makeFolders(scenario: MockScenario): Folder[] { } function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] { - if (scenario === 'empty') return [] + if (isEmptyLibraryScenario(scenario)) return [] const count = scenario === 'extreme' ? 1_440 : scenario === 'huge' ? 720 : scenario === 'errors' ? 54 : 72 const images = Array.from({ length: count }, (_, index) => @@ -268,7 +268,7 @@ function makeAlbums( images: ImageRecord[], scenario: MockScenario ): { albums: Album[]; albumImageIds: Record } { - if (scenario === 'empty') return { albums: [], albumImageIds: {} } + if (isEmptyLibraryScenario(scenario)) return { albums: [], albumImageIds: {} } const selects = images .filter((image) => image.rating >= 4) .slice(0, 36) @@ -401,7 +401,7 @@ function makeExploreTags(images: ImageRecord[], scenario: MockScenario): Explore } function makeDuplicateGroups(images: ImageRecord[], scenario: MockScenario): DuplicateGroup[] { - if (scenario === 'empty') return [] + if (isEmptyLibraryScenario(scenario)) return [] const candidates = images.filter((image) => image.media_kind === 'image') const groups = [0, 1, 2, 3] .map((groupIndex) => ({ diff --git a/src/dev/mockScenarios.ts b/src/dev/mockScenarios.ts index 7dfc7ff..42ee1cc 100644 --- a/src/dev/mockScenarios.ts +++ b/src/dev/mockScenarios.ts @@ -9,6 +9,8 @@ export type MockScenario = | 'extreme' | 'unready' | 'joytag-unready' + | 'new-user' + | 'just-updated' const SCENARIOS = new Set([ 'rich', @@ -21,8 +23,17 @@ const SCENARIOS = new Set([ 'extreme', 'unready', 'joytag-unready', + 'new-user', + 'just-updated', ]) +// Scenarios that start with no folders or media. 'empty' is a bare library +// with onboarding already behind it; 'new-user' is the true first run and +// additionally opens the onboarding tour with no tagger model downloaded. +export function isEmptyLibraryScenario(scenario: MockScenario): boolean { + return scenario === 'empty' || scenario === 'new-user' +} + export function getMockScenario(): MockScenario { if (typeof window === 'undefined') return 'rich' const value = new URLSearchParams(window.location.search).get('scenario')