feat(ui-lab): add first-run and update scenarios
github/actions/ci GitHub Actions CI finished: success

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.
This commit is contained in:
2026-07-04 21:41:34 +01:00
parent af1a443a64
commit aa3fe2062d
4 changed files with 38 additions and 13 deletions
+6 -1
View File
@@ -40,7 +40,9 @@ UI Lab reads `?scenario=` from the URL. If no scenario is provided, it uses
| URL | Purpose | | URL | Purpose |
| --- | --- | | --- | --- |
| `/?scenario=rich` | Default realistic library with folders, albums, ratings, favorites, tags, images, and videos | | `/?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=busy` | Background workers with pending thumbnail, metadata, embedding, caption, and tagging jobs |
| `/?scenario=duplicates` | Duplicate Finder opened with duplicate groups already available | | `/?scenario=duplicates` | Duplicate Finder opened with duplicate groups already available |
| `/?scenario=album` | Gallery opened directly into an album | | `/?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. 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 ## How It Works
`src/main.tsx` bootstraps the app asynchronously. In `ui` mode it imports `src/main.tsx` bootstraps the app asynchronously. In `ui` mode it imports
+16 -7
View File
@@ -11,14 +11,18 @@ import type {
TaggerRuntimeProbe, TaggerRuntimeProbe,
} from '../store' } from '../store'
import { compareImages, createMockDb, mockExif } from './mockFixtures' import { compareImages, createMockDb, mockExif } from './mockFixtures'
import { getMockScenario } from './mockScenarios' import { getMockScenario, isEmptyLibraryScenario } from './mockScenarios'
const db = createMockDb(getMockScenario()) const db = createMockDb(getMockScenario())
let nextFolderId = 100 let nextFolderId = 100
let nextAlbumId = 100 let nextAlbumId = 100
let nextTagId = 10_000 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 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<TaggerModel, number> = { const mockTaggerThresholds: Record<TaggerModel, number> = {
wd: 0.35, wd: 0.35,
joytag: 0.4, joytag: 0.4,
@@ -405,9 +409,9 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
case 'find_similar_by_region': case 'find_similar_by_region':
return similarImages(payload) return similarImages(payload)
case 'get_visual_clusters': case 'get_visual_clusters':
return db.scenario === 'empty' ? [] : db.visualClusters return isEmptyLibraryScenario(db.scenario) ? [] : db.visualClusters
case 'get_explore_tags': case 'get_explore_tags':
return db.scenario === 'empty' return isEmptyLibraryScenario(db.scenario)
? [] ? []
: db.scenario === 'extreme' : db.scenario === 'extreme'
? db.exploreTags ? db.exploreTags
@@ -607,7 +611,7 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
return mockTaggerModel return mockTaggerModel
case 'set_tagger_model': case 'set_tagger_model':
mockTaggerModel = p.model ?? 'wd' mockTaggerModel = p.model ?? 'wd'
mockTaggerReady = db.scenario !== 'unready' && db.scenario !== 'joytag-unready' mockTaggerReady = !taggerStartsUnready(db.scenario)
return mockTaggerModel return mockTaggerModel
case 'prepare_tagger_model': case 'prepare_tagger_model':
mockTaggerReady = true mockTaggerReady = true
@@ -644,8 +648,11 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
return null return null
case 'get_notifications_paused': case 'get_notifications_paused':
case 'get_worker_pauses_persist': 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': case 'get_onboarding_completed':
return db.scenario !== 'empty' return db.scenario !== 'new-user'
case 'set_onboarding_completed': case 'set_onboarding_completed':
case 'set_last_seen_version': case 'set_last_seen_version':
return null return null
@@ -659,8 +666,10 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
})) }))
case 'get_build_variant': case 'get_build_variant':
return 'cpu' 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': 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': case 'get_ffmpeg_status':
return { installed: true, downloading: false, failed: false } return { installed: true, downloading: false, failed: false }
case 'retry_ffmpeg_download': case 'retry_ffmpeg_download':
+5 -5
View File
@@ -11,7 +11,7 @@ import type {
SortOrder, SortOrder,
VisualClusterEntry, VisualClusterEntry,
} from '../store' } from '../store'
import type { MockScenario } from './mockScenarios' import { isEmptyLibraryScenario, type MockScenario } from './mockScenarios'
import { fixtureMediaPath } from './mockMedia' import { fixtureMediaPath } from './mockMedia'
export interface MockDb { export interface MockDb {
@@ -210,7 +210,7 @@ function makeImage(index: number, folderId: number, scenario: MockScenario): Ima
} }
function makeFolders(scenario: MockScenario): Folder[] { function makeFolders(scenario: MockScenario): Folder[] {
if (scenario === 'empty') return [] if (isEmptyLibraryScenario(scenario)) return []
const names = scenario === 'extreme' ? extremeFolderNames : folderNames const names = scenario === 'extreme' ? extremeFolderNames : folderNames
return names.map((name, index) => ({ return names.map((name, index) => ({
id: index + 1, id: index + 1,
@@ -224,7 +224,7 @@ function makeFolders(scenario: MockScenario): Folder[] {
} }
function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] { function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] {
if (scenario === 'empty') return [] if (isEmptyLibraryScenario(scenario)) return []
const count = const count =
scenario === 'extreme' ? 1_440 : scenario === 'huge' ? 720 : scenario === 'errors' ? 54 : 72 scenario === 'extreme' ? 1_440 : scenario === 'huge' ? 720 : scenario === 'errors' ? 54 : 72
const images = Array.from({ length: count }, (_, index) => const images = Array.from({ length: count }, (_, index) =>
@@ -268,7 +268,7 @@ function makeAlbums(
images: ImageRecord[], images: ImageRecord[],
scenario: MockScenario scenario: MockScenario
): { albums: Album[]; albumImageIds: Record<number, number[]> } { ): { albums: Album[]; albumImageIds: Record<number, number[]> } {
if (scenario === 'empty') return { albums: [], albumImageIds: {} } if (isEmptyLibraryScenario(scenario)) return { albums: [], albumImageIds: {} }
const selects = images const selects = images
.filter((image) => image.rating >= 4) .filter((image) => image.rating >= 4)
.slice(0, 36) .slice(0, 36)
@@ -401,7 +401,7 @@ function makeExploreTags(images: ImageRecord[], scenario: MockScenario): Explore
} }
function makeDuplicateGroups(images: ImageRecord[], scenario: MockScenario): DuplicateGroup[] { 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 candidates = images.filter((image) => image.media_kind === 'image')
const groups = [0, 1, 2, 3] const groups = [0, 1, 2, 3]
.map((groupIndex) => ({ .map((groupIndex) => ({
+11
View File
@@ -9,6 +9,8 @@ export type MockScenario =
| 'extreme' | 'extreme'
| 'unready' | 'unready'
| 'joytag-unready' | 'joytag-unready'
| 'new-user'
| 'just-updated'
const SCENARIOS = new Set<MockScenario>([ const SCENARIOS = new Set<MockScenario>([
'rich', 'rich',
@@ -21,8 +23,17 @@ const SCENARIOS = new Set<MockScenario>([
'extreme', 'extreme',
'unready', 'unready',
'joytag-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 { export function getMockScenario(): MockScenario {
if (typeof window === 'undefined') return 'rich' if (typeof window === 'undefined') return 'rich'
const value = new URLSearchParams(window.location.search).get('scenario') const value = new URLSearchParams(window.location.search).get('scenario')