From e8dc99ce16669ef24988ad71f1cfd706cc3726a0 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Mon, 26 Oct 2026 04:34:40 +0200 Subject: [PATCH 1/2] [feat] fw1 first visit: one question, a first-win quest, advanced tools after the first Play MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Welcome asks one question — Play / Remix / Template / Describe it (empty scene stays as a quiet link) — and each answer leads straight into its own path (the Play path loads a game and starts it; Remix / Template list the bundled starters; Describe it runs the assistant or says how to set one up) - the first-win quest rides the tours engine: change something → Play → invite someone, each step completed by doing it (an undo-worthy edit, Play starting, the invite link copied or a peer joining); a quiet ring, no dimming, hidden while the game runs; a template / remix starts it once the scene has loaded, Play starts it after the first game ends - tour engine: `hideWhilePlaying` + `quiet` on a TourDef, `onAdvanced` listeners (an action completing a step, heard before the step moves on); TourCard notes Skip / Don't show - index entries may carry a `quest` line; core keeps a default per bundled game and template - progressive disclosure: on a first visit the advanced toolbar buttons (flow, animation, pivot, mode) wait in the » overflow and Modules / toolboxes / Layouts behind More until the first Play; Settings ▸ Interface ▸ "Show everything now" and "Start the first-win quest" - the editor tour no longer starts after the Welcome — it is offered at the end of the quest and from the logo menu ("Tour the interface") - unit: tests/unit/onboardingQuests.test.js; e2e: tests/e2e/onboarding.test.cjs (battery), tours.test.cjs follows the new start Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/menu/Connect.svelte | 5 +- src/components/menu/Controls.svelte | 13 +- src/components/menu/Sidebar.svelte | 65 ++- src/components/menu/Welcome.svelte | 510 +++++++++++++++--- .../menu/settings/InterfaceSettings.svelte | 26 +- src/components/tours/TourCard.svelte | 29 +- src/lib/debugHooks.js | 3 + src/lib/onboarding/firstWin.js | 239 ++++++++ src/lib/onboarding/onboardState.js | 53 ++ src/lib/onboarding/quests.js | 149 +++++ src/lib/sceneTemplates.js | 7 + src/lib/settings/resetCategory.js | 2 + src/lib/settingsIndex.js | 4 +- src/lib/tours/builtin.js | 44 +- src/lib/tours/engine.js | 30 +- src/lib/whatsNew.js | 4 + tests/e2e/battery.json | 1 + tests/e2e/onboarding.test.cjs | 434 +++++++++++++++ tests/e2e/tours.test.cjs | 19 +- tests/e2e/whats-new.test.cjs | 6 +- tests/unit/onboardingQuests.test.js | 198 +++++++ 21 files changed, 1697 insertions(+), 144 deletions(-) create mode 100644 src/lib/onboarding/firstWin.js create mode 100644 src/lib/onboarding/onboardState.js create mode 100644 src/lib/onboarding/quests.js create mode 100644 tests/e2e/onboarding.test.cjs create mode 100644 tests/unit/onboardingQuests.test.js diff --git a/src/components/menu/Connect.svelte b/src/components/menu/Connect.svelte index 533f8b5be..85c792be9 100644 --- a/src/components/menu/Connect.svelte +++ b/src/components/menu/Connect.svelte @@ -4,6 +4,8 @@ import { peerColor } from '$lib/lockControl'; import Icon from '../ui/Icon.svelte'; import { phoneShellActive } from '$lib/ui/phoneShell.js'; + import { tours } from '$lib/tours/index.js'; // 76 FW1: the copy is the first-win quest's share step + import { SIGNALS } from '$lib/onboarding/quests.js'; // 43 K3/K4: whether the pill is shown (Edit positions / Customize), and the top-row items it measures against import { toolbarSlots } from '$lib/toolbarSlots'; import { peers, userdata, waitingForApproval, pendingApprovals, showToast, settingsOpen, settingsSection, connectDrawerOpen, connectDrawerTab, connectDrawerPinned, showRoomsButton, connectDocked, connectBarHeight, toastStore, toastsInDrawerOnly, touchTools } from '../../stores/appStore' @@ -503,7 +505,8 @@ navigator.clipboard .writeText(window.location.origin + '#' + myidcap + inviteServerParam(srv)) .then(function () { - // alert("yeah!"); // success + // 76 FW1: the first-win quest's last step waits for this + tours.signal(SIGNALS.copied); }) .catch(function () { // alert("err"); // error diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index ac75c7478..1d27a5e91 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -65,6 +65,7 @@ import { dockShownKeys, dockOccupants, bottomInset, FLOW_FAMILY, armDockMode, DOCK_TITLES } from '$lib/bottomDock'; import { togglePanel } from '$lib/panelToggles'; import { requestPlay, willEnterXR, willEnterAR, xrSessionFailed, playAsItems } from '$lib/playMode'; + import { simpleUntilPlay, holdAdvanced } from '$lib/onboarding/onboardState.js'; // 76 FW1 // 60 Q1: Play here — a roster button (Customize), a row in Play's own menu, Ctrl+Shift+Enter import { playHere, playHereItems, playingHere } from '$lib/playHere'; // 30 P2: Test play — the play button's right-click row beside the game chip's button @@ -1566,15 +1567,19 @@ // positions runs (it shows every item, wrapping) and on a phone-width bar (its cells shrink instead). const foldPlan = $derived.by(() => { if (controlsLayout.collapsed) return { shown: [SPACER], folded: [] as string[] }; - const row = visualIds().filter((id) => !(id === 'pivot' && viewportW < NARROW_BAR)); - if (editing || viewportW <= FOLD_MIN_VW || !barRoom.ok) return { shown: row, folded: [] as string[] }; - return foldToFit(row, { + const all = visualIds().filter((id) => !(id === 'pivot' && viewportW < NARROW_BAR)); + if (editing) return { shown: all, folded: [] as string[] }; + // 76 FW1: until a new user's first Play the advanced buttons wait in "»" (still one press away) + const { row, held } = holdAdvanced(all, $simpleUntilPlay); + if (viewportW <= FOLD_MIN_VW || !barRoom.ok) return { shown: row, folded: held }; + const fit = foldToFit(row, { ...barMetrics, // a px of slack: the arithmetic is in whole layout px, the browser's in fractions of one budget: barRoom.right - barRoom.left - 1, // the "…" is drawn only on a wide window (hud.css hides it below 720 px) more: controlsLayout.more !== false && viewportW >= 720 }); + return held.length ? { shown: fit.shown, folded: [...fit.folded, ...held] } : fit; }); const visibleCells = $derived(foldPlan.shown.map((id) => ({ id }))); /** the cells folded into "»", in the bar's left → right order */ @@ -3863,7 +3868,7 @@ - {:else if !controlsLayout.collapsed && controlsLayout.more !== false} diff --git a/src/components/menu/Sidebar.svelte b/src/components/menu/Sidebar.svelte index d2ca4b72b..d3c1e3a35 100644 --- a/src/components/menu/Sidebar.svelte +++ b/src/components/menu/Sidebar.svelte @@ -8,6 +8,9 @@ import { openProblemReport } from '$lib/problemReport'; import '../../app.css'; import { moduleToolboxes, openToolboxes, buildToolboxItems } from '$lib/moduleToolboxes'; + import { simpleUntilPlay } from '$lib/onboarding/onboardState.js'; // 76 FW1 + /** 76 FW1: the "More" row was pressed this time the menu is open */ + let sceneMore = $state(false); import '../../styles/menu.css'; import { tick } from 'svelte'; import { fade } from 'svelte/transition'; @@ -278,32 +281,39 @@ - - - {#each buildToolboxItems($moduleToolboxes, $openToolboxes, 'sidebar') as box (box.id)} - - {/each} - - + + {#each buildToolboxItems($moduleToolboxes, $openToolboxes, 'sidebar') as box (box.id)} + + {/each} + + + {:else} + + {/if}
Collaborate
- + {/if} diff --git a/src/components/menu/Welcome.svelte b/src/components/menu/Welcome.svelte index 3639a27a8..e5b3780ae 100644 --- a/src/components/menu/Welcome.svelte +++ b/src/components/menu/Welcome.svelte @@ -1,13 +1,25 @@ {#if $welcomeOpen} @@ -48,12 +142,15 @@ {/if} @@ -119,8 +315,8 @@ /* transform makes this the containing block for fixed children — there are none inside, so centering this way is safe (see the menus gotcha). */ transform: translate(-50%, -50%); - width: min(560px, 92vw); - max-height: 88vh; + width: min(600px, calc(100vw - 24px)); + max-height: 90dvh; overflow-y: auto; padding: 18px 20px 14px; border-radius: 16px; @@ -134,7 +330,7 @@ display: flex; align-items: center; gap: 11px; - margin-bottom: 14px; + margin-bottom: 12px; } .welcome-title { font-size: 18px; @@ -169,25 +365,178 @@ background: var(--surface-hover); } .welcome-lead { - font-size: 13.5px; - line-height: 1.55; - color: var(--text-2); + font-size: 13px; + line-height: 1.5; + color: var(--text-muted); } .welcome-lead strong { - color: var(--text); + color: var(--text-2); font-weight: 600; } + .welcome-q { + margin-top: 14px; + font-size: 16px; + font-weight: 650; + color: var(--text); + } + .welcome-subhead { + display: flex; + align-items: center; + gap: 6px; + } + .welcome-subhead .welcome-q { + margin-top: 0; + } + .welcome-back { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + } + .welcome-back:hover { + color: var(--text); + background: var(--surface-hover); + } + .welcome-choices { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 10px; + } + .welcome-choice { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + min-height: 104px; + padding: 12px 13px; + border-radius: 12px; + border: 1px solid var(--border-strong); + background: var(--surface-2); + color: var(--text-2); + text-align: left; + cursor: pointer; + } + .welcome-choice:hover { + background: var(--surface-hover); + border-color: var(--accent); + } + .welcome-ico { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + margin-bottom: 4px; + border-radius: 10px; + background: var(--accent-soft); + color: var(--accent-soft-text); + } + .welcome-choice-title { + font-size: 14px; + font-weight: 650; + color: var(--text); + } + .welcome-choice-sub { + font-size: 12px; + line-height: 1.4; + color: var(--text-muted); + } + .welcome-quiet { + margin-top: 12px; + text-align: center; + } .welcome-sub { margin-top: 8px; font-size: 12.5px; line-height: 1.5; color: var(--text-muted); } - .welcome-actions { + .welcome-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); + gap: 8px; + margin-top: 10px; + } + .welcome-item { + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text-2); + text-align: left; + cursor: pointer; + } + .welcome-item:hover { + border-color: var(--accent); + } + .welcome-item:disabled { + opacity: 0.6; + cursor: progress; + } + .welcome-skel { + height: 112px; + cursor: default; + } + .welcome-thumb { + width: 100%; + height: 76px; + object-fit: cover; + background: var(--surface-inset); + } + .welcome-thumb-ph { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-faint); + } + .welcome-item-title { + padding: 6px 8px 8px; + font-size: 12.5px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .welcome-prompt { + width: 100%; + margin-top: 10px; + resize: vertical; + font-size: 13px; + } + .welcome-chips { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 6px; + } + .welcome-chip { + padding: 7px 10px; + border-radius: 9px; + border: 1px solid var(--border-strong); + background: var(--surface-2); + color: var(--text-2); + font-size: 12px; + text-align: left; + cursor: pointer; + } + .welcome-chip:hover { + background: var(--surface-hover); + } + .welcome-row { display: flex; flex-wrap: wrap; gap: 8px; - margin-top: 16px; + margin-top: 12px; } .welcome-btn { flex: 0 0 auto; @@ -202,15 +551,9 @@ .welcome-btn:hover { background: var(--surface-active); } - /* 15-M: star count chip inside the GitHub button */ - .welcome-stars { - margin-left: 7px; - padding: 1px 6px; - border-radius: 999px; - background: var(--surface-active); - color: var(--ink-warn); - font-size: 11px; - font-variant-numeric: tabular-nums; + .welcome-btn:disabled { + opacity: 0.55; + cursor: default; } .welcome-btn-primary { background: var(--accent-fill); @@ -222,14 +565,33 @@ background: var(--accent-fill); filter: brightness(0.92); } + /* 15-M: star count chip inside the GitHub link */ + .welcome-stars { + margin-left: 5px; + padding: 0 5px; + border-radius: 999px; + background: var(--surface-active); + color: var(--ink-warn); + font-size: 10.5px; + font-variant-numeric: tabular-nums; + text-decoration: none; + display: inline-block; + } .welcome-foot { display: flex; + flex-wrap: wrap; align-items: center; - gap: 12px; - margin-top: 16px; + gap: 8px 12px; + margin-top: 14px; padding-top: 11px; border-top: 1px solid var(--border); } + .welcome-foot-links { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-left: auto; + } .welcome-check { display: flex; align-items: center; @@ -239,7 +601,6 @@ cursor: pointer; } .welcome-link { - margin-left: auto; background: transparent; border: 0; padding: 0; @@ -248,13 +609,36 @@ text-decoration: underline; cursor: pointer; } + .welcome-quiet .welcome-link { + font-size: 12.5px; + } .welcome-link:hover { color: var(--accent-soft-text); } + .welcome-sr { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } @media (max-width: 480px) { - .welcome-actions .welcome-btn { - flex: 1 1 100%; - text-align: center; + .welcome-card { + padding: 14px 14px 12px; + } + .welcome-choice { + min-height: 96px; + padding: 10px; + } + .welcome-choice-sub { + font-size: 11.5px; + } + .welcome-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .welcome-foot-links { + margin-left: 0; } } diff --git a/src/components/menu/settings/InterfaceSettings.svelte b/src/components/menu/settings/InterfaceSettings.svelte index 8a5b1c8f9..4d976daca 100644 --- a/src/components/menu/settings/InterfaceSettings.svelte +++ b/src/components/menu/settings/InterfaceSettings.svelte @@ -12,7 +12,9 @@ 'show pop-ups only in the drawer': ['toasts in drawer only', 'toasts', 'notifications'], 'show tours automatically': ['tour', 'tours', 'tutorial', 'onboarding', 'first run', 'guide'], 'vr welcome tour': ['vr welcome', 'controllers', 'quest', 'tour', 'tutorial'], - 'editor tour': ['tour', 'tutorial', 'help', 'guide'], + 'editor tour': ['tour', 'tutorial', 'help', 'guide', 'tour the interface'], + 'first-game quest': ['quest', 'first win', 'onboarding', 'tutorial', 'guide', 'make it yours'], + 'show everything': ['simple', 'beginner', 'more', 'hidden buttons', 'node editor', 'modules', 'onboarding', 'advanced'], 'offer enter vr in a headset browser': ['offer enter vr', 'enter vr', 'headset browser', 'quest'], 'reset tours': ['tour', 'tours', 'tutorial'], 'reset tips': ['tip', 'tips', 'hint', 'hints', 'help', 'how-to'], @@ -71,6 +73,9 @@ import { showWelcomeOnStart, showWhatsNewNotice, openWelcome } from '$lib/whatsNew'; import { tours, tourRecords } from '$lib/tours/index.js'; import { VR_TOUR, editorTourId, startVRWelcome, startEditorTour, resetAllTours } from '$lib/tours/builtin.js'; + import { FIRST_WIN_TOUR } from '$lib/onboarding/quests.js'; // 76 FW1 + import { startFirstWin } from '$lib/onboarding/firstWin.js'; + import { simpleUntilPlay } from '$lib/onboarding/onboardState.js'; // 43 J9: closed tips (the phone More sheet's how-to) come back from one row import { resetTips, dismissedTips } from '$lib/tips'; import { xrOfferEnabled } from '$lib/xrOffer'; @@ -208,7 +213,7 @@
- + tours.setAutoStart(on)} /> @@ -235,6 +240,23 @@ }}>Start + + + {#snippet desc()}Three steps: change something, play it, invite a friend. {tourStatus(FIRST_WIN_TOUR, $tourRecords)}{/snippet} + + + + + simpleUntilPlay.set(!on)} /> + diff --git a/src/components/tours/TourCard.svelte b/src/components/tours/TourCard.svelte index af3576270..ad484145c 100644 --- a/src/components/tours/TourCard.svelte +++ b/src/components/tours/TourCard.svelte @@ -9,7 +9,8 @@ import { installTours, BUILTIN_TARGETS, FAMILY_KEY } from '$lib/tours/builtin.js' import { placeCard, targetSelectors, SPOT_PAD } from '$lib/tours/place.js' import { controllerSvg, litFor, familyLabel } from '$lib/tours/controllerArt.js' - import { isVRMode } from '../../stores/sceneStore' + import { isVRMode, isLocked } from '../../stores/sceneStore' + import { noteTourSkip } from '$lib/onboarding/firstWin.js' import { safeStorage } from '$lib/safeStorage' import { minimalScroll } from '$lib/ui/minimalScroll.js' @@ -19,7 +20,20 @@ }) onDestroy(() => uninstall?.()) - let tour = $derived($activeTour && !$isVRMode && ($activeTour.surface === 'card' || $activeTour.preview) ? $activeTour : null) + // 76 FW1: a `hideWhilePlaying` tour (the first-win quest) stands down while the game runs + let tour = $derived( + $activeTour && !$isVRMode && ($activeTour.surface === 'card' || $activeTour.preview) && !($activeTour.hideWhilePlaying && $isLocked === true) + ? $activeTour + : null + ) + function skip() { + noteTourSkip(tour) + tours.skip() + } + function never() { + noteTourSkip(tour) + tours.dontShowAgain() + } let cardEl: HTMLElement | null = $state(null) let spot: { x: number; y: number; w: number; h: number } | null = $state(null) @@ -76,7 +90,7 @@ if (e.key === 'Escape') { e.preventDefault() e.stopPropagation() - tours.skip() + skip() } else if (e.key === 'ArrowRight') { e.preventDefault() tours.next() @@ -101,6 +115,7 @@ @@ -145,8 +160,8 @@

→ {tour.step.hint}

{/if}
- - + + {#if !tour.first} @@ -168,6 +183,10 @@ transition: left 0.25s ease, top 0.25s ease, width 0.25s ease, height 0.25s ease; animation: tour-pulse 1.8s ease-in-out infinite; } + /* 76 FW1: a quest's ring — the user is working, so the viewport stays undimmed */ + .tour-spot.quiet { + box-shadow: none; + } @keyframes tour-pulse { 50% { outline-offset: 4px; diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 88e20ce11..5592dc767 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -41,6 +41,9 @@ export const DEBUG_HOOKS = [ // 36 U3b/I5: the tour engine (tours, activeTour, toursDebug) + the built-in tours ['tours', () => import('./tours/index.js')], ['toursBuiltin', () => import('./tours/builtin.js')], + // 76 FW1: the first-win quest (start / choose / debug) + its local state + ['firstWin', () => import('./onboarding/firstWin.js')], + ['onboardState', () => import('./onboarding/onboardState.js')], // 39: drag to place — the preview setting, the measured dims, the pack downloads, the ghost ['placementPrefs', () => import('./placementPrefs.js')], ['placementDims', () => import('./placementDims.js')], diff --git a/src/lib/onboarding/firstWin.js b/src/lib/onboarding/firstWin.js new file mode 100644 index 000000000..49a203ded --- /dev/null +++ b/src/lib/onboarding/firstWin.js @@ -0,0 +1,239 @@ +// 76 FW1 — THE FIRST WIN on the existing tours engine: the Welcome card's answer arms a three-step +// quest (change something → Play → share, see quests.js), and this module owns everything around it: +// +// · the SIGNAL PRODUCERS the steps wait for — a new undo entry (`scene-edited`: an add, a move, a +// recolour, an AI batch — whatever the user can undo is a change they made), Play starting +// (`play-entered`), the invite link copied (`share-copied`, signalled by Connect's copy) and a +// peer's connection opening (`peer-joined`); +// · WHEN the quest starts: a template / remix starts it once the scene has loaded, Play starts it +// after that first game ends (it becomes the remix quest — "now make it yours"), Describe it and +// an empty scene start it at once; +// · how it ENDED: every step done by doing it → a celebration toast with the next step; +// · the progressive-disclosure switch's other half: the first Play clears `simpleUntilPlay`. +// +// Under test nothing starts by itself unless the suite opts in with `toursUnderTest` (the tours +// precedent): the whole e2e battery must not grow a card over the canvas. + +import { get } from 'svelte/store'; +import { tours, activeTour } from '../tours/index.js'; +import { safeStorage } from '../safeStorage'; +import { coarsePointer } from '../inputDevice'; +import { isLocked } from '../../stores/sceneStore'; +import { showToast, templatesModalOpen } from '../../stores/appStore.js'; +import { peerJoinedAt } from '../connectionState'; +import { undoStack } from '../history'; +import { templateLoaded } from '../sceneTemplates'; +import { + FIRST_WIN_TOUR, + SIGNALS, + firstWinSteps, + questFor, + questComplete, + playEdge +} from './quests.js'; +import { onboardChoice, simpleUntilPlay } from './onboardState.js'; + +/** may the quest start by itself on this page? (not under test unless the suite asks) */ +function allowed() { + if (!safeStorage.getItem('debugStores')) return true; + return safeStorage.getItem('toursUnderTest') === 'true'; +} + +/** the quest owed for after the first game ends (the Play answer) @type {{entry: any} | null} */ +let afterPlay = null; +/** the quest owed for when the next template / game loads (Remix / Template) @type {string} */ +let afterLoad = ''; +/** step ids an ACTION completed in the running quest */ +let advancedBy = new Set(); +/** set by the card's Skip / Don't show again just before the engine closes the quest */ +let skippedAt = ''; +/** the quest the suites can read back @type {{choice: string, quest: string, steps: string[]} | null} */ +let lastQuest = null; +/** @type {(() => void) | null} injected by the tours installer (builtin.js imports us, not the reverse) */ +let startEditorTour = null; +/** @type {Set<(e: {kind: string, step?: string}) => void>} */ +const endListeners = new Set(); + +/** + * Start the quest now (from the top, replacing any running tour). `force`: a manual start (Settings), + * which the under-test guard does not apply to. + * @param {{choice?: string, entry?: any, force?: boolean}} [options] + * @returns {boolean} + */ +export function startFirstWin({ choice = get(onboardChoice), entry = null, force = false } = {}) { + if (!force && (!allowed() || !tours.autoStartEnabled())) return false; + const quest = questFor(entry, choice); + tours.register(FIRST_WIN_TOUR, { + title: 'Your first game', + surface: 'card', + hideWhilePlaying: true, + quiet: true, + steps: firstWinSteps({ quest, touch: coarsePointer() }) + }); + advancedBy = new Set(); + skippedAt = ''; + lastQuest = { choice, quest, steps: ['change', 'play', 'share'] }; + tours.reset(FIRST_WIN_TOUR); + return tours.start(FIRST_WIN_TOUR, { from: 'start' }); +} + +/** + * The Welcome card's answer. Records it, and either starts the quest or arms it: + * 'play' after the first game ends + * 'remix' / 'template' when the next template / game has loaded (any card — the Welcome's own + * list or the Templates window it links to) + * 'prompt' / 'empty' now + * @param {string} choice @param {{entry?: any, loaded?: boolean}} [options] + */ +export function chooseOnboarding(choice, { entry = null, loaded = false } = {}) { + onboardChoice.set(choice); + if (choice === 'play') { + afterPlay = { entry }; + return false; + } + if ((choice === 'remix' || choice === 'template') && !loaded) { + afterLoad = choice; + return false; + } + return startFirstWin({ choice, entry }); +} + +/** TourCard's Skip / Don't show again, just before the engine closes the tour @param {any} tour */ +export function noteTourSkip(tour) { + if (tour?.id === FIRST_WIN_TOUR) skippedAt = String(tour.step?.id ?? ''); +} + +/** hear how a quest ended: {kind: 'done'} or {kind: 'skip', step} @param {(e: {kind: string, step?: string}) => void} fn */ +export function onQuestEnd(fn) { + endListeners.add(fn); + return () => endListeners.delete(fn); +} + +/** @param {{kind: string, step?: string}} e */ +function ended(e) { + for (const fn of endListeners) { + try { + fn(e); + } catch { + /* a listener must not wedge the quest */ + } + } +} + +function celebrate() { + /** @type {{label: string, action: () => void}[]} */ + const actions = [{ label: 'More templates', action: () => templatesModalOpen.set(true) }]; + if (startEditorTour) + actions.unshift({ label: 'Tour the interface', action: () => startEditorTour?.() }); + showToast( + 'First win! You changed a game, played it and shared it. Save it from the logo menu ▸ Save.', + actions + ); +} + +/** + * Wire the producers (idempotent; the tours installer calls it). + * @param {{startEditorTour?: () => void}} [options] @returns {() => void} + */ +export function installFirstWin(options = {}) { + startEditorTour = options.startEditorTour ?? null; + /** @type {(() => void)[]} */ + const off = []; + + // scene-edited: a NEW top entry on the undo stack (a selection pick is not a change) + const seen = new WeakSet(); + for (const e of get(undoStack)) seen.add(e); + off.push( + undoStack.subscribe((stack) => { + const top = stack[stack.length - 1]; + if (!top || seen.has(top)) return; + seen.add(top); + if (top.kind !== 'selection') tours.signal(SIGNALS.edited); + }) + ); + + // play-entered, the first Play's disclosure, and the Play answer's quest after the game + /** @type {'edit' | 'play' | 'leaving'} */ + let phase = get(isLocked) === true ? 'play' : 'edit'; + off.push( + isLocked.subscribe((v) => { + const edge = playEdge(phase, v); + phase = edge.phase; + if (edge.entered) { + tours.signal(SIGNALS.played); + if (get(simpleUntilPlay)) simpleUntilPlay.set(false); + } + if (edge.left && afterPlay) { + const owed = afterPlay; + afterPlay = null; + // the remix quest, a beat after the editor is back (the toolbar re-mounts) + setTimeout(() => startFirstWin({ choice: 'play', entry: owed.entry }), 600); + } + }) + ); + + // remix / template: the scene the answer waits for has loaded + let lastLoad = get(templateLoaded); + off.push( + templateLoaded.subscribe((load) => { + if (!load || load === lastLoad) return; + lastLoad = load; + if (!afterLoad) return; + const choice = afterLoad; + afterLoad = ''; + setTimeout(() => startFirstWin({ choice, entry: load.entry }), 400); + }) + ); + + // peer-joined: a peer id we have not seen join before + const known = new Set(Object.keys(get(peerJoinedAt))); + off.push( + peerJoinedAt.subscribe((map) => { + let fresh = false; + for (const id of Object.keys(map)) { + if (known.has(id)) continue; + known.add(id); + fresh = true; + } + if (fresh) tours.signal(SIGNALS.joined); + }) + ); + + // how the quest ended + off.push( + tours.onAdvanced((e) => { + if (e.tour === FIRST_WIN_TOUR) advancedBy.add(e.step); + }) + ); + let wasQuest = get(activeTour)?.id === FIRST_WIN_TOUR; + off.push( + activeTour.subscribe((t) => { + const isQuest = t?.id === FIRST_WIN_TOUR; + if (wasQuest && !isQuest) { + if (questComplete(advancedBy)) { + ended({ kind: 'done' }); + celebrate(); + } else if (skippedAt) ended({ kind: 'skip', step: skippedAt }); + skippedAt = ''; + } + wasQuest = isQuest; + }) + ); + + return () => { + for (const fn of off.splice(0)) fn(); + startEditorTour = null; + }; +} + +/** the suites' view */ +export function firstWinDebug() { + return { + lastQuest, + advancedBy: [...advancedBy], + afterPlay: !!afterPlay, + afterLoad, + choice: get(onboardChoice), + simple: get(simpleUntilPlay) + }; +} diff --git a/src/lib/onboarding/onboardState.js b/src/lib/onboarding/onboardState.js new file mode 100644 index 000000000..88a84b08e --- /dev/null +++ b/src/lib/onboarding/onboardState.js @@ -0,0 +1,53 @@ +// 76 FW1 — the onboarding's LOCAL state, as a leaf (svelte/store + safeStorage only), so the +// toolbar, the logo menu and Settings can read it without joining the first-win module's imports. +// +// onboardChoice the Welcome card's answer ('' = never asked) — branches the flow, never sent +// simpleUntilPlay the progressive-disclosure switch: true only for a browser that met the new +// Welcome on its FIRST visit, until its first Play (or Settings ▸ Show everything). +// While on, the node editor / animation / pivot / click-mode buttons wait in the +// toolbar's "»" and the logo menu's Modules row under "More". A returning user +// never has it, so nobody who knows the app loses a button. + +import { writable } from 'svelte/store'; +import { safeStorage } from '../safeStorage'; + +const CHOICE_KEY = 'onboard.choice'; +const SIMPLE_KEY = 'onboard.simple'; + +/** @param {string} key @param {string} dflt */ +function stringPref(key, dflt) { + const raw = safeStorage.getItem(key); + const store = writable(raw === null ? dflt : raw); + store.subscribe((v) => { + if (v === dflt) safeStorage.removeItem(key); + else safeStorage.setItem(key, v); + }); + return store; +} + +/** @type {import('svelte/store').Writable} */ +export const onboardChoice = stringPref(CHOICE_KEY, ''); + +const simple = stringPref(SIMPLE_KEY, ''); +/** true while the advanced buttons wait behind More @type {import('svelte/store').Readable & {set(v: boolean): void}} */ +export const simpleUntilPlay = { + subscribe: (/** @type {(v: boolean) => void} */ fn) => simple.subscribe((v) => fn(v === 'true')), + set: (/** @type {boolean} */ on) => simple.set(on ? 'true' : '') +}; + +/** the bottom-bar ids that wait in "»" while `simpleUntilPlay` is on */ +export const ADVANCED_TOOLBAR = Object.freeze(['flow', 'animation', 'pivot', 'mode']); + +/** + * The bar row with the advanced ids pulled out, and those ids (in row order) for the "»" menu. + * Pure — Controls folds the rest by width as before. + * @param {string[]} row @param {boolean} on + * @returns {{row: string[], held: string[]}} + */ +export function holdAdvanced(row, on) { + if (!on) return { row, held: [] }; + return { + row: row.filter((id) => !ADVANCED_TOOLBAR.includes(id)), + held: row.filter((id) => ADVANCED_TOOLBAR.includes(id)) + }; +} diff --git a/src/lib/onboarding/quests.js b/src/lib/onboarding/quests.js new file mode 100644 index 000000000..ab34c1cea --- /dev/null +++ b/src/lib/onboarding/quests.js @@ -0,0 +1,149 @@ +// 76 FW1 — THE FIRST-WIN QUEST, as data (a LEAF: imports NOTHING, so the unit layer runs it with no +// browser). The Welcome card asks one question (Play / Remix / Template / Describe it, or an empty +// scene); every answer except Play ends in the same three steps on the tours engine: +// +// change change something advances on 'scene-edited' (a new undo entry) +// play press Play advances on 'play-entered' +// share invite a friend advances on 'share-copied' or 'peer-joined' +// +// Step 1's text is the scene's own task when it has one: a template / game index entry may carry +// `quest` (a short imperative, ≤ QUEST_MAX chars), and core knows a default for the bundled slugs so +// the quest reads right before the scenes index grows the field. Anything else gets the generic line. + +/** the tours-engine id of the quest */ +export const FIRST_WIN_TOUR = 'first-win'; +/** the signals the quest waits for (producers: firstWin.js) */ +export const SIGNALS = Object.freeze({ + edited: 'scene-edited', + played: 'play-entered', + copied: 'share-copied', + joined: 'peer-joined' +}); +/** the Welcome card's answers, in the order the card shows them */ +export const CHOICES = /** @type {const} */ (['play', 'remix', 'template', 'prompt', 'empty']); +/** longest quest text kept from an index entry (a card line, not a paragraph) */ +export const QUEST_MAX = 120; + +/** default task text per bundled slug (scenes repo index: games + templates) @type {Record} */ +export const DEFAULT_QUESTS = { + // games + towers: 'Make it yours: add a block of your own to the tower, or recolour one.', + 'stars-room': 'Make it yours: add a planet (a sphere) and give it a colour.', + football: 'Make it yours: recolour a goal, or move the ball somewhere new.', + 'jam-room': 'Make it yours: move an instrument to a new spot.', + 'dungeon-realms': 'Make it yours: add a crate or a light by the entrance.', + untangle: 'Make it yours: recolour the board or add a decoration.', + waves: 'Make it yours: add a wall to hide behind.', + 'escape-room': 'Make it yours: move a clue, or add a second door.', + 'marble-maze': 'Make it yours: add a block to the maze.', + 'mini-golf': 'Make it yours: add an obstacle on the green.', + 'sky-run': 'Make it yours: add a platform to the run.', + 'target-toss': 'Make it yours: add a target, or move one further away.', + race: 'Make it yours: add a ramp or a cone on the track.', + // templates + 'level-blockout': 'Add a cover block to the level, then walk around it.', + 'physics-playground': 'Add a box on top of the ramp and watch it slide.', + 'architecture-shell': 'Add a column or a wall to the room.', + 'castle-courtyard': 'Add a crate or a barrel to the courtyard.', + 'forest-clearing': 'Add a rock or a tree to the clearing.', + 'tavern-interior': 'Add a table or a stool to the tavern.', + 'wizards-tower': 'Add a bookshelf or a light to the tower.', + 'market-square': 'Add a stall or a crate to the square.' +}; + +/** the generic step-1 line, per choice */ +const GENERIC = { + empty: 'Start with one thing: add a shape, a light or a model.', + prompt: 'Change anything the assistant built: move it, recolour it, or add something.', + default: 'Make it yours: add something, or move or recolour what is there.' +}; + +/** + * The task text for an entry (an index row or anything with `slug` / `quest`). + * @param {{slug?: string, quest?: unknown} | null | undefined} entry + * @param {string} [choice] + * @returns {string} + */ +export function questFor(entry, choice = '') { + const own = typeof entry?.quest === 'string' ? entry.quest.trim().replace(/\s+/g, ' ') : ''; + if (own) return own.length > QUEST_MAX ? own.slice(0, QUEST_MAX - 1).trimEnd() + '…' : own; + const slug = String(entry?.slug ?? ''); + if (slug && Object.prototype.hasOwnProperty.call(DEFAULT_QUESTS, slug)) + return DEFAULT_QUESTS[slug]; + return choice === 'empty' + ? GENERIC.empty + : choice === 'prompt' + ? GENERIC.prompt + : GENERIC.default; +} + +/** keep the `quest` field of an index entry: a trimmed string, or '' @param {unknown} raw */ +export function normalizeQuest(raw) { + if (typeof raw !== 'string') return ''; + const text = raw.trim().replace(/\s+/g, ' '); + return text.length > QUEST_MAX ? text.slice(0, QUEST_MAX - 1).trimEnd() + '…' : text; +} + +/** + * The quest's three steps for this device (`touch` = a coarse pointer: fingers and the + button). + * Plain values, so the engine never calls back into anything. + * @param {{quest: string, touch?: boolean}} options + * @returns {import('../tours/engine.js').TourStep[]} + */ +export function firstWinSteps({ quest, touch = false }) { + return [ + { + id: 'change', + title: 'Make it yours', + body: + quest + + (touch + ? ' Tap + to add something, or tap an object and drag its arrows.' + : ' Right-click the viewport (or press +) to add something, or click an object and drag its arrows.'), + hint: 'Change anything', + target: 'add', + advanceOn: SIGNALS.edited + }, + { + id: 'play', + title: 'Now play it', + body: touch + ? 'Tap Play to try your change. Back returns you to editing.' + : 'Press Play to try your change. Esc brings you back to editing.', + hint: 'Press Play', + target: 'play', + advanceOn: SIGNALS.played + }, + { + id: 'share', + title: 'You made a game!', + body: 'Invite a friend: copy your link and send it — they join you in this scene, no account needed.', + hint: touch ? 'Tap your ID to copy the link' : 'Click your ID to copy the link', + target: 'connect', + advanceOn: [SIGNALS.copied, SIGNALS.joined] + } + ]; +} + +/** + * Did a finished quest end by doing every step (not Skip / Done pressed early)? + * @param {Set | string[]} advancedBy the step ids an ACTION completed + */ +export function questComplete(advancedBy) { + const done = new Set(advancedBy); + return ['change', 'play', 'share'].every((id) => done.has(id)); +} + +/** + * Play's store on the way in and out. `isLocked` walks true → false → null when Play ends + * (playMode's exit debounce: the transient false, then the editor back at null), so "the game + * ended" is the null AFTER a true, with the false in between remembered as 'leaving'. + * @param {'edit' | 'play' | 'leaving'} phase @param {boolean | null} v the store's new value + * @returns {{phase: 'edit' | 'play' | 'leaving', entered: boolean, left: boolean}} + */ +export function playEdge(phase, v) { + if (v === true) return { phase: 'play', entered: phase !== 'play', left: false }; + if (v === false) + return { phase: phase === 'play' ? 'leaving' : phase, entered: false, left: false }; + return { phase: 'edit', entered: false, left: phase !== 'edit' }; +} diff --git a/src/lib/sceneTemplates.js b/src/lib/sceneTemplates.js index e946bff68..0025efe32 100644 --- a/src/lib/sceneTemplates.js +++ b/src/lib/sceneTemplates.js @@ -12,6 +12,7 @@ import { primeLoadOrigin } from './gameIdentity.js'; // 33 (L3): the Clear scene modal's setup check and full clear (see confirmClearScene) import * as sceneSwitch from './sceneSwitch'; import { offerUndo } from './undoToast'; +import { normalizeQuest } from './onboarding/quests.js'; // Templates modal content (roadmap: "Templates" sidebar row → General/Examples/ // Community tabs). Two content sources, no bytes in this repo beyond the bundled @@ -68,6 +69,9 @@ export const games = writable([]); /** 'idle' | 'loading' | 'ready' (remote) | 'fallback' (bundled seed) | 'error' * @type {import('svelte/store').Writable} */ export const templatesState = writable('idle'); +/** 76 FW1: the last template / example / game that LOADED here ({entry, origin, at}) — the first-win + * quest starts from it (a remix or a template answer waits for its scene) @type {import('svelte/store').Writable} */ +export const templateLoaded = writable(null); /** normalized Community-tab entries @type {import('svelte/store').Writable} */ export const communityEntries = writable([]); /** 'idle' | 'loading' | 'ready' | 'empty' | 'error' @@ -114,6 +118,8 @@ function normalizeEntry(entry, base) { ) : [], bytes: entry.bytes || 0, + // 76 FW1: the first-win quest's task text for this scene ('' = core's default for the slug) + quest: normalizeQuest(entry.quest), sceneUrl: resolveUrl(entry.scene, base), thumbUrl: resolveUrl(entry.thumb, base) }; @@ -345,6 +351,7 @@ export async function loadRemoteScene(entry, opts = {}) { const isTemplate = origin === 'games' || origin === 'general' || origin === 'examples'; primeLoadOrigin(payload.id, { remote: true, keep: opts.keepGameId === true, template: isTemplate ? String(entry.slug || '') : '' }); const applied = await requestLoadSession(payload.id, { job }); + if (applied) templateLoaded.set({ entry, origin, at: Date.now() }); // (not for a click a newer one superseded — that template never opened) if (origin === 'games' && entry.slug && !job.cancelled) notifyTemplateOpen(String(entry.slug)); return applied; diff --git a/src/lib/settings/resetCategory.js b/src/lib/settings/resetCategory.js index 2411cce00..4153f2e01 100644 --- a/src/lib/settings/resetCategory.js +++ b/src/lib/settings/resetCategory.js @@ -63,6 +63,7 @@ import { motionPref, DEFAULT_MOTION } from '../motion'; import { editorCaptions } from '../captionsPref'; import { gameMusicVolume } from '../gameMusic'; import { showWelcomeOnStart, showWhatsNewNotice } from '../whatsNew'; +import { simpleUntilPlay } from '../onboarding/onboardState.js'; import { tours } from '../tours/index.js'; import { xrOfferEnabled } from '../xrOffer'; import { perfStatsShown } from '../fpsMeter'; @@ -143,6 +144,7 @@ const RESETS = { gameSoundVolume.set(0.8); gameMusicVolume.set(0.6); showWelcomeOnStart.set(false); + simpleUntilPlay.set(false); // 76 FW1: Show everything showWhatsNewNotice.set(true); toastsInDrawerOnly.set(false); tours.setAutoStart(true); diff --git a/src/lib/settingsIndex.js b/src/lib/settingsIndex.js index a4fea900b..4ec1e7d79 100644 --- a/src/lib/settingsIndex.js +++ b/src/lib/settingsIndex.js @@ -34,8 +34,10 @@ export const SETTINGS_ROWS = [ ["Interface", "Welcome on start", "Show the welcome card every time the app opens."], ["Interface", "Announce new versions", "After an update, mark the logo menu with a dot and show one toast linking to the changelog."], ["Interface", "Toasts in drawer only", "Hide ALL pop-up toasts in the viewport — including connection requests — so they appear only in the connect…"], - ["Interface", "Show tours automatically", "Start the short first-run tours by themselves: the editor tour after the welcome card, and \"Welcome to TheP…"], + ["Interface", "Show tours automatically", "Start the short first-run tours by themselves: the first-game quest after the welcome card, and \"Welcome to…"], ["Interface", "VR welcome", "Your controllers, moving, pointing, grabbing, the radial menu, playing and leaving VR."], + ["Interface", "First-game quest", "Three steps after the welcome card: change something, play it, invite a friend."], + ["Interface", "Show everything", "Off: the node editor, animation and other advanced buttons wait behind More until your first Play."], ["Interface", "Editor tour", "Six steps around the editor — adding things, looking around, the tools, Play, building together and the log…"], ["Interface", "Offer Enter VR", "In a headset's browser (Quest), let the browser show its own \"Enter VR\" button for this page, once per visit."], ["Interface", "Reset tours", "Forget which tours you have seen or skipped and turn automatic tours back on"], diff --git a/src/lib/tours/builtin.js b/src/lib/tours/builtin.js index 703612ac3..3ad4bcefb 100644 --- a/src/lib/tours/builtin.js +++ b/src/lib/tours/builtin.js @@ -5,9 +5,10 @@ // editor the desktop first-run tour (mouse + keyboard), ≤ 6 steps // editor-touch the same tour for a touch screen (fingers, the + button, on-screen controls) // -// FIRST RUN: the editor tour follows the first-visit Welcome card (when it closes — and not while -// the Templates window it may have opened is still up); a visit that skipped the Welcome (an -// invite or a scene link) is somebody who came for the session, so no tour either. The VR welcome +// FIRST RUN (76 FW1): the first-visit Welcome card asks one question and its answer starts the +// three-step first-win quest (onboarding/); the editor tour is OPTIONAL now — started from the logo +// menu ▸ Tours or Settings. A visit that skipped the Welcome (an invite or a scene link) is somebody +// who came for the session, so it gets neither. The VR welcome // starts a moment after the first session begins. Under test (the debug hook is on) nothing // starts by itself unless the suite opts in with `toursUnderTest` — the whatsNew precedent: the // whole e2e battery must not grow a card over the canvas. @@ -17,9 +18,9 @@ import { tours, activeTour } from './index.js'; import { safeStorage } from '../safeStorage'; import { isVRMode, vrMenuOpen, editorMode } from '../../stores/sceneStore'; import { bindingOf, bindingLabel, vrBindings } from '../vr/bindings.js'; -import { templatesModalOpen, showToast } from '../../stores/appStore.js'; -import { welcomeOpen } from '../whatsNew'; +import { showToast } from '../../stores/appStore.js'; import { coarsePointer } from '../inputDevice'; +import { installFirstWin } from '../onboarding/firstWin.js'; // 76 FW1 import { HDRI_TOUR, HDRI_STEPS } from '../hdri/hdriTour.js'; // 37-hdri export const VR_TOUR = 'vr-welcome'; @@ -288,31 +289,12 @@ export function installTours() { }) ); - // ---- the editor tour: after the first-visit Welcome card closes ---- - let welcomeWasOpen = get(welcomeOpen); - let owed = false; - /** @type {any} */ let editorTimer = null; - const tryEditor = () => { - clearTimeout(editorTimer); - if (!owed || get(welcomeOpen) || get(templatesModalOpen) || get(isVRMode)) return; - editorTimer = setTimeout(() => { - if (!owed || get(welcomeOpen) || get(templatesModalOpen) || get(isVRMode)) return; - owed = false; - tours.maybeAutoStart(editorTourId()); - }, EDITOR_START_DELAY); - }; - teardown.push( - welcomeOpen.subscribe((open) => { - if (welcomeWasOpen && !open && autoStartAllowed()) owed = true; - welcomeWasOpen = open; - tryEditor(); - }) - ); - teardown.push(templatesModalOpen.subscribe(() => tryEditor())); - teardown.push(() => { - clearTimeout(vrTimer); - clearTimeout(editorTimer); - }); + // ---- 76 FW1: the first-win quest replaces the editor tour as what follows the Welcome ---- + // The Welcome card's answer starts (or arms) the three-step quest itself (onboarding/firstWin.js); + // the six-step interface tour no longer starts by itself — it is "Tour the interface" in the logo + // menu ▸ Tours, Settings ▸ Tours and the quest's closing toast (user-started tours finish more often). + teardown.push(installFirstWin({ startEditorTour: () => startEditorTour() })); + teardown.push(() => clearTimeout(vrTimer)); return () => { for (const fn of teardown.splice(0)) fn(); }; @@ -320,8 +302,6 @@ export function installTours() { /** ms after the session starts before the welcome shows (controller profiles arrive late) */ export const VR_START_DELAY = 1200; -/** ms after the Welcome card closes before the editor tour's first card */ -export const EDITOR_START_DELAY = 500; /** the last controller family a session reported (the desktop preview draws the same pair) */ export const FAMILY_KEY = 'tourControllerFamily'; diff --git a/src/lib/tours/engine.js b/src/lib/tours/engine.js index 82c08870f..cb86b89bb 100644 --- a/src/lib/tours/engine.js +++ b/src/lib/tours/engine.js @@ -43,9 +43,13 @@ * hint?: string, controls?: Controls, waiting: string[]}} ShownStep */ /** - * @typedef {{title: string, surface?: 'card' | 'vr', steps: TourStep[]}} TourDef + * @typedef {{title: string, surface?: 'card' | 'vr', hideWhilePlaying?: boolean, quiet?: boolean, steps: TourStep[]}} TourDef + * `hideWhilePlaying` (76 FW1): the card stands down while the game runs (the first-win quest waits for + * Play, and a card over the game would cover it) and comes back when you are editing again. `quiet`: + * the user WORKS while it shows (a quest, not a tour), so the target gets its ring without the scrim. * @typedef {{id: string, title: string, surface: 'card' | 'vr', index: number, total: number, - * step: ShownStep, first: boolean, last: boolean, waiting: string[], preview: boolean}} ActiveTour + * step: ShownStep, first: boolean, last: boolean, waiting: string[], preview: boolean, + * hideWhilePlaying: boolean, quiet: boolean}} ActiveTour * @typedef {{getItem: (k: string) => string | null, setItem: (k: string, v: string) => void, * removeItem: (k: string) => void}} TourStorage */ @@ -65,6 +69,8 @@ export function createTourEngine({ storage, onChange = () => {} }) { let current = null; /** signal counts, for the suites (which action advanced what) @type {Record} */ const signals = {}; + /** 76 FW1: who hears that an ACTION (not Next) completed a step @type {Set<(e: {tour: string, step: string, signal: string}) => void>} */ + const advancedListeners = new Set(); /** @param {string} key */ function read(key) { @@ -137,6 +143,8 @@ export function createTourEngine({ storage, onChange = () => {} }) { total: current.steps.length, step, preview: current.preview, + hideWhilePlaying: !!def.hideWhilePlaying, + quiet: !!def.quiet, first: current.index === 0, last: current.index === current.steps.length - 1, waiting @@ -242,10 +250,27 @@ export function createTourEngine({ storage, onChange = () => {} }) { signals[name] = (signals[name] ?? 0) + 1; const now = active(); if (!now || !now.waiting.includes(name)) return false; + // heard BEFORE the move: on the last step the move ends the tour, and whoever reads "how did it + // end" on that change must already know this step was done + const done = { tour: now.id, step: now.step.id, signal: name }; + for (const fn of advancedListeners) { + try { + fn(done); + } catch { + /* a listener that throws must not wedge the engine */ + } + } next(); return true; } + /** 76 FW1: hear every step an action completed (not Next / Back / Skip) → unsubscribe + * @param {(e: {tour: string, step: string, signal: string}) => void} fn */ + function onAdvanced(fn) { + advancedListeners.add(fn); + return () => advancedListeners.delete(fn); + } + /** Re-resolve the current step (its text/controls are functions of settings — a VR button remap * mid-step must reach the surfaces, which only hear `onChange`). */ function refresh() { @@ -280,6 +305,7 @@ export function createTourEngine({ storage, onChange = () => {} }) { close, refresh, signal, + onAdvanced, seen, status, reset, diff --git a/src/lib/whatsNew.js b/src/lib/whatsNew.js index 507c60da2..05305259d 100644 --- a/src/lib/whatsNew.js +++ b/src/lib/whatsNew.js @@ -11,6 +11,7 @@ import { showToast } from '../stores/appStore.js'; // The changelog ships as the repo-root CHANGELOG.md (GitHub renders the same file). import changelogRaw from '../../CHANGELOG.md?raw'; import { safeStorage } from './safeStorage'; +import { simpleUntilPlay } from './onboarding/onboardState.js'; /** Raw markdown of the changelog, rendered by WhatsNew.svelte. */ export const CHANGELOG = String(changelogRaw || ''); @@ -160,6 +161,9 @@ export function startWhatsNew() { const skipEnv = !underTest && String(import.meta.env.VITE_SKIP_WELCOME ?? '') === 'true'; const welcomeThisBoot = !invited && !skipEnv && (firstVisit || get(showWelcomeOnStart)); if (welcomeThisBoot) welcomeOpen.set(true); + // 76 FW1: a browser meeting the new Welcome on its FIRST visit starts with the advanced buttons + // behind More, until its first Play (onboarding/onboardState.js) — never a returning user + if (welcomeThisBoot && firstVisit) simpleUntilPlay.set(true); if (firstVisit) { // The welcome overlay IS the announcement — the current version counts as // seen so the update badge can't fire on top of it. diff --git a/tests/e2e/battery.json b/tests/e2e/battery.json index a94c07622..c2917a53c 100644 --- a/tests/e2e/battery.json +++ b/tests/e2e/battery.json @@ -503,6 +503,7 @@ "object-delete": 43, "object-list-keys": 106, "object-list-modules": 68, + "onboarding": 150, "overload-guard": 60, "pack-refs": 42, "packs": 70, diff --git a/tests/e2e/onboarding.test.cjs b/tests/e2e/onboarding.test.cjs new file mode 100644 index 000000000..c4fd8018f --- /dev/null +++ b/tests/e2e/onboarding.test.cjs @@ -0,0 +1,434 @@ +// 76 FW1 — THE FIRST WIN, end to end. +// +// A desktop first visit: the Welcome asks ONE question (Play / Remix / Template / Describe it + +// a quiet Empty scene), keyboard lands on the first answer; the advanced toolbar buttons wait +// in "»" and the logo menu's Modules under More; Template → a list → load → the first-win quest +// with the template's own task; a new undo entry completes "change", Play completes "play" (the +// card stands down while playing, and the first Play shows every button again), copying the +// invite completes "share" → the celebration toast. Dark + light shots. +// B Play: a game opens straight in Play, and the remix quest follows once the game ends. +// C Describe it with no AI provider says what is needed and offers Settings ▸ AI. +// D two peers: a peer JOINING completes the share step (the peer-joined producer). +// E the phone (OPPO Find N6 folded 390×896 and unfolded 770×850, DPR 2.9, touch): the Welcome +// fits with no sideways scroll, the quest card is a sheet that fits. +// Guard: under test without `toursUnderTest`, an answer starts no quest (the battery stays clean). +// +// ONBOARD_SHOTS= writes the screenshots. ONBOARD_PART=a|b|c|d|e|all. +const h = require('./helpers.cjs'); +const fs = require('fs'); +const path = require('path'); + +const SHOTS = process.env.ONBOARD_SHOTS || ''; +if (SHOTS) fs.mkdirSync(SHOTS, { recursive: true }); +const PART = process.env.ONBOARD_PART || 'all'; +const on = (p) => PART === 'all' || PART.split(',').includes(p); + +const N6_FOLDED = { + viewport: { width: 390, height: 896 }, + deviceScaleFactor: 2.9, + hasTouch: true, + isMobile: true +}; +const N6_UNFOLDED = { + viewport: { width: 770, height: 850 }, + deviceScaleFactor: 2.9, + hasTouch: true, + isMobile: true +}; + +async function shot(page, name) { + if (SHOTS) await page.screenshot({ path: path.join(SHOTS, name + '.png') }); +} +/** a store's current value (the debug hooks expose the stores) */ +const read = (page, expr) => + page.evaluate((expr) => { + const s = window.__stores; + // eslint-disable-next-line no-new-func + const store = new Function('s', 'return ' + expr)(s); + let v; + store.subscribe((x) => (v = x))(); + return v; + }, expr); +const tourId = (page) => + page + .locator('#tour-card') + .getAttribute('data-tour-id') + .catch(() => null); +const tourStep = (page) => + page + .locator('#tour-card') + .getAttribute('data-step') + .catch(() => null); + +/** a fresh first visit (no hasSeenWelcome) with the debug hooks, like whats-new / tours */ +async function firstVisit(browser, storage, context) { + const ctx = await browser.newContext({ ignoreHTTPSErrors: true, ...(context ?? {}) }); + await ctx.grantPermissions(['clipboard-read', 'clipboard-write']).catch(() => {}); + await ctx.addInitScript((seed) => { + localStorage.setItem('debugStores', 'true'); + localStorage.setItem('hasSeenDisclaimer', 'true'); + for (const [k, v] of Object.entries(seed || {})) + if (localStorage.getItem(k) === null) localStorage.setItem(k, v); + }, storage || {}); + const page = await ctx.newPage(); + page.__errors = []; + page.on('pageerror', (err) => { + page.__errors.push(err.message); + console.log('[pageerror] ' + err.stack); + }); + await page.goto(h.URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.waitForFunction( + () => window.__stores && !!window.__stores.firstWin && !!window.__stores.whatsNew, + null, + { timeout: 90000 } + ); + await page.locator('#welcome-overlay').waitFor({ state: 'visible', timeout: 15000 }); + await page.waitForTimeout(600); + return { ctx, page }; +} + +async function noSideScroll(page) { + return page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1); +} +async function fitsViewport(page, sel) { + return page.evaluate((sel) => { + const r = document.querySelector(sel)?.getBoundingClientRect(); + return ( + !!r && + r.left >= -1 && + r.top >= -1 && + r.right <= window.innerWidth + 1 && + r.bottom <= window.innerHeight + 1 + ); + }, sel); +} + +async function partA(browser) { + // ---- the guard: under test without the opt-in, an answer starts nothing ---------------- + const G = await firstVisit(browser, {}); + await G.page.locator('#welcome-start').click(); + await G.page.waitForTimeout(1500); + h.check( + (await G.page.locator('#tour-card').count()) === 0, + 'FW1 guard: under test without toursUnderTest the Empty-scene answer starts no quest' + ); + await G.ctx.close(); + + const A = await firstVisit(browser, { toursUnderTest: 'true' }); + const page = A.page; + // ---- the one question -------------------------------------------------------------------- + const choices = await page + .locator('#welcome-overlay [data-choice]') + .evaluateAll((els) => els.map((e) => e.getAttribute('data-choice'))); + h.check( + JSON.stringify(choices) === JSON.stringify(['play', 'remix', 'template', 'prompt']), + 'FW1: the Welcome asks one question with four answers (' + choices + ')' + ); + h.check( + (await page.locator('#welcome-question').innerText()).includes('What do you want to do?'), + 'FW1: the question reads "What do you want to do?"' + ); + h.check( + await page.locator('#welcome-start').isVisible(), + 'FW1: a quiet "Empty scene" link sits below the answers' + ); + h.check( + (await page.evaluate(() => document.activeElement?.id)) === 'welcome-play', + 'FW1: keyboard focus lands on the first answer' + ); + await page.keyboard.press('Tab'); + h.check( + (await page.evaluate(() => document.activeElement?.id)) === 'welcome-remix', + 'FW1: Tab walks the answers in order' + ); + await shot(page, '01-welcome-desktop-dark'); + await page.evaluate(() => window.__stores.themes.theme.set('light')); + await page.waitForTimeout(300); + await shot(page, '02-welcome-desktop-light'); + await page.evaluate(() => window.__stores.themes.theme.set('dark')); + + // ---- progressive disclosure: a first visit starts simple -------------------------------- + h.check( + (await read(page, 's.onboardState.simpleUntilPlay')) === true, + 'FW1: a first visit that met the Welcome starts with simpleUntilPlay on' + ); + + // ---- Template → list → load → the quest --------------------------------------------------- + await page.locator('#welcome-template').click(); + await page + .locator('#welcome-list [data-scene-slug]') + .first() + .waitFor({ state: 'visible', timeout: 20000 }); + h.check( + (await page.locator('#welcome-overlay').getAttribute('data-view')) === 'template', + 'FW1: Start from a template opens the template list in the card' + ); + await shot(page, '03-welcome-template-list'); + await page.keyboard.press('Escape'); + h.check( + (await page.locator('#welcome-overlay').getAttribute('data-view')) === 'ask', + 'FW1: Esc in a list goes back to the question' + ); + await page.locator('#welcome-template').click(); + const first = page.locator('#welcome-list [data-scene-slug]').first(); + await first.waitFor({ state: 'visible', timeout: 20000 }); + const slug = await first.getAttribute('data-scene-slug'); + await first.click(); + await page + .locator('#tour-card[data-tour-id="first-win"]') + .waitFor({ state: 'visible', timeout: 30000 }); + h.check( + (await tourStep(page)) === 'change', + 'FW1: the template load starts the first-win quest on "change" (' + slug + ')' + ); + const debug = await page.evaluate(() => window.__stores.firstWin.firstWinDebug()); + const body = await page.locator('#tour-card .tour-body').innerText(); + h.check( + debug.choice === 'template' && body.startsWith(debug.lastQuest.quest), + 'FW1: step 1 carries the template’s own task ("' + debug.lastQuest.quest + '")' + ); + // the simple toolbar: the advanced buttons wait in "»" + const folded = await page + .locator('#toolbar-overflow') + .getAttribute('data-folded') + .catch(() => ''); + h.check( + ['flow', 'animation', 'mode'].every((id) => (folded || '').split(',').includes(id)), + 'FW1: the node editor / animation / click mode wait in "»" (' + folded + ')' + ); + await page.locator('#logo-menu').click(); + await page.waitForTimeout(300); + h.check( + (await page.locator('#open-modules-manager').count()) === 0 && + (await page.locator('#sidebar-more').isVisible()), + 'FW1: the logo menu holds Modules under More' + ); + await page.locator('#sidebar-more').click(); + h.check(await page.locator('#open-modules-manager').isVisible(), 'FW1: More shows Modules'); + await shot(page, '04-logo-menu-more'); + await page.keyboard.press('Escape'); + await page.evaluate(() => window.__stores.closeMenu?.set?.(true)); + await page.waitForTimeout(300); + await shot(page, '05-quest-change-dark'); + const spotQuiet = await page + .locator('#tour-spotlight') + .evaluate((el) => getComputedStyle(el).boxShadow) + .catch(() => 'none'); + h.check( + spotQuiet === 'none', + 'FW1: the quest rings its target without dimming the viewport (' + spotQuiet + ')' + ); + + // ---- change: a new undo entry ------------------------------------------------------------- + await page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box')); + await h.eventually( + () => tourStep(page), + (s) => s === 'play', + 'FW1: adding an object completes "change" → "play"' + ); + await shot(page, '06-quest-play'); + + // ---- play: the card stands down while playing, and the first Play unlocks every button ---- + await page.locator('#play-button').click(); + await h.eventually( + () => read(page, 's.isLocked'), + (v) => v === true, + 'FW1: Play starts' + ); + await page.waitForTimeout(500); + h.check( + (await page.locator('#tour-card').count()) === 0, + 'FW1: the quest card stands down while the game runs' + ); + h.check( + (await read(page, 's.onboardState.simpleUntilPlay')) === false, + 'FW1: the first Play turns simpleUntilPlay off' + ); + await h.leavePlay(page); + await page.locator('#tour-card').waitFor({ state: 'visible', timeout: 8000 }); + h.check((await tourStep(page)) === 'share', 'FW1: back in the editor the quest is on "share"'); + const foldedAfter = await page + .locator('#toolbar-overflow') + .getAttribute('data-folded') + .catch(() => ''); + h.check( + !(foldedAfter || '').split(',').includes('flow'), + 'FW1: after the first Play the node editor is back on the bar (' + + (foldedAfter || 'nothing folded') + + ')' + ); + await shot(page, '07-quest-share'); + + // ---- share: copying the invite ends the quest with the celebration ------------------------ + await page.locator('.cx-id').first().click(); + await h.eventually( + () => page.locator('#tour-card').count(), + (n) => n === 0, + 'FW1: copying the invite completes "share" and ends the quest' + ); + const toast = await page + .locator('.tp-toast-text', { hasText: 'First win' }) + .first() + .innerText() + .catch(() => ''); + h.check(toast.includes('First win'), 'FW1: the quest ends with the celebration toast'); + h.check( + (await page.locator('.tp-toast .tp-toast-action', { hasText: 'Tour the interface' }).count()) >= + 1, + 'FW1: …which offers "Tour the interface" (the optional editor tour)' + ); + const done = await page.evaluate(() => window.__stores.firstWin.firstWinDebug()); + h.check( + JSON.stringify(done.advancedBy.sort()) === JSON.stringify(['change', 'play', 'share']), + 'FW1: every step was completed by doing it (' + done.advancedBy + ')' + ); + await shot(page, '08-first-win-toast'); + await page + .locator('.tp-toast .tp-toast-action', { hasText: 'Tour the interface' }) + .first() + .click(); + await h.eventually( + () => tourId(page), + (id) => id === 'editor', + 'FW1: "Tour the interface" starts the editor tour' + ); + await page.locator('#tour-skip').click(); + h.check( + A.page.__errors.length === 0, + 'FW1: no page errors (' + A.page.__errors.join(' | ') + ')' + ); + await A.ctx.close(); +} + +async function partB(browser) { + // Play: no network dependency — the Games list is fed the bundled templates for this check + const B = await firstVisit(browser, { toursUnderTest: 'true' }); + const page = B.page; + await page.evaluate(async () => { + const st = window.__stores.sceneTemplates; + await st.loadTemplatesIndex(); + let tpl; + st.templates.subscribe((v) => (tpl = v))(); + st.games.set(tpl.slice(0, 2).map((e) => ({ ...e, slug: e.slug, quest: '' }))); + }); + await page.locator('#welcome-play').click(); + const game = page.locator('#welcome-list [data-scene-slug]').first(); + await game.waitFor({ state: 'visible', timeout: 15000 }); + await shot(page, '10-welcome-play-list'); + await game.click(); + await h.eventually( + () => read(page, 's.isLocked'), + (v) => v === true, + 'FW1 Play: the game opens straight in Play', + 30000 + ); + h.check( + (await page.locator('#tour-card').count()) === 0, + 'FW1 Play: no quest while the game runs' + ); + await h.leavePlay(page); + await page + .locator('#tour-card[data-tour-id="first-win"]') + .waitFor({ state: 'visible', timeout: 8000 }); + const dbg = await page.evaluate(() => window.__stores.firstWin.firstWinDebug()); + h.check( + (await tourStep(page)) === 'change' && dbg.choice === 'play', + 'FW1 Play: when the game ends the remix quest starts ("make it yours")' + ); + await B.ctx.close(); +} + +async function partC(browser) { + const C = await firstVisit(browser, { toursUnderTest: 'true' }); + const page = C.page; + await page.locator('#welcome-prompt').click(); + h.check( + await page.locator('#welcome-no-ai').isVisible(), + 'FW1 Describe: with no AI provider the card says one is needed' + ); + await shot(page, '11-welcome-describe-no-ai'); + await page.locator('#welcome-ai-setup').click(); + await page.waitForTimeout(600); + h.check( + !(await page.locator('#welcome-overlay').isVisible()) && + (await read(page, 's.settingsOpen')) === true, + 'FW1 Describe: "Set up in Settings ▸ AI" opens Settings' + ); + await C.ctx.close(); +} + +async function partD(browser) { + // two peers: the joiner's arrival completes the share step on the host + const A = await h.setupPage(browser, 'A', { storage: { toursUnderTest: 'true' } }); + const B = await h.setupPage(browser, 'B'); + await A.page.evaluate(() => { + const s = window.__stores; + s.firstWin.startFirstWin({ choice: 'empty', force: true }); + s.tours.tours.signal('scene-edited'); + s.tours.tours.signal('play-entered'); + }); + await h.eventually( + () => tourStep(A.page), + (st) => st === 'share', + 'FW1 two-peer: the host’s quest waits on "share"' + ); + await h.connect(B, A); + await h.eventually( + () => A.page.locator('#tour-card').count(), + (n) => n === 0, + 'FW1 two-peer: a peer joining completes "share"', + 20000 + ); + const dbg = await A.page.evaluate(() => window.__stores.firstWin.firstWinDebug()); + h.check( + dbg.advancedBy.includes('share'), + 'FW1 two-peer: the share step was completed by the join (' + dbg.advancedBy + ')' + ); + await h.closePeer(A); + await h.closePeer(B); +} + +async function partE(browser) { + for (const [label, context] of [ + ['folded', N6_FOLDED], + ['unfolded', N6_UNFOLDED] + ]) { + const P = await firstVisit(browser, { toursUnderTest: 'true' }, context); + const page = P.page; + h.check(await noSideScroll(page), `FW1 N6 ${label}: no sideways scroll with the Welcome up`); + h.check( + await fitsViewport(page, '#welcome-overlay'), + `FW1 N6 ${label}: the Welcome card fits the screen` + ); + for (const id of ['#welcome-play', '#welcome-remix', '#welcome-template', '#welcome-prompt']) { + h.check(await page.locator(id).isVisible(), `FW1 N6 ${label}: ${id} is visible`); + } + await shot(page, `20-welcome-n6-${label}-dark`); + await page.evaluate(() => window.__stores.themes.theme.set('light')); + await page.waitForTimeout(300); + await shot(page, `21-welcome-n6-${label}-light`); + await page.evaluate(() => window.__stores.themes.theme.set('dark')); + await page.locator('#welcome-start').tap(); + await page + .locator('#tour-card[data-tour-id="first-win"]') + .waitFor({ state: 'visible', timeout: 8000 }); + const body = await page.locator('#tour-card .tour-body').innerText(); + h.check(/Tap \+/.test(body), `FW1 N6 ${label}: the quest speaks of taps on a touch screen`); + h.check( + await fitsViewport(page, '#tour-card'), + `FW1 N6 ${label}: the quest card fits the screen` + ); + await shot(page, `22-quest-n6-${label}`); + await P.ctx.close(); + } +} + +h.run(async () => { + const browser = await h.launch(); + if (on('a')) await partA(browser); + if (on('b')) await partB(browser); + if (on('c')) await partC(browser); + if (on('d')) await partD(browser); + if (on('e')) await partE(browser); + await h.finish(browser); +}); diff --git a/tests/e2e/tours.test.cjs b/tests/e2e/tours.test.cjs index a4f6fb296..e8b874636 100644 --- a/tests/e2e/tours.test.cjs +++ b/tests/e2e/tours.test.cjs @@ -6,8 +6,9 @@ // Y → Interact, leaving VR → done) and the panel's own buttons press with the trigger; // re-entering (and a reload) shows nothing; Skip persists; an interrupted tour resumes; // the radial's Settings ▸ Welcome tour and Settings ▸ Tours ▸ Reset bring it back. -// Screen: the editor tour follows the first-visit Welcome card (and NOT under test without -// the opt-in — the guard that keeps the battery clean), its card never covers its target, +// Screen: the first-visit Welcome card's answer starts the first-win quest (76 FW1; NOT under test +// without the opt-in — the guard that keeps the battery clean); the editor tour is started from +// the logo menu, its card never covers its target, // Settings / the logo menu start it, "Don't show again" turns auto-start off, the VR // welcome previews on a screen without being marked seen, the touch variant on a phone. // A2: with a fake headset browser, the page offers immersive-VR (not threlte's AR default) @@ -262,7 +263,15 @@ async function screenPart(browser) { h.check((await page.locator('#tour-card').count()) === 0, 'I5: …and no tour on top of it'); await page.locator('#welcome-start').click(); await page.locator('#tour-card').waitFor({ state: 'visible', timeout: 5000 }); - h.check((await page.locator('#tour-card').getAttribute('data-tour-id')) === 'editor', 'I5: closing the Welcome starts the desktop editor tour'); + // 76 FW1: the Welcome's answer starts the first-win QUEST now; the editor tour is optional + h.check((await page.locator('#tour-card').getAttribute('data-tour-id')) === 'first-win', 'FW1: closing the Welcome (Empty scene) starts the first-win quest, not the editor tour'); + await page.locator('#tour-skip').click(); + await page.waitForTimeout(300); + h.check((await page.locator('#tour-card').count()) === 0, 'FW1: Skip closes the quest'); + await page.locator('#logo-menu').click(); + await page.locator('#open-tour').click(); + await page.locator('#tour-card').waitFor({ state: 'visible', timeout: 5000 }); + h.check((await page.locator('#tour-card').getAttribute('data-tour-id')) === 'editor', 'I5: logo menu ▸ Tour the interface starts the desktop editor tour'); const STEPS = ['add', 'navigate', 'tools', 'play', 'connect', 'menu']; const TARGET = { add: '#mobile-add-button', tools: '#controls-pill', play: '#play-button', connect: '.connect-pill', menu: '#logo-menu' }; for (let i = 0; i < STEPS.length; i++) { @@ -345,7 +354,9 @@ async function screenPart(browser) { ); await T.page.locator('#welcome-start').click(); await T.page.locator('#tour-card').waitFor({ state: 'visible', timeout: 5000 }); - h.check((await T.page.locator('#tour-card').getAttribute('data-tour-id')) === 'editor-touch', 'I5: a touch screen gets the touch tour'); + h.check((await T.page.locator('#tour-card').getAttribute('data-tour-id')) === 'first-win', 'FW1: a phone gets the first-win quest after the Welcome'); + await T.page.evaluate(() => window.__stores.toursBuiltin.startEditorTour()); + await h.eventually(() => T.page.locator('#tour-card').getAttribute('data-tour-id'), (id) => id === 'editor-touch', 'I5: a touch screen gets the touch tour'); h.check(/sheet/.test(await T.page.locator('#tour-card').getAttribute('data-side')), 'I5: on a phone the card is a sheet'); const tcard = await rectOf(T.page, '#tour-card'); h.check(tcard.x >= 0 && tcard.x + tcard.w <= 412 && tcard.y + tcard.h <= 915, 'I5: the sheet fits the phone'); diff --git a/tests/e2e/whats-new.test.cjs b/tests/e2e/whats-new.test.cjs index f499ae5b3..1490d96fb 100644 --- a/tests/e2e/whats-new.test.cjs +++ b/tests/e2e/whats-new.test.cjs @@ -20,7 +20,7 @@ async function firstVisitPage(browser, seed) { const page = await ctx.newPage(); page.on('pageerror', (err) => console.log('[pageerror] ' + err.stack)); await page.goto(h.URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); - await page.waitForFunction(() => window.__stores && !!window.__stores.whatsNew, { timeout: 30000 }); + await page.waitForFunction(() => window.__stores && !!window.__stores.whatsNew, null, { timeout: 90000 }); await page.waitForTimeout(600); return { ctx, page }; } @@ -60,7 +60,7 @@ h.run(async () => { // --- second visit: nothing greets the user --------------------------------- await A.page.reload({ waitUntil: 'domcontentloaded' }); - await A.page.waitForFunction(() => window.__stores && !!window.__stores.whatsNew, { timeout: 30000 }); + await A.page.waitForFunction(() => window.__stores && !!window.__stores.whatsNew, null, { timeout: 90000 }); await A.page.waitForTimeout(600); h.check(!(await A.page.locator('#welcome-overlay').isVisible()), 'RW: a returning user gets no overlay'); await A.ctx.close(); @@ -145,7 +145,7 @@ h.run(async () => { // a reload no longer nags await B.page.reload({ waitUntil: 'domcontentloaded' }); - await B.page.waitForFunction(() => window.__stores && !!window.__stores.whatsNew, { timeout: 30000 }); + await B.page.waitForFunction(() => window.__stores && !!window.__stores.whatsNew, null, { timeout: 90000 }); await B.page.waitForTimeout(600); h.check( !(await B.page.locator('#logo-menu .update-dot').isVisible().catch(() => false)), diff --git a/tests/unit/onboardingQuests.test.js b/tests/unit/onboardingQuests.test.js new file mode 100644 index 000000000..efa5f7152 --- /dev/null +++ b/tests/unit/onboardingQuests.test.js @@ -0,0 +1,198 @@ +// @ts-nocheck — fixtures build partial step objects +import { describe, it, expect } from 'vitest'; +import { createTourEngine } from '../../src/lib/tours/engine.js'; +import { + FIRST_WIN_TOUR, + SIGNALS, + DEFAULT_QUESTS, + QUEST_MAX, + questFor, + normalizeQuest, + firstWinSteps, + questComplete, + playEdge +} from '../../src/lib/onboarding/quests.js'; +import { holdAdvanced, ADVANCED_TOOLBAR } from '../../src/lib/onboarding/onboardState.js'; + +// 76 FW1: the first-win quest's data, the engine's two additions it rides on, and the toolbar hold. +function memoryStorage() { + const map = new Map(); + return { + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => map.set(k, String(v)), + removeItem: (k) => map.delete(k) + }; +} + +describe('quest text', () => { + it('an index entry carries its own task, trimmed and capped', () => { + expect(questFor({ slug: 'towers', quest: ' Stack three more blocks. ' })).toBe( + 'Stack three more blocks.' + ); + const long = questFor({ slug: 'x', quest: 'a'.repeat(400) }); + expect(long.length).toBe(QUEST_MAX); + expect(long.endsWith('…')).toBe(true); + }); + it('a bundled slug falls back to core’s default, anything else to the generic line', () => { + expect(questFor({ slug: 'mini-golf' })).toBe(DEFAULT_QUESTS['mini-golf']); + expect(questFor({ slug: 'nobody-knows' })).toMatch(/^Make it yours/); + expect(questFor(null, 'empty')).toMatch(/^Start with one thing/); + expect(questFor(undefined, 'prompt')).toMatch(/assistant/); + }); + it('every one of the 13 games and the 8 templates has a default under the cap', () => { + const games = [ + 'towers', + 'stars-room', + 'football', + 'jam-room', + 'dungeon-realms', + 'untangle', + 'waves', + 'escape-room', + 'marble-maze', + 'mini-golf', + 'sky-run', + 'target-toss', + 'race' + ]; + const tpls = [ + 'level-blockout', + 'physics-playground', + 'architecture-shell', + 'castle-courtyard', + 'forest-clearing', + 'tavern-interior', + 'wizards-tower', + 'market-square' + ]; + for (const slug of [...games, ...tpls]) { + expect(DEFAULT_QUESTS[slug], slug).toBeTruthy(); + expect(DEFAULT_QUESTS[slug].length).toBeLessThanOrEqual(QUEST_MAX); + } + }); + it('normalizeQuest keeps only a string (the index field is optional)', () => { + expect(normalizeQuest(undefined)).toBe(''); + expect(normalizeQuest(42)).toBe(''); + expect(normalizeQuest(' Add a door ')).toBe('Add a door'); + }); +}); + +describe('the three steps', () => { + it('change → play → share, each waiting for the action that completes it', () => { + const steps = firstWinSteps({ quest: 'Add a door.' }); + expect(steps.map((s) => s.id)).toEqual(['change', 'play', 'share']); + expect(steps[0].advanceOn).toBe(SIGNALS.edited); + expect(steps[1].advanceOn).toBe(SIGNALS.played); + expect(steps[2].advanceOn).toEqual([SIGNALS.copied, SIGNALS.joined]); + expect(steps[0].body.startsWith('Add a door.')).toBe(true); + expect(steps.map((s) => s.target)).toEqual(['add', 'play', 'connect']); + }); + it('the touch variant speaks of taps', () => { + const steps = firstWinSteps({ quest: 'Q.', touch: true }); + expect(steps[0].body).toMatch(/Tap \+/); + expect(steps[1].body).toMatch(/Tap Play/); + }); + it('questComplete needs every step done by doing it', () => { + expect(questComplete(['change', 'play', 'share'])).toBe(true); + expect(questComplete(new Set(['change', 'play']))).toBe(false); + }); +}); + +describe('Play in and out (the Play answer waits for the game to end)', () => { + /** @param {(boolean | null)[]} values */ + const walk = (values, phase = 'edit') => { + const out = []; + for (const v of values) { + const e = playEdge(phase, v); + phase = e.phase; + out.push(e.entered ? 'in' : e.left ? 'out' : '-'); + } + return out; + }; + it('leaving goes true → false → null: the game has ended at the null, not the false', () => { + expect(walk([true, false, null])).toEqual(['in', '-', 'out']); + }); + it('a straight true → null ends it too; the editor at rest ends nothing', () => { + expect(walk([true, null])).toEqual(['in', 'out']); + expect(walk([null, false, null])).toEqual(['-', '-', '-']); + }); + it('a Play pressed during the exit debounce is a new entry', () => { + expect(walk([true, false, true, false, null])).toEqual(['in', '-', 'in', '-', 'out']); + }); +}); + +describe('the engine the quest rides on', () => { + function quest() { + const changes = []; + const t = createTourEngine({ storage: memoryStorage(), onChange: (a) => changes.push(a) }); + t.register(FIRST_WIN_TOUR, { + title: 'Your first game', + hideWhilePlaying: true, + quiet: true, + steps: firstWinSteps({ quest: 'Q.' }) + }); + return { t, changes }; + } + it('active() carries hideWhilePlaying and quiet; other tours read false', () => { + const { t } = quest(); + t.start(FIRST_WIN_TOUR); + expect(t.active().hideWhilePlaying).toBe(true); + expect(t.active().quiet).toBe(true); + t.register('plain', { title: 'P', steps: [{ id: 'a', title: 'A', body: '' }] }); + t.start('plain'); + expect(t.active().hideWhilePlaying).toBe(false); + expect(t.active().quiet).toBe(false); + }); + it('onAdvanced hears an ACTION completing a step — not Next — and before the tour ends', () => { + const { t } = quest(); + const heard = []; + /** what the listener saw active when the LAST step's action landed */ + let activeAtLast = 'unset'; + t.onAdvanced((e) => { + heard.push(e.step + ':' + e.signal); + if (e.step === 'share') activeAtLast = t.active()?.step.id ?? null; + }); + t.start(FIRST_WIN_TOUR); + t.signal(SIGNALS.played); // not what step 1 waits for + expect(heard).toEqual([]); + t.signal(SIGNALS.edited); + t.next(); // Next on "play" — not an action + t.signal(SIGNALS.joined); + expect(heard).toEqual(['change:scene-edited', 'share:peer-joined']); + expect(activeAtLast).toBe('share'); + expect(t.active()).toBeNull(); + expect(t.seen(FIRST_WIN_TOUR)).toBe(true); + }); + it('an unsubscribed listener hears nothing', () => { + const { t } = quest(); + const heard = []; + const off = t.onAdvanced((e) => heard.push(e.step)); + off(); + t.start(FIRST_WIN_TOUR); + t.signal(SIGNALS.edited); + expect(heard).toEqual([]); + }); +}); + +describe('the toolbar hold (simpleUntilPlay)', () => { + const ROW = [ + 'move', + 'rotate', + 'scale', + 'pivot', + 'mode', + 'objects', + 'flow', + 'explorer', + 'animation' + ]; + it('off: the row is untouched', () => { + expect(holdAdvanced(ROW, false)).toEqual({ row: ROW, held: [] }); + }); + it('on: the advanced ids leave the row, in row order', () => { + const { row, held } = holdAdvanced(ROW, true); + expect(row).toEqual(['move', 'rotate', 'scale', 'objects', 'explorer']); + expect(held).toEqual(['pivot', 'mode', 'flow', 'animation']); + expect([...held].sort()).toEqual([...ADVANCED_TOOLBAR].sort()); + }); +}); From 9443a9d010fea25cc021bb1d792052aad231eda9 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Mon, 26 Oct 2026 04:42:24 +0200 Subject: [PATCH 2/2] [feat] fw2 anonymous first-visit funnel counters behind the cloud seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - onboarding/funnel.js: ob_view / ob_choice / ob_first_edit / ob_first_play (+ a time bucket) / ob_first_share / ob_quest_done / ob_tour_skip (+ the step), once per browser and only for a browser that met the Welcome; return_d1 / return_d7 / one return_w per later week for any browser (the G1 weekly-returning line); ob_waitlist for the plugin's feature-update opt-in - closed vocabularies only — {e, c (the Welcome answer), b (release), d desktop|touch|vr, x}; no id of any kind; the first-seen day stays in this browser - Do Not Track / Global Privacy Control send nothing and store nothing; Settings ▸ Privacy ▸ "Count my visits anonymously" (default on, local) appears once a plugin listens; under test nothing counts without `funnelUnderTest` - cloudHooks `onFunnelEvent(fn)` + a 20-event boot queue; cloudApi `onFunnelEvent` / `funnelEvent('ob_waitlist')` (additive, typeof-probed); the open-source build sends nothing - unit: tests/unit/onboardingFunnel.test.js (rules on a synthetic clock, guards with their counterfactuals, the seam); e2e: onboarding.test.cjs part F Co-Authored-By: Claude Opus 5.5 (1M context) --- OPEN-CORE.md | 2 + src/components/menu/Connect.svelte | 3 + .../menu/settings/PrivacySettings.svelte | 18 +- src/lib/cloudHooks.js | 43 ++++ src/lib/cloudPlugin.js | 8 +- src/lib/debugHooks.js | 1 + src/lib/onboarding/firstWin.js | 19 +- src/lib/onboarding/funnel.js | 227 ++++++++++++++++++ src/lib/whatsNew.js | 3 + tests/e2e/onboarding.test.cjs | 221 ++++++++++++++++- tests/unit/onboardingFunnel.test.js | 209 ++++++++++++++++ 11 files changed, 748 insertions(+), 6 deletions(-) create mode 100644 src/lib/onboarding/funnel.js create mode 100644 tests/unit/onboardingFunnel.test.js diff --git a/OPEN-CORE.md b/OPEN-CORE.md index 30b4fc56c..e1eb2c119 100644 --- a/OPEN-CORE.md +++ b/OPEN-CORE.md @@ -259,6 +259,8 @@ static|embed>`. Core never talks to a server about it. | `loadRemoteScene({…, keepGameId?})` | `keepGameId: true` = the OWNER opening their own published scene (no fork); otherwise a community load is a remix | | `onExportBuilt(fn)` → off | after an export zip is built: `fn({gameId, buildId, preset, title, template, parentGameId, appVersion})` | | `onTemplateOpen(fn)` → off | a Games-tab template was started: `fn(slug)` (one "template open") | +| `onFunnelEvent(fn)` → off | 76 FW2: one anonymous first-visit funnel event `fn({e, c, b, d, x})` — closed vocabularies only (event name, Welcome answer, release, desktop/touch/vr, a time bucket or quest step), never an id. Core has already applied once-per-browser, Do Not Track / Global Privacy Control and the under-test guard; events from before registration are queued (20 max) and handed to the first listener. With no plugin nothing is sent anywhere. Probe with `typeof` | +| `funnelEvent(name)` → Promise\ | 76 FW2: the plugin's own funnel event through the same rules; only `'ob_waitlist'` (a feature-update opt-in) is accepted | | `toast(message, actions?, {keep?})` | fix-live F-2: with `keep: true` an action toast's buttons also stay on its notification-history entry until one is pressed (a role offer the user sees later) | | `setPeerMenuItems(items)` | fix-live: `[{id, label, run(peerId, {name})}]` (≤ 8, labels ≤ 40) appended to the Users peer menu (right-click a peer) under core's own entries — the cloud's "Report…"; `[]` clears | | `setConnectBadge(badge \| null)` | fix-live F-5: `{text, title?, onClick?}` — a small badge on the Connect pill for a standing setting (e.g. "Public · guests join"); `null` hides it. Probe with `typeof` | diff --git a/src/components/menu/Connect.svelte b/src/components/menu/Connect.svelte index 85c792be9..a116adc6d 100644 --- a/src/components/menu/Connect.svelte +++ b/src/components/menu/Connect.svelte @@ -6,6 +6,7 @@ import { phoneShellActive } from '$lib/ui/phoneShell.js'; import { tours } from '$lib/tours/index.js'; // 76 FW1: the copy is the first-win quest's share step import { SIGNALS } from '$lib/onboarding/quests.js'; + import { funnelEvent } from '$lib/onboarding/funnel.js'; // 76 FW2: …and the funnel's first share // 43 K3/K4: whether the pill is shown (Edit positions / Customize), and the top-row items it measures against import { toolbarSlots } from '$lib/toolbarSlots'; import { peers, userdata, waitingForApproval, pendingApprovals, showToast, settingsOpen, settingsSection, connectDrawerOpen, connectDrawerTab, connectDrawerPinned, showRoomsButton, connectDocked, connectBarHeight, toastStore, toastsInDrawerOnly, touchTools } from '../../stores/appStore' @@ -506,6 +507,8 @@ .writeText(window.location.origin + '#' + myidcap + inviteServerParam(srv)) .then(function () { // 76 FW1: the first-win quest's last step waits for this + // (the funnel first: the signal can end the quest, whose own event follows it) + funnelEvent('ob_first_share'); tours.signal(SIGNALS.copied); }) .catch(function () { diff --git a/src/components/menu/settings/PrivacySettings.svelte b/src/components/menu/settings/PrivacySettings.svelte index 93cf5c98d..009ca3004 100644 --- a/src/components/menu/settings/PrivacySettings.svelte +++ b/src/components/menu/settings/PrivacySettings.svelte @@ -1,6 +1,6 @@
@@ -32,4 +35,17 @@ {/if}
+
+ {#if $funnelListening} + + {#snippet desc()} + Counts a few first-visit steps (the welcome card, a first change, a first Play, a first share) and whether you come back — each once, with no ID, account or IP address. Off whenever your browser asks not to be tracked. + Privacy + {/snippet} + + + {:else} + + {/if} +
diff --git a/src/lib/cloudHooks.js b/src/lib/cloudHooks.js index 2b42dc739..a25254537 100644 --- a/src/lib/cloudHooks.js +++ b/src/lib/cloudHooks.js @@ -414,6 +414,49 @@ export function notifyTemplateOpen(slug) { } } +/** @typedef {{ e: string, c: string, b: string, d: string, x: string }} FunnelEvent */ + +/** @type {Set<(ev: FunnelEvent) => void>} */ +const funnelListeners = new Set(); +/** 76 FW2: events from before the plugin registered (ob_view fires at boot); flushed to the first + * listener, capped — an engine with no plugin keeps at most this many and sends nothing + * @type {FunnelEvent[]} */ +const funnelQueue = []; +const FUNNEL_QUEUE_MAX = 20; +/** something listens (a plugin that counts) — Settings ▸ Privacy offers its switch only then */ +export const funnelListening = writable(false); + +/** cloudApi `onFunnelEvent(fn)` — 76 FW2: one anonymous first-visit funnel event (onboarding/ + * funnel.js decided it may be counted). Only closed-vocabulary fields, never an id. + * @param {(ev: FunnelEvent) => void} fn @returns {() => void} off */ +export function onFunnelEvent(fn) { + if (typeof fn !== 'function') return () => {}; + funnelListeners.add(fn); + funnelListening.set(true); + for (const ev of funnelQueue.splice(0)) { + try { + fn({ ...ev }); + } catch (e) { + console.error('funnel listener threw:', e); + } + } + return () => funnelListeners.delete(fn); +} +/** core → plugin @param {FunnelEvent} ev */ +export function notifyFunnelEvent(ev) { + if (!funnelListeners.size) { + if (funnelQueue.length < FUNNEL_QUEUE_MAX) funnelQueue.push({ ...ev }); + return; + } + for (const fn of funnelListeners) { + try { + fn({ ...ev }); + } catch (e) { + console.error('funnel listener threw:', e); + } + } +} + /** * C6: THE HEART FOR THE SCENE ON SCREEN — the play-link start card and a published game's pause * menu render a heart toggle while this is set. The plugin sets it after loading a published diff --git a/src/lib/cloudPlugin.js b/src/lib/cloudPlugin.js index ceaada09a..71d626401 100644 --- a/src/lib/cloudPlugin.js +++ b/src/lib/cloudPlugin.js @@ -31,7 +31,7 @@ import { safeStorage } from './safeStorage'; // (svelte/store + safeStorage), so a static edge from here closes nothing. import { aiProviders, aiActiveProvider, aiEnabled, addAiProvider, updateAiProvider, removeAiProvider, setAiActiveProvider, setAiEnabled } from './ai/providers'; import { meshProviders, meshActiveProvider, meshGenEnabled, addMeshProvider, updateMeshProvider, removeMeshProvider, setMeshActiveProvider, setMeshGenEnabled } from './ai/meshProviders'; -import { meshJobStatus, onExportBuilt, onTemplateOpen, setSceneHeart, problemReporter, setWhatsNewCloud, setConnectBadge, setPeerMenuItems } from './cloudHooks'; +import { meshJobStatus, onExportBuilt, onTemplateOpen, onFunnelEvent, setSceneHeart, problemReporter, setWhatsNewCloud, setConnectBadge, setPeerMenuItems } from './cloudHooks'; // 36-community (C4): a leaf (svelte/store only) — safe as a static edge import { gameIdentity, ensureGameId, forkGameId } from './gameIdentity.js'; import { exportMode } from './export/exportBoot.js'; @@ -544,6 +544,12 @@ export function makeCloudApi() { onExportBuilt: (/** @type {any} */ fn) => onExportBuilt(fn), /** A Games-tab template was started: `fn(slug)`. Returns `off`. */ onTemplateOpen: (/** @type {any} */ fn) => onTemplateOpen(fn), + /** 76 FW2: an anonymous first-visit funnel event `fn({e, c, b, d, x})` — closed fields only, + * already past core's once-per-browser / DNT / GPC rules. Returns `off`. Probe with typeof. */ + onFunnelEvent: (/** @type {any} */ fn) => onFunnelEvent(fn), + /** 76 FW2: the plugin's own funnel event, through the same rules. Only `ob_waitlist` (a + * feature-update opt-in) is accepted; anything else is ignored. Returns whether it went out. */ + funnelEvent: async (/** @type {any} */ name) => (String(name) === 'ob_waitlist' ? (await import('./onboarding/funnel.js')).funnelEvent('ob_waitlist') : false), /** C6: the heart for the scene on screen (play-link start card + pause menu): * `{count, liked, toggle() → Promise<{liked, count} | null>}`, or null to remove it. */ setSceneHeart: (/** @type {any} */ info) => setSceneHeart(info), diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 5592dc767..e3fed7f81 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -44,6 +44,7 @@ export const DEBUG_HOOKS = [ // 76 FW1: the first-win quest (start / choose / debug) + its local state ['firstWin', () => import('./onboarding/firstWin.js')], ['onboardState', () => import('./onboarding/onboardState.js')], + ['funnel', () => import('./onboarding/funnel.js')], // 39: drag to place — the preview setting, the measured dims, the pack downloads, the ghost ['placementPrefs', () => import('./placementPrefs.js')], ['placementDims', () => import('./placementDims.js')], diff --git a/src/lib/onboarding/firstWin.js b/src/lib/onboarding/firstWin.js index 49a203ded..12016d313 100644 --- a/src/lib/onboarding/firstWin.js +++ b/src/lib/onboarding/firstWin.js @@ -32,6 +32,7 @@ import { playEdge } from './quests.js'; import { onboardChoice, simpleUntilPlay } from './onboardState.js'; +import { funnelEvent } from './funnel.js'; /** may the quest start by itself on this page? (not under test unless the suite asks) */ function allowed() { @@ -87,6 +88,8 @@ export function startFirstWin({ choice = get(onboardChoice), entry = null, force */ export function chooseOnboarding(choice, { entry = null, loaded = false } = {}) { onboardChoice.set(choice); + // 76 FW2: the funnel counts the answer (once per browser; funnel.js decides) + funnelEvent('ob_choice'); if (choice === 'play') { afterPlay = { entry }; return false; @@ -148,7 +151,10 @@ export function installFirstWin(options = {}) { const top = stack[stack.length - 1]; if (!top || seen.has(top)) return; seen.add(top); - if (top.kind !== 'selection') tours.signal(SIGNALS.edited); + if (top.kind === 'selection') return; + // the funnel before the signal: a signal can end the quest, whose own event comes after + funnelEvent('ob_first_edit'); + tours.signal(SIGNALS.edited); }) ); @@ -160,6 +166,7 @@ export function installFirstWin(options = {}) { const edge = playEdge(phase, v); phase = edge.phase; if (edge.entered) { + funnelEvent('ob_first_play'); tours.signal(SIGNALS.played); if (get(simpleUntilPlay)) simpleUntilPlay.set(false); } @@ -195,7 +202,9 @@ export function installFirstWin(options = {}) { known.add(id); fresh = true; } - if (fresh) tours.signal(SIGNALS.joined); + if (!fresh) return; + funnelEvent('ob_first_share'); + tours.signal(SIGNALS.joined); }) ); @@ -212,8 +221,12 @@ export function installFirstWin(options = {}) { if (wasQuest && !isQuest) { if (questComplete(advancedBy)) { ended({ kind: 'done' }); + funnelEvent('ob_quest_done'); celebrate(); - } else if (skippedAt) ended({ kind: 'skip', step: skippedAt }); + } else if (skippedAt) { + ended({ kind: 'skip', step: skippedAt }); + funnelEvent('ob_tour_skip', { x: skippedAt }); + } skippedAt = ''; } wasQuest = isQuest; diff --git a/src/lib/onboarding/funnel.js b/src/lib/onboarding/funnel.js new file mode 100644 index 000000000..65aadccb4 --- /dev/null +++ b/src/lib/onboarding/funnel.js @@ -0,0 +1,227 @@ +// 76 FW2 — ANONYMOUS FIRST-VISIT FUNNEL COUNTERS. Did the first visit work: of the browsers that met +// the Welcome card, how many answered it, changed something, pressed Play, shared, finished the +// quest — and did they come back. Plus the G1 demand signal (a feature-update opt-in, from the +// cloud plugin). +// +// THE PRIVACY RULE, which decides the shape of everything here: an event is a NAME and a few +// closed-vocabulary fields (`c` the Welcome answer, `b` the release, `d` desktop|touch|vr, `x` a +// time bucket or a quest step) — no id of any kind, nothing that could join two events into one +// person. "Once per browser" is a flag in this browser's own storage and never leaves it; the day +// a browser was first seen stays here too (only "came back on day 1 / in week 3" is sent). A browser +// that says Do Not Track or Global Privacy Control sends nothing, and remembers nothing either. +// +// Core only DECIDES; it sends nothing itself. Events go out through the cloudHooks seam +// (`onFunnelEvent`), so the open-source build with no plugin counts nothing anywhere. Under test +// nothing is counted unless the suite opts in with `funnelUnderTest` (the toursUnderTest precedent). +// +// Two layers: the PURE rules (`funnelStep`, `funnelReturns`, `playBucket`) the unit suite drives +// with a synthetic clock, and the thin wrappers that read storage, check the guards and emit. + +import { get, writable } from 'svelte/store'; +import { safeStorage } from '../safeStorage'; +import { coarsePointer } from '../inputDevice'; +import { notifyFunnelEvent } from '../cloudHooks'; +import { APP_VERSION } from '../version'; +import { releaseLabel } from '../errorReportCore'; +import { onboardChoice } from './onboardState.js'; + +export const FUNNEL_KEY = 'onboard.funnel'; +const SEND_KEY = 'onboard.funnelSend'; + +/** Settings ▸ Privacy's switch. LOCAL, default ON (absent = on) — Do Not Track / GPC win over it. */ +export const funnelOn = writable(safeStorage.getItem(SEND_KEY) !== 'false'); +funnelOn.subscribe((on) => safeStorage.setItem(SEND_KEY, on ? 'true' : 'false')); +/** the first-visit events, each sent at most once per browser, and only by a browser that met the Welcome */ +export const FIRST_VISIT_EVENTS = [ + 'ob_view', + 'ob_choice', + 'ob_first_edit', + 'ob_first_play', + 'ob_first_share', + 'ob_quest_done', + 'ob_tour_skip' +]; +/** sent once per browser by ANY browser (the G1 waitlist signal) */ +export const ANY_BROWSER_EVENTS = ['ob_waitlist']; +const DAY_MS = 86400000; + +/** + * What this browser remembers (never sent): the UTC day it was first seen, when it met the Welcome + * (0 = it never did — an invite, a returning user from before FW2), what it has sent, and the last + * returning week it reported. + * @typedef {{ v: 1, first: number, view: number, sent: string[], w: number }} FunnelState + */ + +/** @param {number} t */ +const dayOf = (t) => Math.floor(t / DAY_MS); + +/** ob_first_play: minutes from the Welcome to the first Play, as a bucket @param {number} ms */ +export function playBucket(ms) { + const min = Math.max(0, ms) / 60000; + return min < 1 ? 'lt1' : min < 3 ? '1to3' : min < 10 ? '3to10' : 'gt10'; +} + +/** @param {any} raw @returns {FunnelState | null} */ +export function parseFunnelState(raw) { + try { + const s = typeof raw === 'string' ? JSON.parse(raw) : raw; + if (!s || s.v !== 1 || !Number.isFinite(s.first)) return null; + return { + v: 1, + first: s.first, + view: Number(s.view) || 0, + sent: Array.isArray(s.sent) ? s.sent.map(String) : [], + w: Number(s.w) || 0 + }; + } catch { + return null; + } +} + +/** + * One event against the state. Pure. Returns the next state (null = unchanged) and the event's + * `x`, or `send: false` when it is not to be counted. + * @param {FunnelState | null} state @param {string} name @param {number} now @param {{x?: string}} [o] + * @returns {{ send: boolean, state: FunnelState | null, x: string }} + */ +export function funnelStep(state, name, now, o = {}) { + const no = { send: false, state: null, x: '' }; + if (name === 'ob_view') { + // the first visit only: a browser with any history (even a legacy one) has met the app before + if (state) return no; + return { + send: true, + state: { v: 1, first: dayOf(now), view: now, sent: ['ob_view'], w: 0 }, + x: '' + }; + } + if (FIRST_VISIT_EVENTS.includes(name)) { + if (!state?.view || state.sent.includes(name)) return no; + const x = + name === 'ob_first_play' + ? playBucket(now - state.view) + : name === 'ob_tour_skip' + ? String(o.x || '') + : ''; + return { send: true, state: { ...state, sent: [...state.sent, name] }, x }; + } + if (ANY_BROWSER_EVENTS.includes(name)) { + const base = state ?? { v: 1, first: dayOf(now), view: 0, sent: [], w: 0 }; + if (base.sent.includes(name)) return no; + return { send: true, state: { ...base, sent: [...base.sent, name] }, x: '' }; + } + return no; +} + +/** + * The returns a boot reports. Pure. A browser with no state starts one (first seen today, never met + * the Welcome) and reports nothing; afterwards: back on day 1 (`return_d1`) and in days 7-13 + * (`return_d7`) for a browser that met the Welcome, and once in every later 7-day week since it was + * first seen (`return_w`) for any browser — the G1 "weekly returning users" line. + * @param {FunnelState | null} state @param {number} now + * @returns {{ state: FunnelState, events: string[] }} + */ +export function funnelReturns(state, now) { + if (!state) return { state: { v: 1, first: dayOf(now), view: 0, sent: [], w: 0 }, events: [] }; + const age = dayOf(now) - state.first; + const events = []; + let next = state; + const once = (/** @type {string} */ name) => { + if (next.sent.includes(name)) return; + events.push(name); + next = { ...next, sent: [...next.sent, name] }; + }; + if (state.view && age === 1) once('return_d1'); + if (state.view && age >= 7 && age < 14) once('return_d7'); + const week = Math.floor(age / 7); + if (week >= 1 && week > state.w) { + events.push('return_w'); + next = { ...next, w: week }; + } + return { state: next, events }; +} + +// ------------------------------------------------------------------------------ the wrappers + +/** May this page count anything? Not under DNT / GPC; not under test unless the suite opts in. */ +export function funnelAllowed() { + if (typeof navigator !== 'undefined') { + const nav = /** @type {any} */ (navigator); + if (nav.doNotTrack === '1' || nav.globalPrivacyControl === true) return false; + } + if (typeof window !== 'undefined' && /** @type {any} */ (window).doNotTrack === '1') return false; + if (!get(funnelOn)) return false; + if (!safeStorage.getItem('debugStores')) return true; + return safeStorage.getItem('funnelUnderTest') === 'true'; +} + +/** desktop | touch | vr — a device CLASS, nothing finer */ +export function funnelDevice() { + const ua = typeof navigator === 'undefined' ? '' : String(navigator.userAgent ?? ''); + if (/OculusBrowser|Quest|Pico|VRShell|Wolvic|Vive/i.test(ua)) return 'vr'; + return coarsePointer() ? 'touch' : 'desktop'; +} + +function release() { + const env = /** @type {any} */ (import.meta.env ?? {}); + return releaseLabel( + APP_VERSION, + typeof location !== 'undefined' ? location.hostname : '', + env.VITE_RELEASE_LABEL + ); +} + +/** @param {FunnelState} s */ +function save(s) { + safeStorage.setItem(FUNNEL_KEY, JSON.stringify(s)); +} + +/** @param {string} e @param {string} x */ +function emit(e, x) { + // the Welcome answer rides every first-visit event after it, so the funnel splits by answer + const c = + e.startsWith('ob_') && e !== 'ob_view' && e !== 'ob_waitlist' + ? String(get(onboardChoice) || '') + : ''; + notifyFunnelEvent({ e, c, b: release(), d: funnelDevice(), x }); +} + +/** + * Count one event (once per browser; see funnelStep). Returns whether it went out. + * @param {string} name @param {{x?: string}} [o] + */ +export function funnelEvent(name, o = {}) { + if (!funnelAllowed()) return false; + const r = funnelStep(parseFunnelState(safeStorage.getItem(FUNNEL_KEY)), name, Date.now(), o); + if (!r.send || !r.state) return false; + save(r.state); + emit(name, r.x); + return true; +} + +/** + * The boot's half, called once from startWhatsNew: a browser meeting the Welcome on its first + * visit counts `ob_view`; every other boot reports its returns. + * @param {{ firstWelcome: boolean }} o + */ +export function funnelBoot({ firstWelcome }) { + if (!funnelAllowed()) return; + const state = parseFunnelState(safeStorage.getItem(FUNNEL_KEY)); + if (firstWelcome && !state) { + funnelEvent('ob_view'); + return; + } + const r = funnelReturns(state, Date.now()); + save(r.state); + for (const e of r.events) emit(e, ''); +} + +/** the suites' view (state only — what this browser remembers) */ +export function funnelDebug() { + return { + allowed: funnelAllowed(), + state: parseFunnelState(safeStorage.getItem(FUNNEL_KEY)), + device: funnelDevice(), + release: release() + }; +} diff --git a/src/lib/whatsNew.js b/src/lib/whatsNew.js index 05305259d..891dff6ae 100644 --- a/src/lib/whatsNew.js +++ b/src/lib/whatsNew.js @@ -12,6 +12,7 @@ import { showToast } from '../stores/appStore.js'; import changelogRaw from '../../CHANGELOG.md?raw'; import { safeStorage } from './safeStorage'; import { simpleUntilPlay } from './onboarding/onboardState.js'; +import { funnelBoot } from './onboarding/funnel.js'; /** Raw markdown of the changelog, rendered by WhatsNew.svelte. */ export const CHANGELOG = String(changelogRaw || ''); @@ -164,6 +165,8 @@ export function startWhatsNew() { // 76 FW1: a browser meeting the new Welcome on its FIRST visit starts with the advanced buttons // behind More, until its first Play (onboarding/onboardState.js) — never a returning user if (welcomeThisBoot && firstVisit) simpleUntilPlay.set(true); + // 76 FW2: the anonymous funnel — that first Welcome is `ob_view`, any other boot reports returns + funnelBoot({ firstWelcome: welcomeThisBoot && firstVisit }); if (firstVisit) { // The welcome overlay IS the announcement — the current version counts as // seen so the update badge can't fire on top of it. diff --git a/tests/e2e/onboarding.test.cjs b/tests/e2e/onboarding.test.cjs index c4fd8018f..b7971be92 100644 --- a/tests/e2e/onboarding.test.cjs +++ b/tests/e2e/onboarding.test.cjs @@ -12,8 +12,13 @@ // E the phone (OPPO Find N6 folded 390×896 and unfolded 770×850, DPR 2.9, touch): the Welcome // fits with no sideways scroll, the quest card is a sheet that fits. // Guard: under test without `toursUnderTest`, an answer starts no quest (the battery stays clean). +// F 76 FW2, the anonymous funnel: a first visit's events reach the cloudHooks seam once each, with +// closed fields only (ob_view → ob_choice → ob_first_edit → ob_first_play → ob_first_share → +// ob_quest_done; a Skip is ob_tour_skip with its step); Do Not Track / GPC send and store +// nothing (the counterfactual sends); a browser back the next day reports return_d1, one +// back a week later return_w; under test nothing counts without `funnelUnderTest`. // -// ONBOARD_SHOTS= writes the screenshots. ONBOARD_PART=a|b|c|d|e|all. +// ONBOARD_SHOTS= writes the screenshots. ONBOARD_PART=a|b|c|d|e|f|all. const h = require('./helpers.cjs'); const fs = require('fs'); const path = require('path'); @@ -423,6 +428,219 @@ async function partE(browser) { } } +/** collect funnel events on the page (the seam's boot queue hands over what came before) */ +const listenFunnel = (page) => + page.evaluate(() => { + window.__ob = []; + window.__stores.cloudHooks.onFunnelEvent((e) => window.__ob.push(e)); + }); +const funnelNames = (page) => page.evaluate(() => (window.__ob || []).map((e) => e.e)); +const DAY = 86400000; + +/** a returning browser (the Welcome already seen) with a funnel record written before boot */ +async function returning(browser, state) { + const ctx = await browser.newContext({ ignoreHTTPSErrors: true }); + await ctx.addInitScript((state) => { + if (sessionStorage.getItem('seeded')) return; + sessionStorage.setItem('seeded', '1'); + localStorage.setItem('debugStores', 'true'); + localStorage.setItem('hasSeenDisclaimer', 'true'); + localStorage.setItem('hasSeenWelcome', 'true'); + localStorage.setItem('funnelUnderTest', 'true'); + localStorage.setItem('onboard.funnel', JSON.stringify(state)); + }, state); + const page = await ctx.newPage(); + await page.goto(h.URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.waitForFunction(() => window.__stores && !!window.__stores.funnel, null, { + timeout: 90000 + }); + await page.waitForTimeout(500); + await listenFunnel(page); + return { ctx, page }; +} + +async function partF(browser) { + // ---- the guard: under test without the opt-in nothing counts -------------------------------- + const G = await firstVisit(browser, { toursUnderTest: 'true' }); + await G.page.locator('#welcome-start').click(); + // (no listener yet: one would also flip the Privacy row checked below; the record is the proof) + await G.page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box')); + await G.page.waitForTimeout(800); + const gState = await G.page.evaluate(() => window.__stores.funnel.funnelDebug()); + h.check( + (await funnelNames(G.page)).length === 0 && gState.state === null && gState.allowed === false, + 'FW2 guard: under test without funnelUnderTest nothing is counted or stored' + ); + // Settings ▸ Privacy: with nothing listening (no plugin) the row says nothing is counted + await G.page.evaluate(() => { + window.__stores.settingsSection.set('privacy'); + window.__stores.settingsOpen.set(true); + }); + await G.page.locator('#row-usage-counts').waitFor({ state: 'visible', timeout: 8000 }); + h.check( + (await G.page.locator('#send-usage-counts').count()) === 0 && + /counts nothing/.test(await G.page.locator('#row-usage-counts').innerText()), + 'FW2 Settings ▸ Privacy: with no plugin listening the row says this build counts nothing' + ); + await G.page.evaluate(() => window.__stores.cloudHooks.onFunnelEvent(() => {})); + await G.page.locator('#send-usage-counts').waitFor({ state: 'visible', timeout: 5000 }); + h.check( + (await G.page.locator('#row-usage-counts a[href="/privacy"]').count()) === 1, + 'FW2 Settings ▸ Privacy: once a plugin listens, the switch appears, linking the privacy page' + ); + await shot(G.page, '30-privacy-usage-counts'); + await G.page.locator('#send-usage-counts').click(); + h.check( + (await G.page.evaluate(() => localStorage.getItem('onboard.funnelSend'))) === 'false', + 'FW2 Settings ▸ Privacy: the switch turns the counts off (remembered on this device)' + ); + await G.page.evaluate(() => window.__stores.settingsOpen.set(false)); + await G.ctx.close(); + + // ---- a whole first win, counted --------------------------------------------------------------- + const A = await firstVisit(browser, { toursUnderTest: 'true', funnelUnderTest: 'true' }); + const page = A.page; + await listenFunnel(page); + h.check( + JSON.stringify(await funnelNames(page)) === '["ob_view"]', + 'FW2: meeting the Welcome on a first visit is ob_view (queued at boot, handed to the listener)' + ); + await page.locator('#welcome-start').click(); + await page + .locator('#tour-card[data-tour-id="first-win"]') + .waitFor({ state: 'visible', timeout: 8000 }); + await page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box')); + await h.eventually( + () => tourStep(page), + (st) => st === 'play', + 'FW2: the quest moves on after the edit' + ); + await page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create sphere')); + await page.locator('#play-button').click(); + await h.eventually( + () => read(page, 's.isLocked'), + (v) => v === true, + 'FW2: Play starts' + ); + await h.leavePlay(page); + await page.locator('#tour-card').waitFor({ state: 'visible', timeout: 8000 }); + await page.locator('.cx-id').first().click(); + await h.eventually( + () => funnelNames(page), + (n) => n.includes('ob_quest_done'), + 'FW2: finishing the quest is ob_quest_done' + ); + const evs = await page.evaluate(() => window.__ob); + h.check( + JSON.stringify(evs.map((e) => e.e)) === + JSON.stringify([ + 'ob_view', + 'ob_choice', + 'ob_first_edit', + 'ob_first_play', + 'ob_first_share', + 'ob_quest_done' + ]), + 'FW2: each step once, in order — a second edit is not a second ob_first_edit (' + + evs.map((e) => e.e) + + ')' + ); + h.check( + evs.every((e) => Object.keys(e).sort().join(',') === 'b,c,d,e,x'), + 'FW2: every event carries only {e, c, b, d, x}' + ); + h.check( + evs.slice(1).every((e) => e.c === 'empty') && evs[0].c === '', + 'FW2: the Welcome answer rides every event after it' + ); + const play = evs.find((e) => e.e === 'ob_first_play'); + h.check( + ['lt1', '1to3'].includes(play.x) && + evs.every((e) => /^\d+\.\d+\.\d+/.test(e.b) && e.d === 'desktop'), + 'FW2: ob_first_play carries its time bucket (' + play.x + '); release + device class on all' + ); + const st = await page.evaluate(() => localStorage.getItem('onboard.funnel')); + h.check( + !/[0-9a-f]{8}-|peer|uuid/i.test(st) && + Object.keys(JSON.parse(st)).sort().join(',') === 'first,sent,v,view,w', + 'FW2: what this browser remembers is days, a time and event names — no id' + ); + await A.ctx.close(); + + // ---- a Skip is ob_tour_skip with its step ---------------------------------------------------- + const S = await firstVisit(browser, { toursUnderTest: 'true', funnelUnderTest: 'true' }); + await listenFunnel(S.page); + await S.page.locator('#welcome-start').click(); + await S.page + .locator('#tour-card[data-tour-id="first-win"]') + .waitFor({ state: 'visible', timeout: 8000 }); + await S.page.locator('#tour-skip').click(); + await h.eventually( + () => S.page.evaluate(() => (window.__ob || []).find((e) => e.e === 'ob_tour_skip')?.x || ''), + (x) => x === 'change', + 'FW2: Skip on the first step is ob_tour_skip x=change' + ); + await S.ctx.close(); + + // ---- Do Not Track / GPC: nothing sent, nothing stored — and the counterfactual --------------- + for (const [label, prop, value] of [ + ['Do Not Track', 'doNotTrack', '1'], + ['Global Privacy Control', 'globalPrivacyControl', true] + ]) { + const ctx = await browser.newContext({ ignoreHTTPSErrors: true }); + await ctx.addInitScript( + ([prop, value]) => { + Object.defineProperty(Navigator.prototype, prop, { get: () => value, configurable: true }); + localStorage.setItem('debugStores', 'true'); + localStorage.setItem('hasSeenDisclaimer', 'true'); + localStorage.setItem('funnelUnderTest', 'true'); + localStorage.setItem('toursUnderTest', 'true'); + }, + [prop, value] + ); + const page = await ctx.newPage(); + await page.goto(h.URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.waitForFunction(() => window.__stores && !!window.__stores.funnel, null, { + timeout: 90000 + }); + await page.locator('#welcome-overlay').waitFor({ state: 'visible', timeout: 15000 }); + await listenFunnel(page); + await page.locator('#welcome-start').click(); + await page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box')); + await page.waitForTimeout(800); + h.check( + (await funnelNames(page)).length === 0 && + (await page.evaluate(() => localStorage.getItem('onboard.funnel'))) === null, + `FW2: ${label} — no event and no record` + ); + await ctx.close(); + } + + // ---- returns ---------------------------------------------------------------------------------- + const now = Date.now(); + const today = Math.floor(now / DAY); + const R1 = await returning(browser, { + v: 1, + first: today - 1, + view: now - DAY, + sent: ['ob_view'], + w: 0 + }); + h.check( + JSON.stringify(await funnelNames(R1.page)) === '["return_d1"]', + 'FW2: back the next day is return_d1 (' + (await funnelNames(R1.page)) + ')' + ); + await R1.ctx.close(); + const R7 = await returning(browser, { v: 1, first: today - 15, view: 0, sent: [], w: 1 }); + h.check( + JSON.stringify(await funnelNames(R7.page)) === '["return_w"]', + 'FW2: a browser that never met the Welcome, back in a later week, is return_w only (' + + (await funnelNames(R7.page)) + + ')' + ); + await R7.ctx.close(); +} + h.run(async () => { const browser = await h.launch(); if (on('a')) await partA(browser); @@ -430,5 +648,6 @@ h.run(async () => { if (on('c')) await partC(browser); if (on('d')) await partD(browser); if (on('e')) await partE(browser); + if (on('f')) await partF(browser); await h.finish(browser); }); diff --git a/tests/unit/onboardingFunnel.test.js b/tests/unit/onboardingFunnel.test.js new file mode 100644 index 000000000..bebe2e492 --- /dev/null +++ b/tests/unit/onboardingFunnel.test.js @@ -0,0 +1,209 @@ +// @ts-nocheck — fixtures stub navigator and build partial states +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + funnelStep, + funnelReturns, + playBucket, + parseFunnelState, + FIRST_VISIT_EVENTS +} from '../../src/lib/onboarding/funnel.js'; + +// 76 FW2: the funnel's rules (pure, synthetic clock), the guards, and the cloudHooks seam. +const DAY = 86400000; +const T0 = Date.parse('2026-10-26T10:00:00Z'); +const viewed = () => funnelStep(null, 'ob_view', T0).state; + +describe('the first-visit events', () => { + it('ob_view starts the record, and only for a browser with no history', () => { + const r = funnelStep(null, 'ob_view', T0); + expect(r.send).toBe(true); + expect(r.state).toEqual({ + v: 1, + first: Math.floor(T0 / DAY), + view: T0, + sent: ['ob_view'], + w: 0 + }); + expect(funnelStep(r.state, 'ob_view', T0 + 1000).send).toBe(false); + // a legacy browser (first seen before, never met the Welcome) is not a first visit + const legacy = funnelReturns(null, T0).state; + expect(funnelStep(legacy, 'ob_view', T0).send).toBe(false); + }); + it('every later event needs a browser that met the Welcome, and goes out once', () => { + const legacy = funnelReturns(null, T0).state; + for (const e of FIRST_VISIT_EVENTS.slice(1)) { + expect(funnelStep(legacy, e, T0).send, e).toBe(false); + const once = funnelStep(viewed(), e, T0 + 1000); + expect(once.send, e).toBe(true); + expect(funnelStep(once.state, e, T0 + 2000).send, e + ' twice').toBe(false); + } + }); + it('ob_first_play carries minutes since the Welcome as a bucket; ob_tour_skip its step', () => { + expect(funnelStep(viewed(), 'ob_first_play', T0 + 30000).x).toBe('lt1'); + expect(funnelStep(viewed(), 'ob_first_play', T0 + 2 * 60000).x).toBe('1to3'); + expect(funnelStep(viewed(), 'ob_first_play', T0 + 9 * 60000).x).toBe('3to10'); + expect(funnelStep(viewed(), 'ob_first_play', T0 + 60 * 60000).x).toBe('gt10'); + expect(funnelStep(viewed(), 'ob_tour_skip', T0, { x: 'play' }).x).toBe('play'); + expect(funnelStep(viewed(), 'ob_first_edit', T0, { x: 'play' }).x).toBe(''); + expect([playBucket(0), playBucket(59999), playBucket(60000), playBucket(-5)]).toEqual([ + 'lt1', + 'lt1', + '1to3', + 'lt1' + ]); + }); + it('ob_waitlist counts once from ANY browser; an unknown name never', () => { + const fresh = funnelStep(null, 'ob_waitlist', T0); + expect(fresh.send).toBe(true); + expect(fresh.state.view).toBe(0); + expect(funnelStep(fresh.state, 'ob_waitlist', T0).send).toBe(false); + expect(funnelStep(viewed(), 'pageview', T0).send).toBe(false); + }); + it('the record holds no id — only days, a time and event names', () => { + const s = funnelStep(viewed(), 'ob_choice', T0).state; + expect(Object.keys(s).sort()).toEqual(['first', 'sent', 'v', 'view', 'w']); + expect(parseFunnelState(JSON.stringify(s))).toEqual(s); + expect(parseFunnelState('{"v":2}')).toBeNull(); + expect(parseFunnelState('not json')).toBeNull(); + }); +}); + +describe('returns', () => { + it('day 1 and days 7-13 for a browser that met the Welcome, each once', () => { + let s = viewed(); + expect(funnelReturns(s, T0 + 3600000).events).toEqual([]); // same day + const d1 = funnelReturns(s, T0 + DAY); + expect(d1.events).toEqual(['return_d1']); + expect(funnelReturns(d1.state, T0 + DAY + 3600000).events).toEqual([]); + expect(funnelReturns(s, T0 + 2 * DAY).events).toEqual([]); // day 2 is not day 1 + s = d1.state; + const d7 = funnelReturns(s, T0 + 8 * DAY); + expect(d7.events).toEqual(['return_d7', 'return_w']); + expect(funnelReturns(d7.state, T0 + 9 * DAY).events).toEqual([]); + }); + it('one return_w per later week, for any browser (the G1 weekly-returning line)', () => { + const legacy = funnelReturns(null, T0); + expect(legacy.events).toEqual([]); + let s = legacy.state; + expect(funnelReturns(s, T0 + 3 * DAY).events).toEqual([]); + const w1 = funnelReturns(s, T0 + 7 * DAY); + expect(w1.events).toEqual(['return_w']); // no d7: never met the Welcome + s = w1.state; + expect(funnelReturns(s, T0 + 10 * DAY).events).toEqual([]); + const w3 = funnelReturns(s, T0 + 22 * DAY); + expect(w3.events).toEqual(['return_w']); + expect(w3.state.w).toBe(3); + }); +}); + +describe('the wrappers: guards and the seam', () => { + /** @type {any} */ + let F; + /** @type {any} */ + let hooks; + /** @type {any} */ + let storage; + beforeEach(async () => { + vi.resetModules(); + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/141.0' }); + storage = (await import('../../src/lib/safeStorage')).safeStorage; + for (const k of [ + 'onboard.funnel', + 'onboard.funnelSend', + 'debugStores', + 'funnelUnderTest', + 'onboard.choice' + ]) + storage.removeItem(k); + F = await import('../../src/lib/onboarding/funnel.js'); + hooks = await import('../../src/lib/cloudHooks.js'); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('an event reaches a listener with the closed fields only; the answer rides later events', async () => { + const got = []; + hooks.onFunnelEvent((ev) => got.push(ev)); + F.funnelBoot({ firstWelcome: true }); + (await import('../../src/lib/onboarding/onboardState.js')).onboardChoice.set('remix'); + F.funnelEvent('ob_choice'); + F.funnelEvent('ob_choice'); + expect(got.map((e) => e.e)).toEqual(['ob_view', 'ob_choice']); + expect(Object.keys(got[1]).sort()).toEqual(['b', 'c', 'd', 'e', 'x']); + expect(got[0].c).toBe(''); + expect(got[1].c).toBe('remix'); + expect(got[1].d).toBe('desktop'); + expect(JSON.stringify(got)).not.toMatch(/Chrome|Linux/); + }); + it('DNT and GPC send nothing and remember nothing — the counterfactual sends', () => { + const got = []; + hooks.onFunnelEvent((ev) => got.push(ev)); + vi.stubGlobal('navigator', { userAgent: 'x', doNotTrack: '1' }); + F.funnelBoot({ firstWelcome: true }); + expect(F.funnelEvent('ob_waitlist')).toBe(false); + vi.stubGlobal('navigator', { userAgent: 'x', globalPrivacyControl: true }); + F.funnelBoot({ firstWelcome: true }); + expect(got).toEqual([]); + expect(storage.getItem('onboard.funnel')).toBeNull(); + vi.stubGlobal('navigator', { userAgent: 'x', doNotTrack: '0' }); + F.funnelBoot({ firstWelcome: true }); + expect(got.map((e) => e.e)).toEqual(['ob_view']); + }); + it('under test nothing counts unless the suite opts in', () => { + const got = []; + hooks.onFunnelEvent((ev) => got.push(ev)); + storage.setItem('debugStores', 'true'); + F.funnelBoot({ firstWelcome: true }); + expect(got).toEqual([]); + storage.setItem('funnelUnderTest', 'true'); + F.funnelBoot({ firstWelcome: true }); + expect(got.map((e) => e.e)).toEqual(['ob_view']); + }); + it('the Settings ▸ Privacy switch off sends nothing; the listener flag is what offers it', () => { + expect(hooks.funnelListening && typeof hooks.funnelListening.subscribe).toBe('function'); + let listening; + hooks.funnelListening.subscribe((v) => (listening = v))(); + expect(listening).toBe(false); + const got = []; + hooks.onFunnelEvent((ev) => got.push(ev)); + hooks.funnelListening.subscribe((v) => (listening = v))(); + expect(listening).toBe(true); + F.funnelOn.set(false); + F.funnelBoot({ firstWelcome: true }); + expect(got).toEqual([]); + expect(storage.getItem('onboard.funnelSend')).toBe('false'); + F.funnelOn.set(true); + F.funnelBoot({ firstWelcome: true }); + expect(got.map((e) => e.e)).toEqual(['ob_view']); + }); + it('a headset browser is vr, nothing finer', () => { + vi.stubGlobal('navigator', { + userAgent: 'Mozilla/5.0 (X11; Linux x86_64; Quest 3) OculusBrowser/40.0' + }); + expect(F.funnelDevice()).toBe('vr'); + }); + it('events before the plugin registered are queued (capped) and flushed to it', () => { + for (let i = 0; i < 30; i++) + hooks.notifyFunnelEvent({ e: 'ob_view', c: '', b: '', d: 'desktop', x: String(i) }); + const got = []; + hooks.onFunnelEvent((ev) => got.push(ev)); + expect(got.length).toBe(20); + expect(got[0].x).toBe('0'); + const later = []; + hooks.onFunnelEvent((ev) => later.push(ev)); + expect(later).toEqual([]); // flushed once, to the first listener + hooks.notifyFunnelEvent({ e: 'return_w', c: '', b: '', d: 'desktop', x: '' }); + expect(got.length).toBe(21); + expect(later.length).toBe(1); + }); + it('a throwing listener does not stop the next one', () => { + const got = []; + hooks.onFunnelEvent(() => { + throw new Error('boom'); + }); + hooks.onFunnelEvent((ev) => got.push(ev)); + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + hooks.notifyFunnelEvent({ e: 'ob_view', c: '', b: '', d: 'desktop', x: '' }); + err.mockRestore(); + expect(got.length).toBe(1); + }); +});