From d8ce442cff3cb5878b59ba4641c1e1af6e463136 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sun, 18 Oct 2026 04:45:02 +0200 Subject: [PATCH] feat(tour): everyone takes the tour once, and tries the layouts in it The tour now floats over the page it describes and must be finished before the app can be used: new readers straight after setup, returning readers on their next visit, and anyone who saw the earlier tour. The page behind stays visible and scrollable but inert. "Pick a layout" (on the Checklist) and the Timeline step switch Relaxed / Structured and New / Classic in place; the selected look is kept. Returning readers start on Relaxed. A replay from Settings stays optional and closable. redesignTourSeen now records new / returning / replay / 2026-10-tour-2; any other value means not yet taken. Co-Authored-By: Claude Opus 5.5 --- TODO.md | 15 +- docs/DATA-MODEL.md | 7 + docs/DESIGN.md | 17 +- docs/design-reviews/fidelity-capture.mjs | 2 +- ...10-17-redesign-second-rejection-handoff.md | 12 + src/client/ReaderApp.tsx | 951 +++++++++--------- .../components/redesign/AppearancePage.tsx | 4 +- .../components/redesign/ReturningTour.tsx | 152 ++- src/client/redesign.css | 134 ++- src/client/state/reader/onboarding.ts | 4 +- test/redesign-onboarding.test.tsx | 79 +- 11 files changed, 836 insertions(+), 541 deletions(-) diff --git a/TODO.md b/TODO.md index cd6750d..bb337e6 100644 --- a/TODO.md +++ b/TODO.md @@ -266,14 +266,7 @@ note only says where to start. readers could reach the ~5 MB quota. Failures are reported, not silent. - After a rollback, anything done in the new build lives only in v2 and isn't shown by the old app. It's stranded, not lost. -- [ ] Get readers to try the layout choices, not just be told about them (owner, 2026-10-18). - - Relaxed vs Structured, and the Timeline's new vs classic look. Each is one switch in - Settings → Appearance today, which most readers will never open. - - Show the difference live, on the reader's own events: a "try it" that switches the page in place, - with an easy way back, rather than a description or a static picture. - - Aim this especially at returning readers, who arrive on the default (Structured, new Timeline) - without having chosen it. - - Offer it even to readers who have already done or skipped the returning-user tour (the tour - records `redesignTourSeen`; this needs its own "seen" so it isn't swallowed by that). - - Open questions: where it appears (a one-time card on the Checklist and Timeline? a tour step?), and - whether trying a look saves it or only previews it until confirmed. +- [x] Get readers to try the layout choices, not just be told about them (owner, 2026-10-18). Done + 2026-10-18. The tour is now forced once for everyone, including new readers and anyone who saw the + earlier tour. Its "Pick a layout" and Timeline steps switch the page behind in place, and the + selected look is kept. Returning readers start on Relaxed. See `docs/DESIGN.md` → "The tour". diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index 9aab3ab..0da12ba 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -816,6 +816,13 @@ Prefs add interface (structured/default or relaxed), density (standard/default o soon or timeline; utility navigation is transient. Future/unreadable records remain exportable even when this build cannot offer editing controls. +`prefs.redesignTourSeen` (2026-10-18) records where the reader is with the tour. Its values are +`"2026-10-tour-2"` (done), `"new"` or `"returning"` (the tour is forced; the value picks the opening +line), and `"replay"` (optional). Any other value, including the earlier tour's `"2026-10-redesign"`, +means the reader has not taken this tour: the app moves them to `interface: "relaxed"` and stores +`"returning"`. A later tour version changes the done value, so every reader takes it once more. See +`tourMode` in `ReturningTour.tsx`. + The fidelity correction adds `prefs.routineGroup` (`reset`, the absent-value default, or `game`) for the Routines page's grouping. It changes no routine IDs or placement keys. Structured is the diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7421917..af576b6 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -631,6 +631,17 @@ legend on the same baseline as "Next: …". (interface, theme, scheme, urgency colours, default view, game order, timeline design, focus bar, checklist group), Your own events and games, Backup (tiers plus a manual "Your backups" list), and per-game Setup / Codes / About. -- **Returning-user tour.** Routines → Checklist → Timeline → Redeem codes → Archive → Settings. - Continue stays in a fixed place, Back sits beside it, and Skip is apart. "Replay the introduction" - starts it at step 1. +- **The tour (2026-10-18, owner).** Every reader takes it once before using the app: new readers + straight after setup, returning readers on their first visit, and readers who saw the earlier tour + once more. Routines → Checklist → Pick a layout → Timeline → Redeem codes → Archive → Settings. + - It floats over the page it describes. While forced, the page behind is `inert` under a light + scrim. It can be scrolled and seen, but not clicked. There is no Skip, and a reload restarts at + step 1. + - "Pick a layout" (on the Checklist) and the Timeline step each hold a two-way switch, Relaxed / + Structured and New / Classic. The switch changes the page behind in place, and the selected one + is kept. Readers are shown the difference rather than told about it, because most never open + Appearance. + - Returning readers are moved to Relaxed as the tour opens. New readers keep the look they chose in + setup. + - Continue stays in a fixed place and Back sits beside it. Only a replay ("Replay the + introduction" in Appearance) has a way out, "Close tour", with no scrim. diff --git a/docs/design-reviews/fidelity-capture.mjs b/docs/design-reviews/fidelity-capture.mjs index 750cc99..5f2a38f 100644 --- a/docs/design-reviews/fidelity-capture.mjs +++ b/docs/design-reviews/fidelity-capture.mjs @@ -103,7 +103,7 @@ for (const variant of ["structured", "relaxed"]) let s = JSON.parse( localStorage.getItem("gacha-tracker:v2:reader"), ); - s.prefs.redesignTourSeen = "2026-10-redesign"; + s.prefs.redesignTourSeen = "2026-10-tour-2"; s.prefs.interface = variant; s.prefs.theme = theme; s.prefs.changelogSeen = "9999-12-31"; diff --git a/docs/handoffs/2026-10-17-redesign-second-rejection-handoff.md b/docs/handoffs/2026-10-17-redesign-second-rejection-handoff.md index 5172127..9f53621 100644 --- a/docs/handoffs/2026-10-17-redesign-second-rejection-handoff.md +++ b/docs/handoffs/2026-10-17-redesign-second-rejection-handoff.md @@ -938,3 +938,15 @@ redesign commit. page. See docs/SOURCES.md § Redeem codes for eight more games. - **Owner's TODO:** let readers try Relaxed/Structured and the new/classic Timeline live, including returning readers who've done the tour. + +## Round: the forced tour and trying the looks (2026-10-18) + +The owner asked for every reader, new and returning, to be put through the tour before they can use +the app, and for returning readers to start on Relaxed. "Pick a layout" (Checklist) and the Timeline +step switch the page behind in place, and the selected look is kept. See `docs/DESIGN.md` → "The tour" +and `docs/DATA-MODEL.md` for the `redesignTourSeen` values. + +Schema review: no data-loss or stuck-state path. Accepted, by the owner's rule: a returning reader who +had picked Structured on purpose is moved to Relaxed once, and the previous value isn't kept. The tour's +layout step puts it back in one click. Left as is (low): a stale second tab can re-force the tour +once, and a save-error banner shown during a forced tour can be read but not clicked. diff --git a/src/client/ReaderApp.tsx b/src/client/ReaderApp.tsx index 445c610..50b71b7 100644 --- a/src/client/ReaderApp.tsx +++ b/src/client/ReaderApp.tsx @@ -91,7 +91,10 @@ import { GameSettings } from "./components/redesign/GameSettings.tsx"; import { RoutinesPage } from "./components/redesign/RoutinesPage.tsx"; import { DailyPanel } from "./components/redesign/DailyPanel.tsx"; import { RoutineGroup } from "./components/redesign/RoutineGroup.tsx"; -import { TimelinePage } from "./components/redesign/TimelinePage.tsx"; +import { + TimelinePage, + timelineLook, +} from "./components/redesign/TimelinePage.tsx"; import { focusBarShown } from "./components/redesign/FocusPicker.tsx"; import { ArchiveDetail } from "./components/redesign/ArchiveDetail.tsx"; import { ArchivePage } from "./components/redesign/ArchivePage.tsx"; @@ -102,7 +105,11 @@ import { UpdatesPanel } from "./components/redesign/UpdatesPanel.tsx"; import { DataSourcesPage } from "./components/redesign/DataSourcesPage.tsx"; import { FeedFooter } from "./components/redesign/FeedFooter.tsx"; import { SetupWizard } from "./components/redesign/SetupWizard.tsx"; -import { ReturningTour } from "./components/redesign/ReturningTour.tsx"; +import { + ReturningTour, + TOUR_DONE, + tourMode, +} from "./components/redesign/ReturningTour.tsx"; /** The upcoming section's sub-line: "3 events in 2 games". */ function upcomingSummary(rows: readonly { event: { game: string } }[]): string { @@ -258,8 +265,6 @@ function ReaderContent({ // A new tab starts at its top. Without this the window kept the last page's // scroll, so leaving a scrolled Settings page opened the Checklist part-way // down. The first render is skipped so a reload's restored scroll stands. - // Set by Replay the introduction, so the tour opens on its first step. - const [tourAutoStart, setTourAutoStart] = useState(false); const firstPage = useRef(true); useEffect(() => { if (firstPage.current) { @@ -268,6 +273,21 @@ function ReaderContent({ } window.scrollTo(0, 0); }, [page]); + // Everyone takes the tour once before using the app; a replay from Settings + // is optional. A returning reader who has not taken it starts on Relaxed. + const tour = prefs.onboarded + ? tourMode(state.prefs.redesignTourSeen) + : "done"; + useEffect(() => { + if (tour !== "unseen") return; + void dispatch({ + type: "prefs", + patch: { interface: "relaxed", redesignTourSeen: "returning" }, + ...actionMeta(), + }); + }, [tour]); + const touring = tour === "new" || tour === "returning" || tour === "replay"; + const tourForced = tour === "new" || tour === "returning"; const [gameTab, setGameTab] = useState<"setup" | "codes">("setup"); const [codesGame, setCodesGame] = useState( startRoute?.game, @@ -669,461 +689,490 @@ function ReaderContent({ return ( -
-
- {prefs.onboarded ? ( - - ) : ( -

- EventClock +

+ {/* While the tour is forced the page behind shows each stop but + takes no clicks or focus. `display: contents` keeps the layout + as if this wrapper were not here. */} +
+
+ {prefs.onboarded ? ( + + ) : ( +

+ EventClock +

+ )} + {prefs.onboarded && ( + r.clock.live).length, + routines: accounts + .flatMap((a) => routineRows(state, a.id, now)) + .filter((r) => r.placement.followed && !r.status.met) + .length, + archive: Object.keys(state.archive).length, + }} + /> + )} +
+ {saveError && !saveError.ok && ( +

+ Your last change could not be saved ({saveError.reason}). Your + previous data remains available. Try again or export it in + Backup.

)} - {prefs.onboarded && ( - r.clock.live).length, - routines: accounts - .flatMap((a) => routineRows(state, a.id, now)) - .filter((r) => r.placement.followed && !r.status.met) - .length, - archive: Object.keys(state.archive).length, - }} - /> + {backupError && ( +

+ {backupError} +

)} -
- {saveError && !saveError.ok && ( -

- Your last change could not be saved ({saveError.reason}). Your - previous data remains available. Try again or export it in Backup. -

- )} - {backupError && ( -

- {backupError} -

- )} - {!prefs.onboarded ? ( - { - const next = setupReader( - draft, - state, - new Date().toISOString(), - mintAccountId, - ); - for (const account of Object.values(next.accounts)) { - const result = await dispatch({ - type: "account", - account, - ...actionMeta(), - }); - if (!result.ok) return; - } - const result = await dispatch({ - type: "prefs", - patch: next.prefs, - ...actionMeta(), - }); - if (result.ok) setPage(draft.view); - }} - /> - ) : ( - <> - {state.prefs.redesignTourSeen !== "2026-10-redesign" && ( - { - if (stop !== "codes") { - if (stop === "settings") showSettings("appearance"); - else setPage(stop); - return; - } - // The Codes tab of a tracked game that has codes, else the - // Checklist, where new codes are announced. - const game = accounts - .map((a) => a.game) - .find((g) => codes?.codes.some((c) => c.game === g)); - if (!game) { - setPage("soon"); - return; - } - setGameTab("codes"); - setCodesGame(game); - showSettings("games"); - }} - onFinish={() => { - setTourAutoStart(false); - void dispatch({ - type: "prefs", - patch: { redesignTourSeen: "2026-10-redesign" }, + {!prefs.onboarded ? ( + { + const next = setupReader( + draft, + state, + new Date().toISOString(), + mintAccountId, + ); + for (const account of Object.values(next.accounts)) { + const result = await dispatch({ + type: "account", + account, ...actionMeta(), }); - setPage(prefs.view); - }} - /> - )} - {page === "soon" && ( -
- -
- {mainUpdates} -
-

Running now

-
-
- {["standard", "compact"].map((d) => ( - - ))} -
-
- {(["ending", "doing"] as const).map((sort) => ( - - ))} -
-
- {/* One line under the title and the buttons: the count - on the left, live's legend on the right. */} -
-

- {visible.filter((r) => r.clock.live).length} events in{" "} - {new Set(visible.map((r) => r.event.game)).size} games - · sorted by what ends first - {!online ? " · offline" : ""} -

- -
-
- {visible.length ? ( - <> - {prefs.density === "compact" && } -
    - {visible - .filter((r) => !r.clock.upcoming) - .map(renderRow)} -
- {visible.some((r) => r.clock.upcoming) && ( -
-
-
-

Coming up

-

Not started yet

-

- {upcomingSummary( - visible.filter((r) => r.clock.upcoming), - )} -

-
-
- {prefs.density === "compact" && ( - - )} -
    - {visible - .filter((r) => r.clock.upcoming) - .map(renderRow)} -
-
- )} - - ) : ( -

- No events to show. Choose another game or change - tracking in Settings → Your games. -

- )} -

- Done or ignored events leave this list and wait in the{" "} - - . -

-
-
- )} - {page === "timeline" && ( - <> - {mainUpdates} - - - )} - {page === "routines" && ( - - )} - {page === "archive" && ( - - )} - {page === "settings" && ( - { - setCodesGame(undefined); - setSettingsPage(p); - }} - onGame={(g) => { - setCodesGame(g); - setGameTab("setup"); - setSettingsPage("games"); - }} - > - {settingsPage === "appearance" || - (settingsPage === "games" && !codesGame) ? ( - { - setTourAutoStart(true); - void dispatch({ - type: "prefs", - patch: { redesignTourSeen: null }, - ...actionMeta(), - }); - }} - /> - ) : settingsPage === "games" && codesGame ? ( - - ) : settingsPage === "time" ? ( - - ) : settingsPage === "sources" ? ( - - trackedGames(state).has(s.game), - )} - now={now} - timezone={zone} - online={online} - onRefresh={refreshFeed} - showUpcoming={prefs.showUpcoming} - detectDaily={prefs.detectDaily} - standardPulls={state.prefs.standardPulls === true} - onPrefs={(patch) => - void dispatch({ type: "prefs", patch, ...actionMeta() }) - } - /> - ) : settingsPage === "backup" ? ( - - ) : settingsPage === "changelog" ? ( - - ) : settingsPage === "about" ? ( - - ) : settingsPage === "own" ? ( -
-

Your own events and games

-

- Only what you added yourself. Routines live on the - Routines tab. -

- -
- ) : null} -
- )} - showSettings("about")} - onRefresh={refreshFeed} - /> - - )} - {transition && ( - - )} - {archivedOpen && ( - setArchivedOpen(null)} - /> - )} - {open && ( - { - setOpen(null); - showSettings("sources"); - }} - row={open} - state={state} - now={now} - timezone={zone} - onAction={dispatch} - onOutcome={(kind) => void outcome(open, kind)} - onClose={() => setOpen(null)} - ownProps={ownProps} - /> - )} - {appUpdate.available && ( - + /> + ) : ( + <> + {page === "soon" && ( +
+ +
+ {mainUpdates} +
+

Running now

+
+
+ {["standard", "compact"].map((d) => ( + + ))} +
+
+ {(["ending", "doing"] as const).map((sort) => ( + + ))} +
+
+ {/* One line under the title and the buttons: the count + on the left, live's legend on the right. */} +
+

+ {visible.filter((r) => r.clock.live).length} events + in {new Set(visible.map((r) => r.event.game)).size}{" "} + games · sorted by what ends first + {!online ? " · offline" : ""} +

+ +
+
+ {visible.length ? ( + <> + {prefs.density === "compact" && } +
    + {visible + .filter((r) => !r.clock.upcoming) + .map(renderRow)} +
+ {visible.some((r) => r.clock.upcoming) && ( +
+
+
+

Coming up

+

Not started yet

+

+ {upcomingSummary( + visible.filter((r) => r.clock.upcoming), + )} +

+
+
+ {prefs.density === "compact" && ( + + )} +
    + {visible + .filter((r) => r.clock.upcoming) + .map(renderRow)} +
+
+ )} + + ) : ( +

+ No events to show. Choose another game or change + tracking in Settings → Your games. +

+ )} +

+ Done or ignored events leave this list and wait in the{" "} + + . +

+
+
+ )} + {page === "timeline" && ( + <> + {mainUpdates} + + + )} + {page === "routines" && ( + + )} + {page === "archive" && ( + + )} + {page === "settings" && ( + { + setCodesGame(undefined); + setSettingsPage(p); + }} + onGame={(g) => { + setCodesGame(g); + setGameTab("setup"); + setSettingsPage("games"); + }} + > + {settingsPage === "appearance" || + (settingsPage === "games" && !codesGame) ? ( + { + void dispatch({ + type: "prefs", + patch: { redesignTourSeen: "replay" }, + ...actionMeta(), + }); + }} + /> + ) : settingsPage === "games" && codesGame ? ( + + ) : settingsPage === "time" ? ( + + ) : settingsPage === "sources" ? ( + + trackedGames(state).has(s.game), + )} + now={now} + timezone={zone} + online={online} + onRefresh={refreshFeed} + showUpcoming={prefs.showUpcoming} + detectDaily={prefs.detectDaily} + standardPulls={state.prefs.standardPulls === true} + onPrefs={(patch) => + void dispatch({ + type: "prefs", + patch, + ...actionMeta(), + }) + } + /> + ) : settingsPage === "backup" ? ( + + ) : settingsPage === "changelog" ? ( + + ) : settingsPage === "about" ? ( + + ) : settingsPage === "own" ? ( +
+

Your own events and games

+

+ Only what you added yourself. Routines live on the + Routines tab. +

+ +
+ ) : null} +
+ )} + showSettings("about")} + onRefresh={refreshFeed} + /> + + )} + {transition && ( + + )} + {archivedOpen && ( + setArchivedOpen(null)} + /> + )} + {open && ( + { + setOpen(null); + showSettings("sources"); + }} + row={open} + state={state} + now={now} + timezone={zone} + onAction={dispatch} + onOutcome={(kind) => void outcome(open, kind)} + onClose={() => setOpen(null)} + ownProps={ownProps} + /> + )} + {appUpdate.available && ( + + )} + + {touring && ( + <> + {tourForced &&
diff --git a/src/client/components/redesign/AppearancePage.tsx b/src/client/components/redesign/AppearancePage.tsx index e352652..8d9e7bc 100644 --- a/src/client/components/redesign/AppearancePage.tsx +++ b/src/client/components/redesign/AppearancePage.tsx @@ -249,7 +249,9 @@ export function AppearancePage({ type="button" className="btn" onClick={() => - onReplayTour ? onReplayTour() : patch({ redesignTourSeen: null }) + onReplayTour + ? onReplayTour() + : patch({ redesignTourSeen: "replay" }) } > Replay the introduction diff --git a/src/client/components/redesign/ReturningTour.tsx b/src/client/components/redesign/ReturningTour.tsx index fd35f67..3a0ed48 100644 --- a/src/client/components/redesign/ReturningTour.tsx +++ b/src/client/components/redesign/ReturningTour.tsx @@ -1,13 +1,17 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; /** Where a step takes the reader; ReaderApp opens the matching page. */ export type TourStop = "routines" | "soon" | "timeline" | "codes" | "archive" | "settings"; +/** A look the reader tries from inside a step, switching the page behind. */ +type TourChoice = "interface" | "timelineLook"; + export const TOUR_STEPS: readonly { stop: TourStop; title: string; body: string; + choice?: TourChoice; }[] = [ { stop: "routines", @@ -19,10 +23,17 @@ export const TOUR_STEPS: readonly { title: "Your Checklist", body: "What ends first, at the top. Focus on a game, tick today's dailies and pinned routines beside the list, and switch between Standard and Compact rows.", }, + { + stop: "soon", + title: "Pick a layout", + body: "Try both on your own Checklist: the page behind changes as you switch. Whichever is selected when you continue is the one you keep.", + choice: "interface", + }, { stop: "timeline", title: "The Timeline", - body: "Every event as a bar across the calendar, with now marked. Show time left or end dates, and choose its look in Settings → Appearance.", + body: "Every event as a bar across the calendar, with now marked. Try the new and classic looks here; whichever is selected when you continue stays.", + choice: "timelineLook", }, { stop: "codes", @@ -41,63 +52,150 @@ export const TOUR_STEPS: readonly { }, ]; +const CHOICES: Record< + TourChoice, + readonly { value: string; label: string; note: string }[] +> = { + interface: [ + { + value: "relaxed", + label: "Relaxed", + note: "Separated panels with more breathing room.", + }, + { + value: "structured", + label: "Structured", + note: "A compact, ruled planner.", + }, + ], + timelineLook: [ + { + value: "new", + label: "New look", + note: "A name column beside each bar.", + }, + { + value: "classic", + label: "Classic look", + note: "Filled bars with the title inside, under each game.", + }, + ], +}; + /** - * The returning reader's introduction to the new look. Continue always sits - * first, in the same place, so clicking through never lands on Back; Back - * follows it, and Skip stands apart at the end. + * `prefs.redesignTourSeen` holds where the reader is with the tour: + * + * - `TOUR_DONE`: finished. Nothing shows. + * - `"new"` / `"returning"`: the tour must be finished before the app can be + * used, and the reader's look is settled: chosen in setup, or moved to + * Relaxed when the tour first opened. Which one sets the opening line, so + * a reload part-way through still greets the reader the same way. + * - `"replay"`: started from Settings. Optional, and closable at any step. + * - anything else (absent, null, or an earlier tour's marker): a returning + * reader who has not taken this tour. ReaderApp moves them to Relaxed and + * stores `"returning"`. + * + * A new tour version changes `TOUR_DONE`, so everyone takes it once more. + */ +export const TOUR_DONE = "2026-10-tour-2"; +export type TourMode = "done" | "new" | "returning" | "replay" | "unseen"; +export function tourMode(seen: unknown): TourMode { + if (seen === TOUR_DONE) return "done"; + if (seen === "new" || seen === "returning" || seen === "replay") return seen; + return "unseen"; +} + +/** + * The introduction to Event Clock. Continue always sits first, in the same + * place, so clicking through never lands on Back; Back follows it. Only a + * replay has a way out before the end. */ export function ReturningTour({ + intro, + closable, + looks, + onLook, onStep, onFinish, - autoStart = false, }: { + /** The opening line on the first step; none on a replay. */ + intro?: "new" | "returning" | undefined; + /** A replay can be closed at any step. */ + closable: boolean; + looks: Record; + onLook: (patch: Partial>) => void; onStep: (stop: TourStop) => void; onFinish: () => void; - /** Replayed from Settings: skip the welcome and open the first step. */ - autoStart?: boolean; }) { - const [step, setStep] = useState(autoStart ? 0 : -1); + const [step, setStep] = useState(0); + const card = useRef(null); const go = (next: number) => { setStep(next); onStep(TOUR_STEPS[next]!.stop); }; useEffect(() => { - if (autoStart) onStep(TOUR_STEPS[0]!.stop); + onStep(TOUR_STEPS[0]!.stop); + card.current?.focus(); }, []); + const current = TOUR_STEPS[step]!; const last = step === TOUR_STEPS.length - 1; return ( -