feat(ui-lab): add first-run and update scenarios
github/actions/ci GitHub Actions CI finished: success
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:
+6
-1
@@ -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
|
||||
|
||||
+16
-7
@@ -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<TaggerModel, number> = {
|
||||
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':
|
||||
|
||||
@@ -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<number, number[]> } {
|
||||
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) => ({
|
||||
|
||||
@@ -9,6 +9,8 @@ export type MockScenario =
|
||||
| 'extreme'
|
||||
| 'unready'
|
||||
| 'joytag-unready'
|
||||
| 'new-user'
|
||||
| 'just-updated'
|
||||
|
||||
const SCENARIOS = new Set<MockScenario>([
|
||||
'rich',
|
||||
@@ -21,8 +23,17 @@ const SCENARIOS = new Set<MockScenario>([
|
||||
'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')
|
||||
|
||||
Reference in New Issue
Block a user