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:
1 parent
0abca649fa
commit
d8ce442cff
11 files changed
+836
-541
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user