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>
@@ -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
+125 -27
View File
@@ -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<TourChoice, string>;
onLook: (patch: Partial<Record<TourChoice, string>>) => 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<HTMLElement>(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 (
<aside className="returning-tour" aria-label="Introduction to the new look">
{step >= 0 && (
<p className="tour-step">
{step + 1} of {TOUR_STEPS.length}
<aside
ref={card}
tabIndex={-1}
className="returning-tour"
aria-label="Introduction to Event Clock"
>
{step === 0 && intro && (
<p className="tour-intro">
{intro === "returning"
? "Welcome back. A little has moved, so here's a short tour before you carry on. Your games, routines and progress are all still here."
: "Welcome to Event Clock. Here's a short tour before you start."}
</p>
)}
<h2>
{step < 0
? "Welcome back. A little has moved."
: TOUR_STEPS[step]!.title}
</h2>
<p>
{step < 0
? "Your games, routines and progress are all still here. Take a short tour of the new layout, or keep browsing."
: TOUR_STEPS[step]!.body}
<p className="tour-step">
{step + 1} of {TOUR_STEPS.length}
</p>
<h2>{current.title}</h2>
<p>{current.body}</p>
{current.choice && (
<div className="tour-choice" role="group" aria-label={current.title}>
{CHOICES[current.choice].map((option) => (
<button
key={option.value}
type="button"
aria-pressed={looks[current.choice!] === option.value}
onClick={() => onLook({ [current.choice!]: option.value })}
>
<strong>{option.label}</strong>
<small>{option.note}</small>
</button>
))}
</div>
)}
{current.choice && (
<p className="tour-later">
You can change this later in Settings → Appearance.
</p>
)}
<div className="tour-actions">
<button
type="button"
className="commit"
onClick={() => (last ? onFinish() : go(step + 1))}
>
{step < 0 ? "Take the quick tour" : last ? "Finish tour" : "Continue"}
{last ? "Finish tour" : "Continue"}
</button>
{step > 0 && (
<button type="button" className="btn" onClick={() => go(step - 1)}>
Back
</button>
)}
<button type="button" className="tour-skip" onClick={onFinish}>
{step < 0 ? "Keep browsing" : "Skip tour"}
</button>
{closable && (
<button type="button" className="tour-skip" onClick={onFinish}>
Close tour
</button>
)}
</div>
</aside>
);
+96 -38
View File
@@ -1158,23 +1158,6 @@ textarea {
flex-direction: row;
align-items: center;
}
.returning-tour {
padding: 20px 24px;
border-radius: 12px;
background: var(--color-raised);
margin-bottom: 24px;
}
.returning-tour h2 {
margin: 0 0 8px;
}
.returning-tour div {
display: flex;
gap: 20px;
margin-top: 14px;
}
.returning-tour button {
color: var(--color-near);
}
.empty-state {
padding: 32px 28px;
color: var(--color-muted);
@@ -2011,27 +1994,6 @@ textarea {
color: var(--color-muted);
font-size: 13px;
}
.returning-tour {
border-radius: 0;
margin: 0;
padding: 20px 24px;
border-bottom: 1px solid var(--color-hairline);
background: var(--color-surface);
}
.returning-tour h2 {
font: 600 21px var(--font-display);
}
.returning-tour p {
font-size: 13px;
line-height: 1.65;
max-width: 75ch;
}
:root[data-interface="relaxed"] .returning-tour {
border-radius: 18px;
margin-bottom: 24px;
padding: 24px 28px;
border: 0;
}
:root[data-interface="relaxed"] .event-detail-sheet {
border-radius: 14px;
}
@@ -5388,3 +5350,99 @@ button.mark {
.redeem-where p span {
color: var(--color-muted);
}
/* The tour floats over the page it describes, so the page stays visible and
scrollable behind it. While the tour is forced the page is inert and a light
scrim says so; a replay has no scrim. The page gets room at its foot so its
last rows can scroll clear of the card. */
.tour-behind {
display: contents;
}
.reader-ui[data-touring] {
padding-bottom: 300px;
}
.tour-scrim {
position: fixed;
inset: 0;
z-index: 80;
pointer-events: none;
background: color-mix(in srgb, var(--color-ground) 30%, transparent);
}
.returning-tour {
position: fixed;
z-index: 90;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
width: min(620px, calc(100vw - 2 * var(--edge)));
max-height: calc(100vh - 48px);
overflow-y: auto;
padding: 22px 26px 20px;
border: 1px solid var(--color-hairline);
border-radius: 14px;
background: var(--color-raised);
box-shadow: 0 18px 48px rgb(0 0 0 / 0.35);
}
.returning-tour:focus {
outline: none;
}
.returning-tour h2 {
margin: 0 0 8px;
font: 600 21px var(--font-display);
}
.returning-tour p {
margin: 0;
font-size: 13px;
line-height: 1.65;
}
.returning-tour .tour-intro {
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-hairline);
color: var(--color-ink);
font-size: 14px;
}
.returning-tour .tour-choice {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 14px;
}
.returning-tour .tour-choice button {
display: grid;
gap: 2px;
padding: 11px 14px;
text-align: left;
border: 1px solid var(--color-hairline);
border-radius: 10px;
background: var(--color-surface);
color: var(--color-ink);
transition:
border-color 160ms ease,
background-color 160ms ease;
}
.returning-tour .tour-choice button:hover {
border-color: var(--color-muted);
}
.returning-tour .tour-choice button[aria-pressed="true"] {
border-color: var(--color-near);
background: color-mix(in srgb, var(--color-near) 14%, var(--color-surface));
}
.returning-tour .tour-choice small {
color: var(--color-muted);
font-size: 12px;
}
.returning-tour .tour-later {
margin-top: 8px;
color: var(--color-muted);
font-size: 12px;
}
@media (max-width: 700px) {
.returning-tour {
bottom: var(--edge);
padding: 18px 18px 16px;
}
.returning-tour .tour-choice {
grid-template-columns: 1fr;
}
}
+3 -1
View File
@@ -84,7 +84,9 @@ export function setupReader(
? { timelineLook: draft.timelineLook }
: {}),
onboarded: true,
redesignTourSeen: "2026-10-redesign",
// Straight into the tour, which a new reader must finish; setup has
// already settled their look.
redesignTourSeen: "new",
hiddenGames: Object.values(accounts)
.map((a) => a.game)
.filter((g) => !draft.games.includes(g)),