Compare commits

...

4 Commits

Author SHA1 Message Date
LyAhn aa3fe2062d 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.
2026-07-04 21:41:34 +01:00
LyAhn af1a443a64 feat(ui-lab): preview What's New entries via ?changelog=
Add a UI-Lab-only (mode === "ui", dead-code-eliminated from real builds)
URL override for the What's New modal: ?changelog=unreleased shows the
in-progress notes before they ship, ?changelog=small serves a synthetic
hotfix-sized entry to exercise the compact layout (including a no-lead
bullet), and ?changelog=<version> previews any released entry. Documented
in docs/ui-lab.md alongside the ?scenario= table.
2026-07-04 21:15:17 +01:00
LyAhn 302a3151ef feat(whats-new): adapt modal layout to release size
Small releases keep the compact single-column list; releases with more than
20 bullets across 2+ sections switch to a wider two-pane layout with a
section nav rail, so no pane is a marathon scroll. The threshold lives in
RAIL_THRESHOLD; single-section entries always stay single-column.
2026-07-04 21:14:45 +01:00
LyAhn d7c3cbd683 docs(changelog): fill unreleased gaps and give Fixed notes a pain-first pass
Add the user-facing changes that had no entry: the Settings page reorg, the
AI-tag reset flow, the onboarding tagger-model choice + per-model confidence
thresholds, and the lightbox tagger-readiness fix. Fold two more papercuts
(search-field button alignment, selectable menu labels) into the papercuts
bullet.

