feat(tour): highlight each step, and make the comparing steps try both looks
Owner feedback on the forced tour. Each step rings what it is about and, while forced, dims the rest of the page around it. "Pick a layout" and the Timeline step hold Continue until both options have been on screen, scroll their subject to the top of the window and, on wide screens, move the card to the right edge so the page has room to show the difference. Guards for the reported snap-back and endless tour, which did not reproduce: the effect that moves an unseen reader to Relaxed fires at most once per page load and never after the tab saw the tour done, and setup saves only the prefs it changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
94fba4d7b5
commit
5be04a7439
8 files changed
+307
-72
No files matched your search
@@ -270,8 +270,14 @@ function ReaderContent({
|
||||
const tour = prefs.onboarded
|
||||
? tourMode(state.prefs.redesignTourSeen)
|
||||
: "done";
|
||||
// At most once per page load, and never after this tab saw the tour
|
||||
// finished: if another tab or a stale write ever put an old marker back,
|
||||
// the reader is not moved to Relaxed and sent round the tour again.
|
||||
const tourSettled = useRef(false);
|
||||
if (tour === "done" && prefs.onboarded) tourSettled.current = true;
|
||||
useEffect(() => {
|
||||
if (tour !== "unseen") return;
|
||||
if (tour !== "unseen" || tourSettled.current) return;
|
||||
tourSettled.current = true;
|
||||
void dispatch({
|
||||
type: "prefs",
|
||||
patch: { interface: "relaxed", redesignTourSeen: "returning" },
|
||||
@@ -752,9 +758,16 @@ function ReaderContent({
|
||||
}
|
||||
// No navigation here: the tour opens next and leads the way,
|
||||
// ending on the chosen view.
|
||||
// Only what setup changed, so a setup finished in a second,
|
||||
// stale tab leaves alone every setting setup does not set.
|
||||
await dispatch({
|
||||
type: "prefs",
|
||||
patch: next.prefs,
|
||||
patch: Object.fromEntries(
|
||||
Object.entries(next.prefs).filter(
|
||||
([k, v]) =>
|
||||
JSON.stringify(v) !== JSON.stringify(state.prefs[k]),
|
||||
),
|
||||
),
|
||||
...actionMeta(),
|
||||
});
|
||||
}}
|
||||
@@ -1123,7 +1136,6 @@ function ReaderContent({
|
||||
</div>
|
||||
{touring && (
|
||||
<>
|
||||
{tourForced && <div className="tour-scrim" aria-hidden="true" />}
|
||||
<ReturningTour
|
||||
intro={tour === "replay" ? undefined : tour}
|
||||
closable={!tourForced}
|
||||
|
||||
@@ -25,6 +25,7 @@ export function AppNavigation({
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
data-page={id}
|
||||
className={id === "settings" ? "gear" : undefined}
|
||||
aria-current={page === id ? "page" : undefined}
|
||||
onClick={() => onPage(id)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
/** Where a step takes the reader; ReaderApp opens the matching page. */
|
||||
export type TourStop =
|
||||
@@ -11,44 +11,54 @@ export const TOUR_STEPS: readonly {
|
||||
stop: TourStop;
|
||||
title: string;
|
||||
body: string;
|
||||
/** What the step is about, highlighted on the page behind. */
|
||||
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: "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"]',
|
||||
},
|
||||
{
|
||||
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.",
|
||||
body: "Try both on your own Checklist: the page changes as you switch. Whichever is selected when you continue is the one you keep.",
|
||||
choice: "interface",
|
||||
target: ".checklist-layout",
|
||||
},
|
||||
{
|
||||
stop: "timeline",
|
||||
title: "The Timeline",
|
||||
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.",
|
||||
body: "Every event as a bar across the calendar, with now marked. Try the new and classic looks on your own events; whichever is selected when you continue stays.",
|
||||
choice: "timelineLook",
|
||||
target:
|
||||
".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: "archive",
|
||||
title: "Your Archive",
|
||||
body: "Done and ignored events move here with their details and progress. Restore any of them at any time.",
|
||||
target: '[data-page="archive"]',
|
||||
},
|
||||
{
|
||||
stop: "settings",
|
||||
title: "Your settings, together",
|
||||
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"]',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -105,10 +115,60 @@ export function tourMode(seen: unknown): TourMode {
|
||||
return "unseen";
|
||||
}
|
||||
|
||||
/** The options of a choice the reader has not yet seen on the page. */
|
||||
export function untriedOptions(choice: TourChoice, seen: readonly string[]) {
|
||||
return CHOICES[choice].filter((o) => !seen.includes(o.value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rings the step's subject and, while the tour is forced, 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 }) {
|
||||
const [box, setBox] = useState<DOMRect | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
let frame = 0;
|
||||
const follow = () => {
|
||||
const next = document.querySelector(target)?.getBoundingClientRect();
|
||||
setBox((previous) =>
|
||||
next &&
|
||||
previous &&
|
||||
next.x === previous.x &&
|
||||
next.y === previous.y &&
|
||||
next.width === previous.width &&
|
||||
next.height === previous.height
|
||||
? previous
|
||||
: (next ?? null),
|
||||
);
|
||||
frame = requestAnimationFrame(follow);
|
||||
};
|
||||
follow();
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [target]);
|
||||
if (!box)
|
||||
return dim ? <div className="tour-scrim" aria-hidden="true" /> : null;
|
||||
const pad = 6;
|
||||
return (
|
||||
<div
|
||||
className={dim ? "tour-spotlight dim" : "tour-spotlight"}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
left: box.x - pad,
|
||||
top: box.y - pad,
|
||||
width: box.width + 2 * pad,
|
||||
height: box.height + 2 * pad,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* replay has a way out before the end. A step with a choice holds Continue
|
||||
* until the reader has seen every option on the page.
|
||||
*/
|
||||
export function ReturningTour({
|
||||
intro,
|
||||
@@ -128,7 +188,19 @@ export function ReturningTour({
|
||||
onFinish: () => void;
|
||||
}) {
|
||||
const [step, setStep] = useState(0);
|
||||
// The options seen at each choice: the look on screen when the step opens
|
||||
// counts, as the reader is looking at it.
|
||||
const [tried, setTried] = useState<Record<TourChoice, string[]>>({
|
||||
interface: [],
|
||||
timelineLook: [],
|
||||
});
|
||||
const card = useRef<HTMLElement>(null);
|
||||
const current = TOUR_STEPS[step]!;
|
||||
const choice = current.choice;
|
||||
const see = (key: TourChoice, value: string) =>
|
||||
setTried((t) =>
|
||||
t[key].includes(value) ? t : { ...t, [key]: [...t[key], value] },
|
||||
);
|
||||
const go = (next: number) => {
|
||||
setStep(next);
|
||||
onStep(TOUR_STEPS[next]!.stop);
|
||||
@@ -137,66 +209,95 @@ export function ReturningTour({
|
||||
onStep(TOUR_STEPS[0]!.stop);
|
||||
card.current?.focus();
|
||||
}, []);
|
||||
const current = TOUR_STEPS[step]!;
|
||||
useEffect(() => {
|
||||
if (choice) see(choice, looks[choice]);
|
||||
}, [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.
|
||||
useEffect(() => {
|
||||
if (!choice) return;
|
||||
let frame = requestAnimationFrame(() => {
|
||||
frame = requestAnimationFrame(() => {
|
||||
const el = document.querySelector(current.target);
|
||||
if (el)
|
||||
window.scrollTo({
|
||||
top: el.getBoundingClientRect().top + window.scrollY - 16,
|
||||
});
|
||||
});
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [step, choice && looks[choice]]);
|
||||
const untried = choice ? untriedOptions(choice, tried[choice]) : [];
|
||||
const last = step === TOUR_STEPS.length - 1;
|
||||
return (
|
||||
<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."}
|
||||
<>
|
||||
<TourSpotlight target={current.target} dim={!closable} />
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
<h2>{current.title}</h2>
|
||||
<p>{current.body}</p>
|
||||
{choice && (
|
||||
<div className="tour-choice" role="group" aria-label={current.title}>
|
||||
{CHOICES[choice].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={looks[choice] === option.value}
|
||||
onClick={() => {
|
||||
see(choice, option.value);
|
||||
onLook({ [choice]: option.value });
|
||||
}}
|
||||
>
|
||||
<strong>{option.label}</strong>
|
||||
<small>{option.note}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{choice && (
|
||||
<p className="tour-later" aria-live="polite">
|
||||
{untried.length
|
||||
? `Try ${untried.map((o) => o.label).join(" and ")} to continue.`
|
||||
: "You can change this later in Settings → Appearance."}
|
||||
</p>
|
||||
)}
|
||||
<div className="tour-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="commit"
|
||||
disabled={untried.length > 0}
|
||||
onClick={() => (last ? onFinish() : go(step + 1))}
|
||||
>
|
||||
{last ? "Finish tour" : "Continue"}
|
||||
</button>
|
||||
{step > 0 && (
|
||||
<button type="button" className="btn" onClick={() => go(step - 1)}>
|
||||
Back
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
{closable && (
|
||||
<button type="button" className="tour-skip" onClick={onFinish}>
|
||||
Close tour
|
||||
</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))}
|
||||
>
|
||||
{last ? "Finish tour" : "Continue"}
|
||||
</button>
|
||||
{step > 0 && (
|
||||
<button type="button" className="btn" onClick={() => go(step - 1)}>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
{closable && (
|
||||
<button type="button" className="tour-skip" onClick={onFinish}>
|
||||
Close tour
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+79
-5
@@ -1314,7 +1314,8 @@ textarea {
|
||||
font: 700 22px/1 var(--font-display);
|
||||
letter-spacing: -0.025em;
|
||||
text-transform: none;
|
||||
padding: 0; top: 0;
|
||||
padding: 0;
|
||||
top: 0;
|
||||
}
|
||||
:root[data-interface="relaxed"] .app-navigation {
|
||||
gap: 6px;
|
||||
@@ -2660,14 +2661,26 @@ dialog.archive-detail {
|
||||
display: block;
|
||||
height: 7px;
|
||||
border-radius: 1px;
|
||||
background: linear-gradient(90deg, var(--color-soon) 0 18%, var(--color-hairline) 18%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-soon) 0 18%,
|
||||
var(--color-hairline) 18%
|
||||
);
|
||||
}
|
||||
.vprev.list i:nth-child(2) {
|
||||
background: linear-gradient(90deg, var(--color-near) 0 46%, var(--color-hairline) 46%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-near) 0 46%,
|
||||
var(--color-hairline) 46%
|
||||
);
|
||||
width: 86%;
|
||||
}
|
||||
.vprev.list i:nth-child(3) {
|
||||
background: linear-gradient(90deg, var(--color-calm) 0 70%, var(--color-hairline) 70%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-calm) 0 70%,
|
||||
var(--color-hairline) 70%
|
||||
);
|
||||
width: 72%;
|
||||
}
|
||||
.vprev.tl {
|
||||
@@ -5366,7 +5379,7 @@ button.mark {
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
pointer-events: none;
|
||||
background: color-mix(in srgb, var(--color-ground) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--color-ground) 62%, transparent);
|
||||
}
|
||||
.returning-tour {
|
||||
position: fixed;
|
||||
@@ -5437,6 +5450,52 @@ 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. */
|
||||
.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);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tour-spotlight {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.returning-tour .tour-actions .commit:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* A step that asks the reader to compare looks moves its card aside, so the
|
||||
page it changes has the window to itself. */
|
||||
@media (min-width: 1100px) {
|
||||
.returning-tour.docked {
|
||||
left: auto;
|
||||
right: 24px;
|
||||
bottom: auto;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 340px;
|
||||
}
|
||||
.returning-tour.docked .tour-choice {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.reader-ui[data-touring]:has(.returning-tour.docked) {
|
||||
padding-right: 380px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.returning-tour {
|
||||
bottom: var(--edge);
|
||||
@@ -5445,4 +5504,19 @@ button.mark {
|
||||
.returning-tour .tour-choice {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
/* On a phone the card stays at the foot, so a comparing step keeps it
|
||||
short: the options side by side, by name only, leaving the page room. */
|
||||
.returning-tour.docked {
|
||||
padding: 14px 16px 12px;
|
||||
}
|
||||
.returning-tour.docked h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
.returning-tour.docked .tour-choice {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.returning-tour.docked .tour-choice small {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user