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 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-18 04:45:02 +02:00
1 parent 0abca649fa
commit d8ce442cff
11 files changed
+836 -541

No files matched your search

+500 -451
View File
@@ -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<string | undefined>(
startRoute?.game,
@@ -669,461 +689,490 @@ function ReaderContent({
return (
<DisplayTimeContext value={zone}>
<GameMetaProvider value={meta}>
<main className="app-shell reader-ui">
<header className="top">
{prefs.onboarded ? (
<button
type="button"
className="mark"
aria-label="Event Clock — go to your default view"
onClick={() => changePage(prefs.view)}
>
Event<span>Clock</span>
</button>
) : (
<p className="mark">
Event<span>Clock</span>
<main
className="app-shell reader-ui"
data-touring={touring || undefined}
>
{/* 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. */}
<div className="tour-behind" inert={tourForced}>
<header className="top">
{prefs.onboarded ? (
<button
type="button"
className="mark"
aria-label="Event Clock — go to your default view"
onClick={() => changePage(prefs.view)}
>
Event<span>Clock</span>
</button>
) : (
<p className="mark">
Event<span>Clock</span>
</p>
)}
{prefs.onboarded && (
<AppNavigation
page={page}
onPage={changePage}
counts={{
soon: outstanding.filter((r) => 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,
}}
/>
)}
</header>
{saveError && !saveError.ok && (
<p role="alert" className="save-error">
Your last change could not be saved ({saveError.reason}). Your
previous data remains available. Try again or export it in
Backup.
</p>
)}
{prefs.onboarded && (
<AppNavigation
page={page}
onPage={changePage}
counts={{
soon: outstanding.filter((r) => 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 && (
<p role="status" className="help">
{backupError}
</p>
)}
</header>
{saveError && !saveError.ok && (
<p role="alert" className="save-error">
Your last change could not be saved ({saveError.reason}). Your
previous data remains available. Try again or export it in Backup.
</p>
)}
{backupError && (
<p role="status" className="help">
{backupError}
</p>
)}
{!prefs.onboarded ? (
<SetupWizard
state={state}
meta={meta}
onFinish={async (draft) => {
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" && (
<ReturningTour
autoStart={tourAutoStart}
onStep={(stop) => {
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 ? (
<SetupWizard
state={state}
meta={meta}
onFinish={async (draft) => {
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" && (
<div className="checklist-layout">
<aside className="checklist-rail">
<div className="rail">
{focusBarShown(state.prefs, "soon") && focusBar}
<NextDeadline
row={scoped.find((r) => r.clock.live)}
then={scoped.filter((r) => r.clock.live).slice(1, 4)}
servers={(game) => manyServers(state, game)}
onOpen={setOpen}
/>
{separate && (
<DailyPanel
onManage={() => changePage("routines")}
rows={pins.filter((r) => r.cadence === "day")}
state={state}
onAction={dispatch}
now={now}
/>
)}
<section className="pinned-routines">
<div className="pinned-heading">
<div>
<h2>Routines</h2>
<p className="sub">Showing only pinned</p>
</div>
<button onClick={() => changePage("routines")}>
Edit pins
</button>
</div>
{accounts
.filter((a) => focus === null || a.game === focus)
.map((a) => {
const rows = pins.filter(
(r) =>
r.accountId === a.id &&
(!separate || r.cadence !== "day"),
);
return rows.length ? (
<RoutineGroup
key={
a.id +
focus +
String(state.prefs.routineDisclosure)
}
title={`${meta(a.game).short}${serverSuffix(state, a.game, a.region)}`}
hue={meta(a.game).hue}
done={rows.filter((r) => r.status.met).length}
total={rows.length}
summary={
rows.every((r) => r.status.met)
? `All ${rows.length} done`
: `${rows.filter((r) => r.status.met).length} of ${rows.length} completed`
}
policy={
state.prefs.routineDisclosure === "always"
? "always"
: state.prefs.routineDisclosure ===
"expanded"
? "expanded"
: "collapsed"
}
focused={focus !== null}
>
{rows.map((row) => (
<PinnedRoutine
key={row.id}
row={row}
hue={meta(a.game).hue}
onAction={dispatch}
now={now}
/>
))}
</RoutineGroup>
) : null;
})}
<button
type="button"
className="quiet"
onClick={() => changePage("routines")}
>
Manage routines
</button>
</section>
</div>
</aside>
<div className="checklist-main">
{mainUpdates}
<div className="list-heading running-heading">
<h1>Running now</h1>
<div className="list-tools">
<div className="segmented">
{["standard", "compact"].map((d) => (
<button
type="button"
key={d}
aria-pressed={prefs.density === d}
onClick={() =>
void dispatch({
type: "prefs",
patch: { density: d },
...actionMeta(),
})
}
>
{d === "standard" ? "Standard" : "Compact"}
</button>
))}
</div>
<div className="segmented" aria-label="Sort events">
{(["ending", "doing"] as const).map((sort) => (
<button
key={sort}
aria-pressed={prefs.sort === sort}
onClick={() =>
void dispatch({
type: "prefs",
patch: { sort },
...actionMeta(),
})
}
>
{sort === "ending"
? "Ending soonest"
: "Doing first"}
</button>
))}
</div>
</div>
{/* One line under the title and the buttons: the count
on the left, live's legend on the right. */}
<div className="heading-meta">
<p className="sub">
{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" : ""}
</p>
<UrgencyLegend />
</div>
</div>
{visible.length ? (
<>
{prefs.density === "compact" && <LedgerColumns />}
<ul className="event-list">
{visible
.filter((r) => !r.clock.upcoming)
.map(renderRow)}
</ul>
{visible.some((r) => r.clock.upcoming) && (
<section className="upcoming-events">
<div className="list-heading">
<div>
<p className="eyebrow">Coming up</p>
<h2>Not started yet</h2>
<p className="sub">
{upcomingSummary(
visible.filter((r) => r.clock.upcoming),
)}
</p>
</div>
</div>
{prefs.density === "compact" && (
<LedgerColumns upcoming />
)}
<ul className="event-list">
{visible
.filter((r) => r.clock.upcoming)
.map(renderRow)}
</ul>
</section>
)}
</>
) : (
<p className="empty-state">
No events to show. Choose another game or change
tracking in Settings → Your games.
</p>
)}
<p className="list-foot">
Done or ignored events leave this list and wait in the{" "}
<button onClick={() => changePage("archive")}>
Archive
</button>
.
</p>
</div>
</div>
)}
{page === "timeline" && (
<>
{mainUpdates}
<TimelinePage
state={state}
focus={focus}
focusBar={
focusBarShown(state.prefs, "timeline") ? focusBar : null
}
rows={visible}
now={now}
onAction={dispatch}
onOpen={setOpen}
/>
</>
)}
{page === "routines" && (
<RoutinesPage state={state} onAction={dispatch} now={now} />
)}
{page === "archive" && (
<ArchivePage
state={state}
onAction={dispatch}
now={now}
onOpen={setArchivedOpen}
/>
)}
{page === "settings" && (
<SettingsLayout
state={state}
game={codesGame}
page={settingsPage}
onPage={(p) => {
setCodesGame(undefined);
setSettingsPage(p);
}}
onGame={(g) => {
setCodesGame(g);
setGameTab("setup");
setSettingsPage("games");
}}
>
{settingsPage === "appearance" ||
(settingsPage === "games" && !codesGame) ? (
<AppearancePage
state={state}
onAction={dispatch}
onReplayTour={() => {
setTourAutoStart(true);
void dispatch({
type: "prefs",
patch: { redesignTourSeen: null },
...actionMeta(),
});
}}
/>
) : settingsPage === "games" && codesGame ? (
<GameSettings
key={`${codesGame}-${gameTab}`}
initialTab={gameTab}
codes={codes}
initialGame={codesGame}
state={state}
sources={feed.sources}
onAction={dispatch}
/>
) : settingsPage === "time" ? (
<TimeSettings state={state} now={now} onAction={dispatch} />
) : settingsPage === "sources" ? (
<DataSourcesPage
sources={feed.sources.filter((s) =>
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" ? (
<BackupPage state={state} />
) : settingsPage === "changelog" ? (
<ChangelogPage notes={NOTES} entries={ENTRIES} />
) : settingsPage === "about" ? (
<AboutPage sources={feed.sources} />
) : settingsPage === "own" ? (
<section>
<h1>Your own events and games</h1>
<p className="help">
Only what you added yourself. Routines live on the
Routines tab.
</p>
<YourOwn {...ownProps} />
</section>
) : null}
</SettingsLayout>
)}
<FeedFooter
sources={feed.sources}
now={now}
online={online}
timezone={zone}
onAbout={() => showSettings("about")}
onRefresh={refreshFeed}
/>
</>
)}
{transition && (
<aside className="archive-toast" role="status">
{transition.label} “{transition.title}” · Moved to Archive{" "}
<button
type="button"
onClick={() => {
void dispatch({
type: "restore",
accountId: transition.accountId,
eventId: transition.eventId,
if (!result.ok) return;
}
// No navigation here: the tour opens next and leads the way,
// ending on the chosen view.
await dispatch({
type: "prefs",
patch: next.prefs,
...actionMeta(),
});
setTransition(null);
}}
>
Undo
</button>
</aside>
)}
{archivedOpen && (
<ArchiveDetail
record={archivedOpen}
state={state}
onAction={dispatch}
onClose={() => setArchivedOpen(null)}
/>
)}
{open && (
<ReaderDetail
sources={feed.sources}
onSources={() => {
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 && (
<UpdateNotice
applying={appUpdate.applying}
onApply={appUpdate.apply}
onDismiss={appUpdate.dismiss}
/>
/>
) : (
<>
{page === "soon" && (
<div className="checklist-layout">
<aside className="checklist-rail">
<div className="rail">
{focusBarShown(state.prefs, "soon") && focusBar}
<NextDeadline
row={scoped.find((r) => r.clock.live)}
then={scoped.filter((r) => r.clock.live).slice(1, 4)}
servers={(game) => manyServers(state, game)}
onOpen={setOpen}
/>
{separate && (
<DailyPanel
onManage={() => changePage("routines")}
rows={pins.filter((r) => r.cadence === "day")}
state={state}
onAction={dispatch}
now={now}
/>
)}
<section className="pinned-routines">
<div className="pinned-heading">
<div>
<h2>Routines</h2>
<p className="sub">Showing only pinned</p>
</div>
<button onClick={() => changePage("routines")}>
Edit pins
</button>
</div>
{accounts
.filter((a) => focus === null || a.game === focus)
.map((a) => {
const rows = pins.filter(
(r) =>
r.accountId === a.id &&
(!separate || r.cadence !== "day"),
);
return rows.length ? (
<RoutineGroup
key={
a.id +
focus +
String(state.prefs.routineDisclosure)
}
title={`${meta(a.game).short}${serverSuffix(state, a.game, a.region)}`}
hue={meta(a.game).hue}
done={rows.filter((r) => r.status.met).length}
total={rows.length}
summary={
rows.every((r) => r.status.met)
? `All ${rows.length} done`
: `${rows.filter((r) => r.status.met).length} of ${rows.length} completed`
}
policy={
state.prefs.routineDisclosure === "always"
? "always"
: state.prefs.routineDisclosure ===
"expanded"
? "expanded"
: "collapsed"
}
focused={focus !== null}
>
{rows.map((row) => (
<PinnedRoutine
key={row.id}
row={row}
hue={meta(a.game).hue}
onAction={dispatch}
now={now}
/>
))}
</RoutineGroup>
) : null;
})}
<button
type="button"
className="quiet"
onClick={() => changePage("routines")}
>
Manage routines
</button>
</section>
</div>
</aside>
<div className="checklist-main">
{mainUpdates}
<div className="list-heading running-heading">
<h1>Running now</h1>
<div className="list-tools">
<div className="segmented">
{["standard", "compact"].map((d) => (
<button
type="button"
key={d}
aria-pressed={prefs.density === d}
onClick={() =>
void dispatch({
type: "prefs",
patch: { density: d },
...actionMeta(),
})
}
>
{d === "standard" ? "Standard" : "Compact"}
</button>
))}
</div>
<div className="segmented" aria-label="Sort events">
{(["ending", "doing"] as const).map((sort) => (
<button
key={sort}
aria-pressed={prefs.sort === sort}
onClick={() =>
void dispatch({
type: "prefs",
patch: { sort },
...actionMeta(),
})
}
>
{sort === "ending"
? "Ending soonest"
: "Doing first"}
</button>
))}
</div>
</div>
{/* One line under the title and the buttons: the count
on the left, live's legend on the right. */}
<div className="heading-meta">
<p className="sub">
{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" : ""}
</p>
<UrgencyLegend />
</div>
</div>
{visible.length ? (
<>
{prefs.density === "compact" && <LedgerColumns />}
<ul className="event-list">
{visible
.filter((r) => !r.clock.upcoming)
.map(renderRow)}
</ul>
{visible.some((r) => r.clock.upcoming) && (
<section className="upcoming-events">
<div className="list-heading">
<div>
<p className="eyebrow">Coming up</p>
<h2>Not started yet</h2>
<p className="sub">
{upcomingSummary(
visible.filter((r) => r.clock.upcoming),
)}
</p>
</div>
</div>
{prefs.density === "compact" && (
<LedgerColumns upcoming />
)}
<ul className="event-list">
{visible
.filter((r) => r.clock.upcoming)
.map(renderRow)}
</ul>
</section>
)}
</>
) : (
<p className="empty-state">
No events to show. Choose another game or change
tracking in Settings → Your games.
</p>
)}
<p className="list-foot">
Done or ignored events leave this list and wait in the{" "}
<button onClick={() => changePage("archive")}>
Archive
</button>
.
</p>
</div>
</div>
)}
{page === "timeline" && (
<>
{mainUpdates}
<TimelinePage
state={state}
focus={focus}
focusBar={
focusBarShown(state.prefs, "timeline") ? focusBar : null
}
rows={visible}
now={now}
onAction={dispatch}
onOpen={setOpen}
/>
</>
)}
{page === "routines" && (
<RoutinesPage state={state} onAction={dispatch} now={now} />
)}
{page === "archive" && (
<ArchivePage
state={state}
onAction={dispatch}
now={now}
onOpen={setArchivedOpen}
/>
)}
{page === "settings" && (
<SettingsLayout
state={state}
game={codesGame}
page={settingsPage}
onPage={(p) => {
setCodesGame(undefined);
setSettingsPage(p);
}}
onGame={(g) => {
setCodesGame(g);
setGameTab("setup");
setSettingsPage("games");
}}
>
{settingsPage === "appearance" ||
(settingsPage === "games" && !codesGame) ? (
<AppearancePage
state={state}
onAction={dispatch}
onReplayTour={() => {
void dispatch({
type: "prefs",
patch: { redesignTourSeen: "replay" },
...actionMeta(),
});
}}
/>
) : settingsPage === "games" && codesGame ? (
<GameSettings
key={`${codesGame}-${gameTab}`}
initialTab={gameTab}
codes={codes}
initialGame={codesGame}
state={state}
sources={feed.sources}
onAction={dispatch}
/>
) : settingsPage === "time" ? (
<TimeSettings
state={state}
now={now}
onAction={dispatch}
/>
) : settingsPage === "sources" ? (
<DataSourcesPage
sources={feed.sources.filter((s) =>
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" ? (
<BackupPage state={state} />
) : settingsPage === "changelog" ? (
<ChangelogPage notes={NOTES} entries={ENTRIES} />
) : settingsPage === "about" ? (
<AboutPage sources={feed.sources} />
) : settingsPage === "own" ? (
<section>
<h1>Your own events and games</h1>
<p className="help">
Only what you added yourself. Routines live on the
Routines tab.
</p>
<YourOwn {...ownProps} />
</section>
) : null}
</SettingsLayout>
)}
<FeedFooter
sources={feed.sources}
now={now}
online={online}
timezone={zone}
onAbout={() => showSettings("about")}
onRefresh={refreshFeed}
/>
</>
)}
{transition && (
<aside className="archive-toast" role="status">
{transition.label} “{transition.title}” · Moved to Archive{" "}
<button
type="button"
onClick={() => {
void dispatch({
type: "restore",
accountId: transition.accountId,
eventId: transition.eventId,
...actionMeta(),
});
setTransition(null);
}}
>
Undo
</button>
</aside>
)}
{archivedOpen && (
<ArchiveDetail
record={archivedOpen}
state={state}
onAction={dispatch}
onClose={() => setArchivedOpen(null)}
/>
)}
{open && (
<ReaderDetail
sources={feed.sources}
onSources={() => {
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 && (
<UpdateNotice
applying={appUpdate.applying}
onApply={appUpdate.apply}
onDismiss={appUpdate.dismiss}
/>
)}
</div>
{touring && (
<>
{tourForced && <div className="tour-scrim" aria-hidden="true" />}
<ReturningTour
intro={tour === "replay" ? undefined : tour}
closable={!tourForced}
looks={{
interface: prefs.interface ?? "structured",
timelineLook: timelineLook(state.prefs),
}}
onLook={(patch) =>
void dispatch({ type: "prefs", patch, ...actionMeta() })
}
onStep={(stop) => {
setOpen(null);
setArchivedOpen(null);
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={() => {
void dispatch({
type: "prefs",
patch: { redesignTourSeen: TOUR_DONE },
...actionMeta(),
});
setPage(prefs.view);
}}
/>
</>
)}
</main>
</GameMetaProvider>