PageSourceSearch

https://pressthink.org/j/rosen-archive/frontend/services/tourState.js?v=3.8.36

js pressthink.org collected 2026-10-02 04:27:10 UTC 5,913 bytes, 140 lines download raw bytes

1// Pure tour-state persistence for the "take a tour" onboarding (issue #454).
2//
3// The launch call settled on a hybrid onboarding: a visible-but-skippable "take
4// a tour" entry point that never auto-launches as a blocking modal, and that
5// does not reappear once a visitor has dismissed or completed it. This module is
6// the decision layer for that: it answers "should the entry point show?" and
7// records the two terminal outcomes, and nothing else.
8//
9// It is deliberately pure, in the same spirit as viewState.js: no React, and no
10// direct read of `window.localStorage`. The caller injects a Web Storage object
11// (or null), so the whole thing is unit-testable under `node --test` with no DOM
12// and no browser globals. That injection is also what makes the graceful-
13// degradation contract real rather than asserted: a storage that throws, or no
14// storage at all (server render, private mode, disabled cookies), degrades to
15// "always skippable" and never traps the visitor, because the module simply
16// treats an unreachable store as "not seen yet" and reports that it could not
17// persist.
18//
19// Two failure modes are handled differently on purpose. A storage that throws is
20// the environment, so it degrades softly. An invalid outcome passed by a caller
21// is a programming bug, so it fails loud.
22//
23// Scope: this decision core only. The React entry-point component, the tour
24// steps, and the content-led path into Jay's example content are the build and
25// are intentionally not here.
26
27// The persisted key carries its format version. Bumping the version is how a
28// future format change invalidates old values cleanly, instead of a tolerant
29// reader trying to migrate whatever it finds. Namespaced so it cannot collide
30// with the archive's other client state.
31export const TOUR_STORAGE_KEY = 'rosen:tour:v1';
32
33// The two terminal outcomes. Both mean "do not show the entry point again";
34// keeping them distinct lets the shell tell a finished tour from a skipped one
35// (useful for analytics and for a future "restart the tour" affordance).
36export const TOUR_OUTCOMES = Object.freeze({
37  dismissed: 'dismissed',
38  completed: 'completed',
39});
40
41const OUTCOME_VALUES = Object.freeze(Object.values(TOUR_OUTCOMES));
42
43/** True when `value` is one of the recognised terminal outcomes. */
44export function isTourOutcome(value) {
45  return OUTCOME_VALUES.includes(value);
46}
47
48/**
49 * Read the visitor's tour state from an injected Web Storage object.
50 *
51 * `storage` is anything with a `getItem(key)` method (a real `localStorage`, or
52 * null/undefined when there is no store — server render, blocked storage). Any
53 * throw from `getItem`, and any unrecognised stored value, resolves to the safe
54 * default: not seen, so the (skippable) entry point shows and the visitor is
55 * never trapped by a store we cannot read.
56 *
57 * Returns `{ seen, outcome, storageAvailable }`:
58 * - `seen` — has the visitor dismissed or completed the tour before?
59 * - `outcome` — which terminal outcome, or null if not seen.
60 * - `storageAvailable` — could we actually read the store? False lets the shell
61 *   hold its own in-session "dismissed" flag when persistence is impossible.
62 */
63export function readTourState(storage) {
64  const unseen = { seen: false, outcome: null, storageAvailable: false };
65  if (!storage || typeof storage.getItem !== 'function') {
66    return unseen;
67  }
68
69  let raw;
70  try {
71    raw = storage.getItem(TOUR_STORAGE_KEY);
72  } catch {
73    // Blocked storage (private mode, disabled cookies) can throw on access.
74    // Degrade to "always skippable" rather than surfacing the error.
75    return unseen;
76  }
77
78  if (raw === null || raw === undefined) {
79    return { seen: false, outcome: null, storageAvailable: true };
80  }
81  // A value we do not recognise (a stale format, a manual edit, another origin's
82  // key) is validated away at this boundary, so the rest of the module only ever
83  // sees a known outcome. An unknown value counts as "not seen" so it can never
84  // permanently hide the entry point.
85  if (!isTourOutcome(raw)) {
86    return { seen: false, outcome: null, storageAvailable: true };
87  }
88  return { seen: true, outcome: raw, storageAvailable: true };
89}
90
91/** Should the "take a tour" entry point be shown? Show it to a visitor who has
92 *  not already dismissed or completed the tour. */
93export function shouldShowTourEntry(state) {
94  return !(state && state.seen === true);
95}
96
97/**
98 * Record a terminal outcome (`dismissed` or `completed`) to the injected store.
99 *
100 * Storage failures degrade softly: if the store is missing or `setItem` throws
101 * (quota, private mode), this returns `{ persisted: false }` without throwing,
102 * so closing the tour can never trap the visitor behind an error. An outcome
103 * that is not a recognised value is a caller bug, not an environment problem, so
104 * it throws a TypeError instead of silently writing garbage.
105 *
106 * Returns `{ persisted, outcome }`.
107 */
108export function recordTourOutcome(storage, outcome) {
109  if (!isTourOutcome(outcome)) {
110    throw new TypeError(
111      `recordTourOutcome: outcome must be one of ${OUTCOME_VALUES.join(', ')}, got ${JSON.stringify(outcome)}`,
112    );
113  }
114  if (!storage || typeof storage.setItem !== 'function') {
115    return { persisted: false, outcome };
116  }
117  try {
118    storage.setItem(TOUR_STORAGE_KEY, outcome);
119    return { persisted: true, outcome };
120  } catch {
121    return { persisted: false, outcome };
122  }
123}
124
125/**
126 * Clear the stored tour state, so the entry point shows again. Backs a
127 * "restart the tour" affordance and test setup. Storage failures degrade
128 * softly, matching `recordTourOutcome`. Returns `{ cleared }`.
129 */
130export function clearTourState(storage) {
131  if (!storage || typeof storage.removeItem !== 'function') {
132    return { cleared: false };
133  }
134  try {
135    storage.removeItem(TOUR_STORAGE_KEY);
136    return { cleared: true };
137  } catch {
138    return { cleared: false };
139  }
140}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.