Rework several Fixed entries to open with what the user used to suffer
rather than the fix, Discord-patch-notes style.
2026-07-04 21:14:29 +01:00
7 changed files with 273 additions and 57 deletions
+31 -13
View File
@@ -41,6 +41,13 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
- **Choose your tagging model** — Settings -> AI Workspace now lets you pick
between the anime-focused WD tagger and JoyTag, which is better suited to photo
libraries and stronger on NSFW concepts (if that's your thing, we don't judge).
New users get the same choice during the welcome tour, and each model keeps its
own confidence threshold instead of sharing one.
- **Reset AI tags** — a new reset action in Settings -> AI Workspace and the Tag
manager wipes AI-generated tags for a folder or the whole library, cancelling
any tagging still in flight. Tag manager rows now show which tags came from the
AI, so you know what you are about to lose before you lose it. Manually-added
tags are never touched.
- **Related tags in Explore** — Hover over a tag in the Tag Cloud to see the tags
that most often appear with it, complete with connection lines and image counts.
Handy for finding little clusters you did not know were there.
@@ -57,6 +64,9 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
### Changed
- **Settings got a spring clean** — preferences are now organised into General,
Media, Updates & Setup, Storage, and AI Workspace pages, so update and
maintenance controls no longer hide at the bottom of unrelated sections.
- **Menus got their act together** — right-click menus (images, folders,
albums, the theme switcher) and every dropdown (sort, folder scope, settings,
sidebar) now share one style with one set of manners: they stay on screen
@@ -95,40 +105,48 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
- **Explore no longer flashes the last folder** — switching folders now clears
the old clusters/tags and shows a loading state while the new folder catches
up.
- **Ratings keep your search order** — rating or favoriting an image no longer
reshuffles similar-image, region, semantic, tag, or album results.
- **Ratings keep your search order** — if you ever rated an image mid-search and
watched your results reshuffle themselves, that's over. Similar-image, region,
semantic, tag, and album results now stay put.
- **Update progress comes back when you need it** — if you dismiss the update
prompt and later start the update from the title bar or Settings, the progress
toast now reappears instead of hiding away in Settings.
- **Subtle Light cleanup** — fixed dark or hard-to-read surfaces, hover states,
dialogs, updater buttons, onboarding controls, and green action buttons in the
light theme.
- **No more self-indexing loops** — if you add a broad folder like your user
profile, Phokus now skips its own app-data directory instead of indexing its
thumbnail cache forever.
- **No more self-indexing loops** — adding a broad folder like your whole user
profile used to send Phokus off indexing its own thumbnail cache, generating
thumbnails of thumbnails until the end of time. It now skips its own app-data
directory.
- **Background tasks show the active work first** — when one folder is paused and
another is processing, the active folder gets the main spot in the background
tasks bar.
- **First launch fits smaller screens** — fresh installs now clamp the window to
the usable monitor area, so 1366x768-style displays do not lose part of the app
below the taskbar.
- **First launch fits smaller screens** — on 1366x768-style displays, fresh
installs used to open with part of the app tucked below the taskbar. The window
now clamps itself to the usable monitor area.
- **Explore is clearer in Subtle Light** — cluster captions, buttons, cloud
words, hover glows, and the new connection lines now use stronger light-theme
colours.
- **Explore got a few sharp edges sanded down** — Cluster Cloud uses the in-app
tooltip, singular counts now say "1 image", and the folder-scope dropdown no
longer hides behind cluster cards.
- **AI tagging stays responsive** — GPU tagging now works in smaller bursts with
brief pauses between them, so the UI keeps moving and the first batch starts
- **AI tagging stays responsive** — starting a big tagging job used to turn the
rest of the app into a slideshow. GPU tagging now works in smaller bursts with
brief pauses between them, so the UI keeps moving and the first results land
sooner.
- **Lightbox AI tags wake up on time** — the lightbox's AI tags action no longer
stays disabled until you happen to open Settings, and it switches on as soon as
a model download finishes.
- **Noisy AI tags get cleaned up** — generic low-signal tags from WD and JoyTag
are filtered before they are saved, and matching older generated tags are
cleaned up on startup. Your manually-added tags are left alone.
- **Selected Folders starts empty** — choosing "Selected Folders" for AI tagging
no longer pre-selects the first folder. You decide exactly what gets queued.
- **A couple of tiny UI papercuts are gone** — the zoom buttons now show the
right tile size when you hover them, and the folder picker no longer adds an
odd trailing slash to Windows drive breadcrumbs.
- **A handful of tiny UI papercuts are gone** — the zoom buttons now show the
right tile size when you hover them, the folder picker no longer adds an odd
trailing slash to Windows drive breadcrumbs, the search field's clear button
no longer sits a few pixels too high, and menu labels no longer highlight like
text when you drag across them.
## [0.1.1] — 2026-06-23
+20 -1
View File
@@ -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 |
@@ -55,6 +57,23 @@ http://127.0.0.1:1422/?scenario=errors
http://127.0.0.1:1422/?scenario=huge
```
## Changelog Previews
The What's New modal adapts its layout to release size (compact single column
for small releases, a two-pane section rail for large ones). UI Lab reads
`?changelog=` to override which entry the modal shows:
| URL | Purpose |
| --- | --- |
| `/?changelog=unreleased` | The in-progress `[Unreleased]` notes — large release, rail layout |
| `/?changelog=small` | Synthetic hotfix-sized entry — compact single-column layout |
| `/?changelog=0.1.1` | Any specific released version |
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
+58
View File
@@ -107,7 +107,65 @@ function parseChangelog(raw: string): ChangelogEntry[] {
const ENTRIES = parseChangelog(changelogRaw)
// Synthetic small release for UI Lab (`?changelog=small`). The What's New modal
// switches layout by release size, and every real entry in CHANGELOG.md is
// large — so the compact single-column layout can't be exercised from real
// data. This stays below the modal's rail threshold on purpose.
const SMALL_PREVIEW_ENTRY: ChangelogEntry = {
version: '0.0.0-preview',
date: '2026-01-01',
sections: [
{
title: 'Added',
items: [
{
lead: 'Sample setting',
body: 'a small toggle that exists purely so this preview has an Added section.',
},
],
},
{
title: 'Changed',
items: [
{
lead: 'Snappier previews',
body: 'the preview fixture now loads instantly, because it is made up.',
},
{ lead: null, body: 'A plain full-sentence bullet without a bold lead, for coverage.' },
],
},
{
title: 'Fixed',
items: [
{
lead: 'Hotfix-sized fix',
body: 'a believable one-liner about a bug that never shipped.',
},
{
lead: 'Another small fix',
body: 'keeps the total item count comfortably under the rail threshold.',
},
],
},
],
}
// UI Lab affordance (browser-only `ui` mode): `?changelog=` previews an entry
// other than the running version's, mirroring the `?scenario=` pattern.
// ?changelog=unreleased — the in-progress [Unreleased] notes (large release)
// ?changelog=small — synthetic hotfix-sized entry (compact layout)
// ?changelog=0.1.1 — any specific released version
function previewOverride(): ChangelogEntry | null {
if (import.meta.env.MODE !== 'ui') return null
const preview = new URLSearchParams(window.location.search).get('changelog')
if (!preview) return null
if (preview === 'small') return SMALL_PREVIEW_ENTRY
return ENTRIES.find((e) => e.version.toLowerCase() === preview.toLowerCase()) ?? null
}
export function getChangelogForVersion(version: string | null | undefined): ChangelogEntry | null {
const override = previewOverride()
if (override) return override
if (!version) return null
// Strip leading "v" and any build suffix (e.g. "-ui", "-dev", "-beta.1") so
// dev/UI-lab builds still resolve to the correct changelog entry.
+132 -31
View File
@@ -2,10 +2,30 @@ import { useEffect, useState, type ReactNode } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { invoke } from '@tauri-apps/api/core'
import { useGalleryStore } from '../store'
import { getChangelogForVersion, type ChangelogSection } from '../changelog'
import {
getChangelogForVersion,
type ChangelogEntry,
type ChangelogItem,
type ChangelogSection,
} from '../changelog'
import { Tooltip } from './Tooltip'
import { ChevronRightIcon, CloseIcon } from './icons'
// The modal adapts its layout to the size of the release: small releases keep
// the compact single-column list, while big ones switch to a two-pane layout
// with a section nav rail so no one pane is a marathon scroll. Preview both in
// UI Lab via `?changelog=unreleased` + the demo panel's "Open What's new modal".
type WhatsNewLayout = 'single' | 'rail'
// Above this many total bullets, the single column gets unpleasantly long.
const RAIL_THRESHOLD = 20
function chooseLayout(entry: ChangelogEntry | null): WhatsNewLayout {
if (!entry || entry.sections.length < 2) return 'single'
const total = entry.sections.reduce((sum, section) => sum + section.items.length, 0)
return total > RAIL_THRESHOLD ? 'rail' : 'single'
}
// Shown in the tooltip so the user can see the link's destination before
// clicking. The actual navigation is handled by the open_changelog_url command.
const CHANGELOG_URL = 'https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md'
@@ -25,6 +45,10 @@ const SECTION_STYLES: Record<string, { label: string; dot: string }> = {
const NEUTRAL_STYLE = { label: 'text-gray-300', dot: 'bg-gray-400/70' }
function sectionStyle(title: string) {
return SECTION_STYLES[title] ?? NEUTRAL_STYLE
}
// Render the small amount of inline markdown the changelog uses: **bold** and
// `code`. Anything else passes through as plain text.
function renderInline(text: string): ReactNode[] {
@@ -58,8 +82,44 @@ function renderInline(text: string): ReactNode[] {
return nodes
}
function Section({ section }: { section: ChangelogSection }) {
const style = SECTION_STYLES[section.title] ?? NEUTRAL_STYLE
function Item({ item, dot }: { item: ChangelogItem; dot: string }) {
return (
<li className="flex gap-3">
<span className={`mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full ${dot}`} />
<p className="text-[13px] leading-relaxed text-gray-400">
{item.lead ? (
<>
<span className="font-semibold text-gray-100">{item.lead}</span>
{item.body ? <span> {renderInline(item.body)}</span> : null}
</>
) : (
renderInline(item.body)
)}
</p>
</li>
)
}
function ItemList({
items,
dot,
className = '',
}: {
items: ChangelogItem[]
dot: string
className?: string
}) {
return (
<ul className={`space-y-2.5 ${className}`}>
{items.map((item, index) => (
<Item key={index} item={item} dot={dot} />
))}
</ul>
)
}
function CollapsibleSection({ section }: { section: ChangelogSection }) {
const style = sectionStyle(section.title)
const [open, setOpen] = useState(true)
return (
@@ -88,23 +148,7 @@ function Section({ section }: { section: ChangelogSection }) {
transition={{ duration: 0.18 }}
className="overflow-hidden"
>
<ul className="mt-2.5 space-y-2.5 pl-5">
{section.items.map((item, index) => (
<li key={index} className="flex gap-3">
<span className={`mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full ${style.dot}`} />
<p className="text-[13px] leading-relaxed text-gray-400">
{item.lead ? (
<>
<span className="font-semibold text-gray-100">{item.lead}</span>
{item.body ? <span> {renderInline(item.body)}</span> : null}
</>
) : (
renderInline(item.body)
)}
</p>
</li>
))}
</ul>
<ItemList items={section.items} dot={style.dot} className="mt-2.5 pl-5" />
</motion.div>
) : null}
</AnimatePresence>
@@ -112,12 +156,61 @@ function Section({ section }: { section: ChangelogSection }) {
)
}
// 'rail' layout — a section nav rail on the left, the selected section's items
// on the right.
function RailSections({ sections }: { sections: ChangelogSection[] }) {
const [active, setActive] = useState(0)
const section = sections[Math.min(active, sections.length - 1)]
const style = sectionStyle(section.title)
return (
<div className="flex min-h-0 flex-1">
<nav className="flex w-44 shrink-0 flex-col gap-1 border-r border-white/[0.07] px-3 py-4">
{sections.map((s, index) => {
const navStyle = sectionStyle(s.title)
const isActive = index === active
return (
<button
key={s.title}
type="button"
onClick={() => setActive(index)}
className={`flex items-center gap-2.5 rounded-md px-3 py-2 text-left text-[12px] font-medium transition-colors ${
isActive
? 'bg-white/[0.07] text-white'
: 'text-gray-500 hover:bg-white/[0.04] hover:text-gray-300'
}`}
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${navStyle.dot}`} />
<span className="flex-1">{s.title}</span>
<span className="text-[11px] font-medium text-gray-600">{s.items.length}</span>
</button>
)
})}
</nav>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<p
className={`mb-3 text-[11px] font-semibold tracking-[0.1em] uppercase ${style.label}`}
>
{section.title}
</p>
<ItemList items={section.items} dot={style.dot} />
</div>
</div>
)
}
const MODAL_WIDTHS: Record<WhatsNewLayout, string> = {
single: 'w-[min(88vw,560px)]',
rail: 'w-[min(90vw,780px)]',
}
export function WhatsNewModal() {
const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen)
const closeWhatsNew = useGalleryStore((s) => s.closeWhatsNew)
const appVersion = useGalleryStore((s) => s.appVersion)
const entry = getChangelogForVersion(appVersion)
const layout = chooseLayout(entry)
useEffect(() => {
if (!whatsNewOpen) return
@@ -128,6 +221,8 @@ export function WhatsNewModal() {
return () => window.removeEventListener('keydown', handleKeyDown)
}, [whatsNewOpen, closeWhatsNew])
const hasSections = entry !== null && entry.sections.length > 0
return (
<AnimatePresence>
{whatsNewOpen ? (
@@ -140,7 +235,7 @@ export function WhatsNewModal() {
transition={{ duration: 0.15 }}
>
<motion.div
className="relative flex max-h-[min(82vh,760px)] w-[min(88vw,560px)] flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60"
className={`relative flex max-h-[min(82vh,760px)] ${MODAL_WIDTHS[layout]} flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60`}
onClick={(event) => event.stopPropagation()}
initial={{ opacity: 0, scale: 0.97, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
@@ -169,16 +264,22 @@ export function WhatsNewModal() {
</Tooltip>
</div>
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-6 py-5">
{entry && entry.sections.length > 0 ? (
entry.sections.map((section) => <Section key={section.title} section={section} />)
) : (
<p className="text-sm text-gray-500">
Release notes for this version aren't available in-app. See the full changelog on
GitHub.
</p>
)}
</div>
{hasSections && layout === 'rail' ? (
<RailSections sections={entry.sections} />
) : (
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-6 py-5">
{hasSections ? (
entry.sections.map((section) => (
<CollapsibleSection key={section.title} section={section} />
))
) : (
<p className="text-sm text-gray-500">
Release notes for this version aren't available in-app. See the full changelog
on GitHub.
</p>
)}
</div>
)}
<div className="flex items-center justify-between gap-4 border-t border-white/[0.07] px-6 py-4">
<Tooltip label={CHANGELOG_URL} side="top" align="start">
+16 -7
View File
@@ -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':
+5 -5
View File
@@ -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) => ({
+11
View File
@@ -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')