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.