feat(tour): a welcome of its own, the owner's order, and the way to codes
The tour opens on a welcome over the dimmed Checklist, then shows the bar along the top, then the Checklist and Timeline with their looks, Routines, Archive and Settings. Codes get three steps that walk the way in (the game in Settings, its Codes tab, the codes), and the last step points at Replay the introduction. A replay now dims and locks the page like the first run, keeping only Close tour. Subjects out of sight scroll into view, and the ring stays inside the window. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
83ed9fa3fa
commit
874d8627a2
+15
-7
@@ -640,13 +640,21 @@ legend on the same baseline as "Next: …".
|
||||
get no marker.
|
||||
- **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`. It can be
|
||||
scrolled and seen, but not clicked. There is no Skip, and a reload restarts at step 1.
|
||||
once more. The order is the owner's (2026-10-18): a welcome on its own over the dimmed Checklist,
|
||||
saying things have changed and a tour is starting, so the first highlight is not missed while the
|
||||
reader is still reading → the bar along the top, for getting around → the Checklist with
|
||||
Relaxed / Structured → the Timeline with New /
|
||||
Classic → Routines → Archive → Settings → the way to codes in three steps (the game in Settings →
|
||||
Your games, its Codes tab, the codes) → where to replay the tour (Settings → Appearance → Replay
|
||||
the introduction).
|
||||
- It floats over the page it describes. The page behind is `inert`: it can be scrolled and seen,
|
||||
but not clicked. There is no Skip, and a reload restarts at step 1.
|
||||
- Each step rings what it is about (owner, 2026-10-18: greying the page "makes sense, but it should
|
||||
highlight the option"): the nav tab for Routines, Checklist, Archive and Settings, the Checklist
|
||||
or the Timeline board for the two comparing steps, the codes list for codes. While forced, the
|
||||
rest of the page is dimmed around it. The ring is a fixed layer drawn over the target's box
|
||||
highlight the option"): the nav tab for Routines, Archive and Settings, the Checklist or the
|
||||
Timeline board for the two comparing steps, the game's row, its Codes tab and the codes list for
|
||||
codes, and the Replay button. A subject out of sight is scrolled into view. The rest of the page
|
||||
is dimmed around it and inert, in a replay from Settings too (owner, 2026-10-18); a replay differs
|
||||
only in having Close tour. The ring is a fixed layer drawn over the target's box
|
||||
(`TourSpotlight`), not the target raised above a scrim, so no stacking context can trap it.
|
||||
- "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
|
||||
@@ -659,4 +667,4 @@ legend on the same baseline as "Next: …".
|
||||
- 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.
|
||||
introduction" in Appearance) has a way out, "Close tour".
|
||||
|
||||
+23
-13
@@ -691,10 +691,10 @@ function ReaderContent({
|
||||
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}>
|
||||
{/* During the tour, forced or replayed, 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={touring}>
|
||||
<header className="top">
|
||||
{prefs.onboarded ? (
|
||||
<button
|
||||
@@ -1150,21 +1150,31 @@ function ReaderContent({
|
||||
onStep={(stop) => {
|
||||
setOpen(null);
|
||||
setArchivedOpen(null);
|
||||
if (stop !== "codes") {
|
||||
if (stop === "settings") showSettings("appearance");
|
||||
else setPage(stop);
|
||||
if (stop === "settings" || stop === "replay") {
|
||||
setCodesGame(undefined);
|
||||
showSettings("appearance");
|
||||
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 (
|
||||
stop !== "codes-game" &&
|
||||
stop !== "codes-tab" &&
|
||||
stop !== "codes"
|
||||
) {
|
||||
setPage(stop);
|
||||
return;
|
||||
}
|
||||
// A tracked game that has codes, else any tracked game:
|
||||
// its Codes tab says when none are published.
|
||||
const game =
|
||||
accounts
|
||||
.map((a) => a.game)
|
||||
.find((g) => codes?.codes.some((c) => c.game === g)) ??
|
||||
accounts[0]?.game;
|
||||
if (!game) {
|
||||
setPage("soon");
|
||||
return;
|
||||
}
|
||||
setGameTab("codes");
|
||||
setGameTab(stop === "codes" ? "codes" : "setup");
|
||||
setCodesGame(game);
|
||||
showSettings("games");
|
||||
}}
|
||||
|
||||
@@ -248,6 +248,7 @@ export function AppearancePage({
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-tour="replay"
|
||||
onClick={() =>
|
||||
onReplayTour
|
||||
? onReplayTour()
|
||||
|
||||
@@ -142,6 +142,7 @@ export function GameSettings({
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
data-tab={id}
|
||||
aria-pressed={tab === id}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
|
||||
@@ -2,35 +2,53 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
/** Where a step takes the reader; ReaderApp opens the matching page. */
|
||||
export type TourStop =
|
||||
"routines" | "soon" | "timeline" | "codes" | "archive" | "settings";
|
||||
| "soon"
|
||||
| "timeline"
|
||||
| "routines"
|
||||
| "archive"
|
||||
| "settings"
|
||||
/** Settings, with a game's page open: its row in the list is the way in. */
|
||||
| "codes-game"
|
||||
/** The same game page, pointing at its Codes tab. */
|
||||
| "codes-tab"
|
||||
/** The game's Codes tab open. */
|
||||
| "codes"
|
||||
/** Settings → Appearance, where the tour can be replayed. */
|
||||
| "replay";
|
||||
|
||||
/** A look the reader tries from inside a step, switching the page behind. */
|
||||
type TourChoice = "interface" | "timelineLook";
|
||||
|
||||
/**
|
||||
* The tour, in the owner's order (2026-10-18). The first step only says a tour
|
||||
* is starting, over a dimmed page, so the first highlight is not missed while
|
||||
* the reader is still reading the welcome; the second shows the bar along the
|
||||
* top that every later step points into. The codes steps walk the way in:
|
||||
* Settings, the game, its Codes tab, the codes.
|
||||
*/
|
||||
export const TOUR_STEPS: readonly {
|
||||
stop: TourStop;
|
||||
title: string;
|
||||
body: string;
|
||||
/** What the step is about, highlighted on the page behind. */
|
||||
target: string;
|
||||
/** What the step is about, highlighted on the page behind; none dims it all. */
|
||||
target?: string;
|
||||
choice?: TourChoice;
|
||||
}[] = [
|
||||
{
|
||||
stop: "routines",
|
||||
title: "Your routines have a home",
|
||||
body: "Follow your dailies, weeklies and monthlies in the Routines tab, and pin the ones you want beside your Checklist or on the Timeline.",
|
||||
target: '[data-page="routines"]',
|
||||
stop: "soon",
|
||||
title: "A short tour",
|
||||
body: "A quick look at where everything is. Each step highlights what it's about.",
|
||||
},
|
||||
{
|
||||
stop: "soon",
|
||||
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.",
|
||||
target: '[data-page="soon"]',
|
||||
title: "Getting around",
|
||||
body: "The bar along the top takes you everywhere: the Checklist and Timeline, your Routines, the Archive and Settings. The numbers beside them count what's still to do. Click Event Clock to go back to your default view.",
|
||||
target: ".reader-ui header.top",
|
||||
},
|
||||
{
|
||||
stop: "soon",
|
||||
title: "Pick a layout",
|
||||
body: "Try both on your own Checklist: the page changes as you switch. Whichever is selected when you continue is the one you keep.",
|
||||
title: "Your Checklist, and its layout",
|
||||
body: "What ends first, at the top, with today's dailies and pinned routines beside it. Try both layouts on your own Checklist: the page changes as you switch, and the one selected when you continue is the one you keep.",
|
||||
choice: "interface",
|
||||
target: ".checklist-layout",
|
||||
},
|
||||
@@ -43,10 +61,10 @@ export const TOUR_STEPS: readonly {
|
||||
".reader-timeline .timeline-scroller, .reader-timeline .classic-timeline",
|
||||
},
|
||||
{
|
||||
stop: "codes",
|
||||
title: "Redeem codes",
|
||||
body: "New codes for the games you track show up at the top of the Checklist. Each game's Codes tab lists them with their rewards and expiry, ready to copy, and remembers which you've redeemed.",
|
||||
target: ".codes-page",
|
||||
stop: "routines",
|
||||
title: "Your routines have a home",
|
||||
body: "Follow your dailies, weeklies and monthlies in the Routines tab, and pin the ones you want beside your Checklist or on the Timeline.",
|
||||
target: '[data-page="routines"]',
|
||||
},
|
||||
{
|
||||
stop: "archive",
|
||||
@@ -60,6 +78,30 @@ export const TOUR_STEPS: readonly {
|
||||
body: "Appearance, region and time, each game's servers and colours, data sources and backups. The changelog has its own page here too.",
|
||||
target: '[data-page="settings"]',
|
||||
},
|
||||
{
|
||||
stop: "codes-game",
|
||||
title: "Redeem codes: pick the game",
|
||||
body: "Codes live with each game. In Settings, choose the game under Your games; a game with codes you haven't seen says how many.",
|
||||
target: '.settings-layout .settings-row[aria-current="page"]',
|
||||
},
|
||||
{
|
||||
stop: "codes-tab",
|
||||
title: "Redeem codes: open Codes",
|
||||
body: "On the game's page, open the Codes tab.",
|
||||
target: '.subtabs [data-tab="codes"]',
|
||||
},
|
||||
{
|
||||
stop: "codes",
|
||||
title: "Redeem codes: copy and tick",
|
||||
body: "Each code with its rewards and expiry, ready to copy, and where to redeem it. Tick Redeemed once you've used one: it moves down with the expired ones.",
|
||||
target: ".codes-page",
|
||||
},
|
||||
{
|
||||
stop: "replay",
|
||||
title: "Take the tour again",
|
||||
body: "Whenever you want this tour again, it's here: Settings → Appearance → Replay the introduction.",
|
||||
target: '[data-tour="replay"]',
|
||||
},
|
||||
];
|
||||
|
||||
const CHOICES: Record<
|
||||
@@ -121,17 +163,19 @@ export function untriedOptions(choice: TourChoice, seen: readonly string[]) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rings the step's subject and, while the tour is forced, dims the rest of the
|
||||
* Rings the step's subject and dims the rest of the
|
||||
* page around it. A fixed layer drawn over the target's box rather than the
|
||||
* target raised above a scrim, so no stacking context on the page can trap it;
|
||||
* it follows the target as the page scrolls, and takes no clicks.
|
||||
*/
|
||||
function TourSpotlight({ target, dim }: { target: string; dim: boolean }) {
|
||||
function TourSpotlight({ target }: { target: string | undefined }) {
|
||||
const [box, setBox] = useState<DOMRect | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
let frame = 0;
|
||||
const follow = () => {
|
||||
const next = document.querySelector(target)?.getBoundingClientRect();
|
||||
const next = target
|
||||
? document.querySelector(target)?.getBoundingClientRect()
|
||||
: undefined;
|
||||
setBox((previous) =>
|
||||
next &&
|
||||
previous &&
|
||||
@@ -147,23 +191,40 @@ function TourSpotlight({ target, dim }: { target: string; dim: boolean }) {
|
||||
follow();
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [target]);
|
||||
if (!box)
|
||||
return dim ? <div className="tour-scrim" aria-hidden="true" /> : null;
|
||||
const pad = 6;
|
||||
if (!box) return <div className="tour-scrim" aria-hidden="true" />;
|
||||
// Padded, but kept 3px inside the window so a full-width subject, such as
|
||||
// the bar along the top, still shows its ring at the edges.
|
||||
const pad = 6,
|
||||
edge = 3,
|
||||
left = Math.max(edge, box.x - pad),
|
||||
top = Math.max(edge, box.y - pad),
|
||||
right = Math.min(window.innerWidth - edge, box.right + pad);
|
||||
return (
|
||||
<div
|
||||
className={dim ? "tour-spotlight dim" : "tour-spotlight"}
|
||||
className="tour-spotlight"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
left: box.x - pad,
|
||||
top: box.y - pad,
|
||||
width: box.width + 2 * pad,
|
||||
height: box.height + 2 * pad,
|
||||
left,
|
||||
top,
|
||||
width: right - left,
|
||||
height: box.bottom + pad - top,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The first step's words when the tour opens on its own; a replay has none. */
|
||||
const WELCOME = {
|
||||
returning: {
|
||||
title: "Welcome back",
|
||||
body: "A few things have changed, so we're going on a short tour before you carry on. Your games, routines and progress are all still here. Each step highlights what it's about.",
|
||||
},
|
||||
new: {
|
||||
title: "Welcome to Event Clock",
|
||||
body: "Before you start, we're going on a short tour of where everything is. Each step highlights what it's about.",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -178,7 +239,7 @@ export function ReturningTour({
|
||||
onStep,
|
||||
onFinish,
|
||||
}: {
|
||||
/** The opening line on the first step; none on a replay. */
|
||||
/** Who the first step welcomes; a replay opens on the plain first step. */
|
||||
intro?: "new" | "returning" | undefined;
|
||||
/** A replay can be closed at any step. */
|
||||
closable: boolean;
|
||||
@@ -214,17 +275,22 @@ export function ReturningTour({
|
||||
}, [choice]);
|
||||
// A choice step brings its subject up to the top of the window, clear of
|
||||
// the banners above it, once the page it lives on has rendered, and again
|
||||
// when the look changes it. Two frames: the app scrolls a newly opened page
|
||||
// to its top in the same commit.
|
||||
// when the look changes it. Two frames: the app scrolls a newly opened page to its top in
|
||||
// the same commit.
|
||||
useEffect(() => {
|
||||
if (!choice) return;
|
||||
if (!current.target) return;
|
||||
const target = current.target;
|
||||
let frame = requestAnimationFrame(() => {
|
||||
frame = requestAnimationFrame(() => {
|
||||
const el = document.querySelector(current.target);
|
||||
if (el)
|
||||
window.scrollTo({
|
||||
top: el.getBoundingClientRect().top + window.scrollY - 16,
|
||||
});
|
||||
const el = document.querySelector(target);
|
||||
if (!el) return;
|
||||
const box = el.getBoundingClientRect();
|
||||
// A choice step's subject, and any too tall for the window, goes
|
||||
// to the top; anything else out of sight, to the middle.
|
||||
if (choice || box.height > window.innerHeight - 32)
|
||||
window.scrollTo({ top: box.top + window.scrollY - 16 });
|
||||
else if (box.top < 0 || box.bottom > window.innerHeight)
|
||||
el.scrollIntoView({ block: "center" });
|
||||
});
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
@@ -233,25 +299,20 @@ export function ReturningTour({
|
||||
const last = step === TOUR_STEPS.length - 1;
|
||||
return (
|
||||
<>
|
||||
<TourSpotlight target={current.target} dim={!closable} />
|
||||
<TourSpotlight target={current.target} />
|
||||
<aside
|
||||
ref={card}
|
||||
tabIndex={-1}
|
||||
className={choice ? "returning-tour docked" : "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>
|
||||
)}
|
||||
<p className="tour-step">
|
||||
{step + 1} of {TOUR_STEPS.length}
|
||||
</p>
|
||||
<h2>{current.title}</h2>
|
||||
<p>{current.body}</p>
|
||||
<h2>
|
||||
{(step === 0 && intro && WELCOME[intro].title) || current.title}
|
||||
</h2>
|
||||
<p>{(step === 0 && intro && WELCOME[intro].body) || current.body}</p>
|
||||
{choice && (
|
||||
<div className="tour-choice" role="group" aria-label={current.title}>
|
||||
{CHOICES[choice].map((option) => (
|
||||
|
||||
+4
-14
@@ -5401,8 +5401,8 @@ button.mark {
|
||||
}
|
||||
|
||||
/* 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
|
||||
scrollable behind it. During the tour, a replay too, the page is inert and
|
||||
dimmed around the step's subject. The page gets room at its foot so its
|
||||
last rows can scroll clear of the card. */
|
||||
.tour-behind {
|
||||
display: contents;
|
||||
@@ -5444,13 +5444,6 @@ button.mark {
|
||||
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;
|
||||
@@ -5486,21 +5479,18 @@ button.mark {
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
/* The step's subject, ringed. While the tour is forced, its shadow is the
|
||||
scrim: the page dims everywhere but here. */
|
||||
/* The step's subject, ringed; its shadow is the scrim, so the page dims
|
||||
everywhere but here. */
|
||||
.tour-spotlight {
|
||||
position: fixed;
|
||||
z-index: 80;
|
||||
pointer-events: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 0 2px var(--color-near);
|
||||
transition:
|
||||
left 220ms ease,
|
||||
top 220ms ease,
|
||||
width 220ms ease,
|
||||
height 220ms ease;
|
||||
}
|
||||
.tour-spotlight.dim {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--color-near),
|
||||
0 0 0 100vmax color-mix(in srgb, var(--color-ground) 62%, transparent);
|
||||
|
||||
@@ -41,9 +41,9 @@ test("the tour opens on its first step, with no way to skip it when forced", ()
|
||||
const html = render(
|
||||
<ReturningTour {...tourProps} intro="returning" closable={false} />,
|
||||
);
|
||||
expect(html).toContain("Welcome back. A little has moved");
|
||||
expect(html).toContain("Welcome back");
|
||||
expect(html).toContain("going on a short tour");
|
||||
expect(html).toContain("1 of " + TOUR_STEPS.length);
|
||||
expect(html).toContain(TOUR_STEPS[0]!.title);
|
||||
expect(html).not.toContain("Skip");
|
||||
expect(html).not.toContain("Close tour");
|
||||
expect(html).not.toContain("Keep browsing");
|
||||
@@ -76,12 +76,28 @@ test("a choice holds Continue until both options have been tried", () => {
|
||||
"classic",
|
||||
]);
|
||||
});
|
||||
test("every step highlights what it is about", () => {
|
||||
for (const step of TOUR_STEPS) expect(step.target).toBeTruthy();
|
||||
test("the tour opens on a dimmed welcome, then highlights each step", () => {
|
||||
expect(TOUR_STEPS[0]!.target).toBeUndefined();
|
||||
for (const step of TOUR_STEPS.slice(1)) expect(step.target).toBeTruthy();
|
||||
expect(TOUR_STEPS.find((s) => s.stop === "routines")!.target).toBe(
|
||||
'[data-page="routines"]',
|
||||
);
|
||||
});
|
||||
test("the tour follows the owner's order, walking the way to the codes", () => {
|
||||
expect(TOUR_STEPS.map((s) => s.stop)).toEqual([
|
||||
"soon",
|
||||
"soon",
|
||||
"soon",
|
||||
"timeline",
|
||||
"routines",
|
||||
"archive",
|
||||
"settings",
|
||||
"codes-game",
|
||||
"codes-tab",
|
||||
"codes",
|
||||
"replay",
|
||||
]);
|
||||
});
|
||||
test("the tour introduces the Timeline right after the Checklist, and codes", () => {
|
||||
const stops = TOUR_STEPS.map((s) => s.stop);
|
||||
expect(stops.indexOf("timeline")).toBe(stops.lastIndexOf("soon") + 1);
|
||||
|
||||
Reference in New Issue
Block a user