@tollbooth-dpyc/web 1.7.1 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The voice a tour speaks in, checked.
3
+ *
4
+ * A step says what a control is and what to do with it, in one or two short
5
+ * sentences. It never says what something is not, and never contrasts: "your
6
+ * npub is your key" and never "your key, not a password". A site's test runs
7
+ * its tours through this so every site holds the same voice.
8
+ */
9
+ const CONTRAST = /\b(not|no|never|nothing|instead of|rather than)\b/i;
10
+ export const MAX_SAYS = 180;
11
+ export const MAX_TITLE = 28;
12
+ export const MAX_SENTENCES = 2;
13
+ function sentences(text) {
14
+ return text.split(/[.!?](\s|$)/).filter((x) => x.trim()).length;
15
+ }
16
+ /** The problems with one step's words, each a sentence; none when it is well said. */
17
+ export function lintStep(s, page = "") {
18
+ const where = page ? `${page} "${s.title}"` : `"${s.title}"`;
19
+ const out = [];
20
+ if (!s.title.trim())
21
+ out.push(`${where}: has no title`);
22
+ if (s.title.length > MAX_TITLE)
23
+ out.push(`${where}: the title runs ${s.title.length} characters`);
24
+ if (!s.says.trim())
25
+ out.push(`${where}: says nothing`);
26
+ if (s.says.length > MAX_SAYS)
27
+ out.push(`${where}: runs ${s.says.length} characters`);
28
+ if (sentences(s.says) > MAX_SENTENCES)
29
+ out.push(`${where}: is more than ${MAX_SENTENCES} sentences`);
30
+ const m = CONTRAST.exec(s.says) ?? CONTRAST.exec(s.title);
31
+ if (m)
32
+ out.push(`${where}: says what something is not ("${m[0]}"): ${s.says}`);
33
+ return out;
34
+ }
35
+ /** Every problem in a tour's words. */
36
+ export function lintTour(t) {
37
+ const out = [];
38
+ if (!t.steps.length)
39
+ out.push(`${t.page}: has no steps`);
40
+ for (const s of t.steps)
41
+ out.push(...lintStep(s, t.page));
42
+ return out;
43
+ }
44
+ /** Every problem across a site's tours. */
45
+ export function lintTours(tours) {
46
+ return Object.values(tours).flatMap(lintTour);
47
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A tour as driver.js runs it, and the offer that precedes it.
3
+ *
4
+ * driver.js draws the popover and the cutout. This adds what it has no idea
5
+ * of: a page action before a step whose control must be opened first, a tour
6
+ * that ends when nothing later can be shown, a "Stop the tutorial" link in
7
+ * every popover, and the centred question a page asks after a quiet while.
8
+ *
9
+ * DOM code, so it is untested here as its peers are; the rules it applies
10
+ * live in `./state.ts` and `./idle.ts`, which are.
11
+ */
12
+ import type { Tour } from "./types.ts";
13
+ export interface TourRun {
14
+ start(): void;
15
+ /** Take the tour down. `silent` is for the page leaving under it. */
16
+ stop(silent?: boolean): void;
17
+ }
18
+ export interface TourHooks {
19
+ /** Do what a step asks for before it is pointed at. */
20
+ run(action: string): void | Promise<void>;
21
+ /** The tour ended by its last step or its ✕: this page is done. */
22
+ onEnd(): void;
23
+ /** "Stop the tutorial": off on every page. */
24
+ onStop(): void;
25
+ }
26
+ export declare const POPOVER_CLASS = "tb-tour";
27
+ export declare function selector(name: string): string;
28
+ export declare function buildTour(tour: Tour, hooks: TourHooks): TourRun;
29
+ export interface OfferHooks {
30
+ onYes(): void;
31
+ /** "No", the ✕, or a tap outside: this page is done being asked. */
32
+ onNo(): void;
33
+ }
34
+ export interface Offer {
35
+ show(): void;
36
+ /** Take the question down without answering it. */
37
+ dismiss(): void;
38
+ }
39
+ /** The question a page asks after a quiet while, centred, with Yes and No. */
40
+ export declare function offerTour(hooks: OfferHooks, words?: OfferWords): Offer;
41
+ export interface OfferWords {
42
+ title: string;
43
+ says: string;
44
+ yes: string;
45
+ no: string;
46
+ }
47
+ export declare const OFFER_WORDS: OfferWords;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * A tour as driver.js runs it, and the offer that precedes it.
3
+ *
4
+ * driver.js draws the popover and the cutout. This adds what it has no idea
5
+ * of: a page action before a step whose control must be opened first, a tour
6
+ * that ends when nothing later can be shown, a "Stop the tutorial" link in
7
+ * every popover, and the centred question a page asks after a quiet while.
8
+ *
9
+ * DOM code, so it is untested here as its peers are; the rules it applies
10
+ * live in `./state.ts` and `./idle.ts`, which are.
11
+ */
12
+ import { driver } from "driver.js";
13
+ /** How long to wait for a control that a page action is still opening. */
14
+ const WAIT_MS = 1500;
15
+ export const POPOVER_CLASS = "tb-tour";
16
+ export function selector(name) {
17
+ return `[data-tour="${name}"]`;
18
+ }
19
+ /** Whether a step's control is on the page right now. */
20
+ function present(s) {
21
+ return typeof s.target === "string" ? !!document.querySelector(selector(s.target)) : !!s.target();
22
+ }
23
+ export function buildTour(tour, hooks) {
24
+ let silent = false;
25
+ // The step at or after `from` that can be shown: its control is on the
26
+ // page, or its page action will put it there. driver.js's own skipping of
27
+ // a missing element loses its place when the FIRST steps are missing, so
28
+ // the tour moves by index and decides for itself.
29
+ const showable = (from) => tour.steps.findIndex((s, j) => j >= from && (Boolean(s.before) || present(s)));
30
+ const showableBack = (from) => {
31
+ for (let j = from; j >= 0; j--)
32
+ if (present(tour.steps[j]))
33
+ return j;
34
+ return -1;
35
+ };
36
+ const steps = tour.steps.map((s) => ({
37
+ // driver.js types a finder as returning an Element; one that finds
38
+ // nothing returns null, which it treats as a missing element and skips.
39
+ element: typeof s.target === "string" ? selector(s.target) : s.target,
40
+ popover: {
41
+ title: s.title,
42
+ description: s.says,
43
+ side: s.side ?? "bottom",
44
+ align: s.align ?? "start",
45
+ },
46
+ }));
47
+ const d = driver({
48
+ steps,
49
+ popoverClass: POPOVER_CLASS,
50
+ showProgress: true,
51
+ progressText: "{{current}} of {{total}}",
52
+ nextBtnText: "Next",
53
+ prevBtnText: "Back",
54
+ doneBtnText: "Done",
55
+ allowClose: true,
56
+ overlayOpacity: 0.45,
57
+ stagePadding: 6,
58
+ stageRadius: 8,
59
+ smoothScroll: true,
60
+ skipMissingElement: true,
61
+ waitForElement: WAIT_MS,
62
+ onPopoverRender(popover) {
63
+ const stop = document.createElement("button");
64
+ stop.type = "button";
65
+ stop.className = "tb-tour-stop";
66
+ stop.textContent = "Stop the tutorial";
67
+ stop.addEventListener("click", () => { silent = true; hooks.onStop(); d.destroy(); });
68
+ popover.wrapper.appendChild(stop);
69
+ },
70
+ async onNextClick() {
71
+ // With no showable step left the tour is over.
72
+ const i = d.getActiveIndex() ?? 0;
73
+ const n = showable(i + 1);
74
+ if (n < 0) {
75
+ d.destroy();
76
+ return;
77
+ }
78
+ const next = tour.steps[n];
79
+ if (next.before)
80
+ await hooks.run(next.before);
81
+ d.moveTo(n);
82
+ },
83
+ onPrevClick() {
84
+ const i = d.getActiveIndex() ?? 0;
85
+ const b = showableBack(i - 1);
86
+ if (b >= 0)
87
+ d.moveTo(b);
88
+ },
89
+ onDestroyed() {
90
+ if (!silent)
91
+ hooks.onEnd();
92
+ },
93
+ });
94
+ return {
95
+ start() {
96
+ const i = showable(0);
97
+ if (i < 0) {
98
+ hooks.onEnd();
99
+ return;
100
+ }
101
+ const first = tour.steps[i];
102
+ if (first.before)
103
+ void Promise.resolve(hooks.run(first.before)).then(() => d.drive(i));
104
+ else
105
+ d.drive(i);
106
+ },
107
+ stop(quiet = true) {
108
+ silent = quiet;
109
+ if (d.isActive())
110
+ d.destroy();
111
+ },
112
+ };
113
+ }
114
+ /** The question a page asks after a quiet while, centred, with Yes and No. */
115
+ export function offerTour(hooks, words = OFFER_WORDS) {
116
+ let answered = false;
117
+ let quiet = false;
118
+ const d = driver({
119
+ popoverClass: `${POPOVER_CLASS} tb-tour-offer`,
120
+ showButtons: [],
121
+ allowClose: true,
122
+ overlayOpacity: 0.3,
123
+ onPopoverRender(popover) {
124
+ const row = document.createElement("div");
125
+ row.className = "tb-tour-answer";
126
+ const no = document.createElement("button");
127
+ no.type = "button";
128
+ no.className = "tb-tour-no";
129
+ no.textContent = words.no;
130
+ no.addEventListener("click", () => { answered = true; d.destroy(); hooks.onNo(); });
131
+ const yes = document.createElement("button");
132
+ yes.type = "button";
133
+ yes.className = "tb-tour-yes";
134
+ yes.textContent = words.yes;
135
+ // The tour starts on the next tick. Started inside this click, the
136
+ // click would go on to the new tour's outside-click listener and close
137
+ // it before it was seen.
138
+ yes.addEventListener("click", () => { answered = true; d.destroy(); window.setTimeout(hooks.onYes, 0); });
139
+ row.append(no, yes);
140
+ popover.wrapper.appendChild(row);
141
+ },
142
+ onDestroyed() {
143
+ if (!answered && !quiet)
144
+ hooks.onNo();
145
+ },
146
+ });
147
+ return {
148
+ show() {
149
+ d.highlight({ popover: { title: words.title, description: words.says } });
150
+ },
151
+ dismiss() {
152
+ quiet = true;
153
+ if (d.isActive())
154
+ d.destroy();
155
+ },
156
+ };
157
+ }
158
+ export const OFFER_WORDS = {
159
+ title: "A tour?",
160
+ says: "Would you like a tour of this page? It points at each control and says what it is for.",
161
+ yes: "Yes",
162
+ no: "No",
163
+ };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Who sees a tour, and when it stops.
3
+ *
4
+ * Three facts, kept per site and per device: whether the live tutorial is on,
5
+ * which pages have had their tour, and a page asked to replay once from its
6
+ * guide. A new device starts with the tutorial on. A device that already
7
+ * holds a patron npub but no tour preference has been here since before the
8
+ * tour existed, and starts with it off — a patron who has used the site for a
9
+ * season is shown nothing until they ask.
10
+ */
11
+ export interface TourPrefs {
12
+ on: boolean;
13
+ toured: Record<string, true>;
14
+ /** A page asked to show its tour once more, whatever the switch says. */
15
+ replay: string | null;
16
+ }
17
+ export declare const TOUR_DEFAULTS: TourPrefs;
18
+ /** Should this page's tour be offered now? */
19
+ export declare function shouldRun(p: TourPrefs, page: string): boolean;
20
+ /** The page's tour ended — by its last step, its ✕, or a "No". Done for this device. */
21
+ export declare function markToured<P extends TourPrefs>(p: P, page: string): P;
22
+ /** "Stop the tutorial", from any popover: off on every page. */
23
+ export declare function stopAll<P extends TourPrefs>(p: P): P;
24
+ /** The switch turned on in the profile: every page offers its tour again. */
25
+ export declare function replayAll<P extends TourPrefs>(p: P): P;
26
+ /** "Show me on the page", from a guide: that page once more, and only it. */
27
+ export declare function replayPage<P extends TourPrefs>(p: P, page: string): P;
28
+ /**
29
+ * The preference as stored, read back. `raw` is the parsed JSON or null when
30
+ * nothing is stored; `oldHand` says whether this device already held a patron
31
+ * npub, which is what makes a device with no preference an old hand rather
32
+ * than a newcomer.
33
+ */
34
+ export declare function fromStored(raw: unknown, oldHand: boolean): TourPrefs;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Who sees a tour, and when it stops.
3
+ *
4
+ * Three facts, kept per site and per device: whether the live tutorial is on,
5
+ * which pages have had their tour, and a page asked to replay once from its
6
+ * guide. A new device starts with the tutorial on. A device that already
7
+ * holds a patron npub but no tour preference has been here since before the
8
+ * tour existed, and starts with it off — a patron who has used the site for a
9
+ * season is shown nothing until they ask.
10
+ */
11
+ export const TOUR_DEFAULTS = { on: true, toured: {}, replay: null };
12
+ /** Should this page's tour be offered now? */
13
+ export function shouldRun(p, page) {
14
+ if (p.replay === page)
15
+ return true;
16
+ return p.on && !p.toured[page];
17
+ }
18
+ /** The page's tour ended — by its last step, its ✕, or a "No". Done for this device. */
19
+ export function markToured(p, page) {
20
+ return { ...p, toured: { ...p.toured, [page]: true }, replay: p.replay === page ? null : p.replay };
21
+ }
22
+ /** "Stop the tutorial", from any popover: off on every page. */
23
+ export function stopAll(p) {
24
+ return { ...p, on: false, replay: null };
25
+ }
26
+ /** The switch turned on in the profile: every page offers its tour again. */
27
+ export function replayAll(p) {
28
+ return { ...p, on: true, toured: {}, replay: null };
29
+ }
30
+ /** "Show me on the page", from a guide: that page once more, and only it. */
31
+ export function replayPage(p, page) {
32
+ const toured = { ...p.toured };
33
+ delete toured[page];
34
+ return { ...p, toured, replay: page };
35
+ }
36
+ /**
37
+ * The preference as stored, read back. `raw` is the parsed JSON or null when
38
+ * nothing is stored; `oldHand` says whether this device already held a patron
39
+ * npub, which is what makes a device with no preference an old hand rather
40
+ * than a newcomer.
41
+ */
42
+ export function fromStored(raw, oldHand) {
43
+ if (raw == null || typeof raw !== "object")
44
+ return { ...TOUR_DEFAULTS, on: !oldHand };
45
+ const r = raw;
46
+ const toured = r.toured && typeof r.toured === "object"
47
+ ? Object.fromEntries(Object.entries(r.toured).filter(([, v]) => v === true))
48
+ : {};
49
+ return {
50
+ on: typeof r.on === "boolean" ? r.on : !oldHand,
51
+ toured,
52
+ replay: typeof r.replay === "string" ? r.replay : null,
53
+ };
54
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The two tours every site shares, because the pages are the package's: the
3
+ * sign-in gate, and the account page's panels. A site puts them in its tour
4
+ * map beside its own, and adds its own steps after the panels' for whatever
5
+ * else its account page holds.
6
+ *
7
+ * The copy says what each thing is, in the words the network uses — Patron,
8
+ * npub, MCP tool, api_sats — in a sentence or two.
9
+ */
10
+ import type { Tour, TourStep } from "./types.ts";
11
+ /** The `data-tour` names the package's own markup carries. */
12
+ export declare const TB_TARGETS: {
13
+ readonly gateNpub: "tb.gate.npub";
14
+ readonly gateLinks: "tb.gate.links";
15
+ readonly gateSend: "tb.gate.send";
16
+ readonly gateSteps: "tb.gate.steps";
17
+ readonly gateVerify: "tb.gate.verify";
18
+ readonly accountProfile: "tb.account.profile";
19
+ readonly accountUsage: "tb.account.usage";
20
+ readonly accountCoupons: "tb.account.coupons";
21
+ readonly accountSessionKey: "tb.account.sessionKey";
22
+ };
23
+ /** The sign-in gate, from the field to the moment of being in. */
24
+ export declare function signInTour(page?: string): Tour;
25
+ /** The account page's package panels: the usage table, the persona, coupons, the session key. */
26
+ export declare function accountPanelsSteps(): TourStep[];
27
+ /** The account page as the package knows it; a site appends its own steps. */
28
+ export declare function accountTour(page?: string, own?: TourStep[]): Tour;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The two tours every site shares, because the pages are the package's: the
3
+ * sign-in gate, and the account page's panels. A site puts them in its tour
4
+ * map beside its own, and adds its own steps after the panels' for whatever
5
+ * else its account page holds.
6
+ *
7
+ * The copy says what each thing is, in the words the network uses — Patron,
8
+ * npub, MCP tool, api_sats — in a sentence or two.
9
+ */
10
+ /** The `data-tour` names the package's own markup carries. */
11
+ export const TB_TARGETS = {
12
+ gateNpub: "tb.gate.npub",
13
+ gateLinks: "tb.gate.links",
14
+ gateSend: "tb.gate.send",
15
+ gateSteps: "tb.gate.steps",
16
+ gateVerify: "tb.gate.verify",
17
+ accountProfile: "tb.account.profile",
18
+ accountUsage: "tb.account.usage",
19
+ accountCoupons: "tb.account.coupons",
20
+ accountSessionKey: "tb.account.sessionKey",
21
+ };
22
+ /** The sign-in gate, from the field to the moment of being in. */
23
+ export function signInTour(page = "signin") {
24
+ const steps = [
25
+ { target: TB_TARGETS.gateNpub, title: "Your npub",
26
+ says: "Paste your npub here. An nsec works too and stays in this browser." },
27
+ { target: TB_TARGETS.gateLinks, title: "Get a key",
28
+ says: "Get a free key in 0xchat or Pricing Studio. Either makes one in a minute.", side: "top" },
29
+ { target: TB_TARGETS.gateSend, title: "Send",
30
+ says: "We send a message to your Nostr client. Reply to it with any words." },
31
+ { target: TB_TARGETS.gateSteps, title: "The proof",
32
+ says: "Your signed reply is the proof. The tiles show where you are.", side: "top" },
33
+ { target: TB_TARGETS.gateVerify, title: "Verify",
34
+ says: "Replied? Tap here and you are in." },
35
+ ];
36
+ return { page, steps };
37
+ }
38
+ /** The account page's package panels: the usage table, the persona, coupons, the session key. */
39
+ export function accountPanelsSteps() {
40
+ return [
41
+ { target: TB_TARGETS.accountUsage, title: "What each tool drew",
42
+ says: "Every MCP tool you have called, how many times, and the api_sats each cost. The operator sets the prices; this is what was paid.", side: "top" },
43
+ { target: TB_TARGETS.accountProfile, title: "Your Nostr persona",
44
+ says: "The name, picture, bio and addresses Nostr shows for your npub. Tap Edit to change them; they publish to your Nostr profile.", side: "top" },
45
+ { target: TB_TARGETS.accountCoupons, title: "Coupons",
46
+ says: "A code an operator hands out. Redeem it once here and it discounts your MCP tool fees on its own after that.", side: "top" },
47
+ { target: TB_TARGETS.accountSessionKey, title: "Your session key",
48
+ says: "This browser holds a key that signs each call for you. Claim it here to carry it to another device.", side: "top" },
49
+ ];
50
+ }
51
+ /** The account page as the package knows it; a site appends its own steps. */
52
+ export function accountTour(page = "account", own = []) {
53
+ return { page, steps: [...accountPanelsSteps(), ...own] };
54
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The shape of a page's guided tour: a handful of steps, each pointing at one
3
+ * control and saying what it is for.
4
+ *
5
+ * Data with no DOM in it, so a site's copy can be tested: every target drawn,
6
+ * every sentence saying what a thing IS. The mechanics that run it are in
7
+ * `./run.ts` and `react/useTour.ts`; the words are each site's own, except
8
+ * for the two pages every site shares (`./tours.ts`).
9
+ */
10
+ export interface TourStep {
11
+ /**
12
+ * A `data-tour` name on the element, or — for markup a site does not own —
13
+ * a function that finds it. A function that finds nothing skips the step.
14
+ */
15
+ target: string | (() => Element | null);
16
+ title: string;
17
+ /** One or two short sentences: what the control is, what to do with it. */
18
+ says: string;
19
+ side?: "top" | "right" | "bottom" | "left";
20
+ align?: "start" | "center" | "end";
21
+ /**
22
+ * Something the page must do before this step can be pointed at — open the
23
+ * add form, pick a tag. The page registers the action by this name.
24
+ */
25
+ before?: string;
26
+ /** The guide step this abbreviates, for a site's own test: [group, control]. */
27
+ guide?: [group: string, control: string];
28
+ }
29
+ export interface Tour {
30
+ /** The page's key, as the site names its views. */
31
+ page: string;
32
+ steps: TourStep[];
33
+ }
34
+ /** What a step may ask the page to do first, by name. */
35
+ export type TourActions = Record<string, () => void | Promise<void>>;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The shape of a page's guided tour: a handful of steps, each pointing at one
3
+ * control and saying what it is for.
4
+ *
5
+ * Data with no DOM in it, so a site's copy can be tested: every target drawn,
6
+ * every sentence saying what a thing IS. The mechanics that run it are in
7
+ * `./run.ts` and `react/useTour.ts`; the words are each site's own, except
8
+ * for the two pages every site shares (`./tours.ts`).
9
+ */
10
+ export {};
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The guided tour's preference, stored per site and per device under
3
+ * `<prefix>:tour:v1`: whether the live tutorial is on, which pages have had
4
+ * their tour, and a page asked to replay once. The rules are in
5
+ * `tour/state.ts`; this is their storage, in the shape `timezone.ts` set.
6
+ */
7
+ import { type TourPrefs } from "./tour/state.ts";
8
+ /** `<prefix>:tour:v1` — for a `storage` event listener. */
9
+ export declare function tourStorageKey(): string;
10
+ export declare function readTourPrefs(): TourPrefs;
11
+ /** Store the preference and tell every listener on the page. */
12
+ export declare function writeTourPrefs(next: TourPrefs): TourPrefs;
13
+ /** Hear every `writeTourPrefs` on this page. Returns an unsubscribe. */
14
+ export declare function onTourChange(fn: (p: TourPrefs) => void): () => void;
15
+ /**
16
+ * Write the preference down if nothing is stored yet. A newcomer is read as
17
+ * "on" while they hold no npub; the moment they sign in they would read as
18
+ * an old hand and the tutorial would go quiet mid-welcome. Settling the
19
+ * first reading keeps a newcomer a newcomer.
20
+ */
21
+ export declare function settleTourPrefs(): TourPrefs;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The guided tour's preference, stored per site and per device under
3
+ * `<prefix>:tour:v1`: whether the live tutorial is on, which pages have had
4
+ * their tour, and a page asked to replay once. The rules are in
5
+ * `tour/state.ts`; this is their storage, in the shape `timezone.ts` set.
6
+ */
7
+ import { getStoredNpub } from "./identity.js";
8
+ import { key, readStored, writeStored } from "./storage.js";
9
+ import { fromStored } from "./tour/state.js";
10
+ const PREF = "tour:v1";
11
+ /** `<prefix>:tour:v1` — for a `storage` event listener. */
12
+ export function tourStorageKey() {
13
+ return key(PREF);
14
+ }
15
+ // The last raw value read and what it parsed to. `useSyncExternalStore`
16
+ // wants the same object back while nothing changed; a fresh parse each read
17
+ // would be a new object every time and a render that never settles.
18
+ let lastRaw = null;
19
+ let lastOldHand = false;
20
+ let lastPrefs = null;
21
+ export function readTourPrefs() {
22
+ const raw = readStored(PREF);
23
+ // A device that already holds a patron npub but no tour preference has
24
+ // been here since before the tour existed, and is left in peace. Read each
25
+ // time: signing in changes it.
26
+ const oldHand = raw === "" && !!getStoredNpub();
27
+ if (lastPrefs && raw === lastRaw && oldHand === lastOldHand)
28
+ return lastPrefs;
29
+ let parsed = null;
30
+ if (raw) {
31
+ try {
32
+ parsed = JSON.parse(raw);
33
+ }
34
+ catch {
35
+ parsed = null;
36
+ }
37
+ }
38
+ lastPrefs = fromStored(parsed, oldHand);
39
+ lastRaw = raw;
40
+ lastOldHand = oldHand;
41
+ return lastPrefs;
42
+ }
43
+ const listeners = new Set();
44
+ /** Store the preference and tell every listener on the page. */
45
+ export function writeTourPrefs(next) {
46
+ writeStored(PREF, JSON.stringify(next));
47
+ lastRaw = null;
48
+ const stored = readTourPrefs();
49
+ listeners.forEach((fn) => fn(stored));
50
+ return stored;
51
+ }
52
+ /** Hear every `writeTourPrefs` on this page. Returns an unsubscribe. */
53
+ export function onTourChange(fn) {
54
+ listeners.add(fn);
55
+ return () => {
56
+ listeners.delete(fn);
57
+ };
58
+ }
59
+ /**
60
+ * Write the preference down if nothing is stored yet. A newcomer is read as
61
+ * "on" while they hold no npub; the moment they sign in they would read as
62
+ * an old hand and the tutorial would go quiet mid-welcome. Settling the
63
+ * first reading keeps a newcomer a newcomer.
64
+ */
65
+ export function settleTourPrefs() {
66
+ const current = readTourPrefs();
67
+ if (readStored(PREF) === "")
68
+ writeTourPrefs(current);
69
+ return current;
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tollbooth-dpyc/web",
3
- "version": "1.7.1",
3
+ "version": "1.8.1",
4
4
  "description": "Shared browser SDK for Tollbooth DPYC operator front ends: npub sign-in, proofs, MCP client, profile and wallet",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Lonnie VanZandt",
@@ -25,11 +25,13 @@
25
25
  "types": "./dist/pagesProxy.d.ts",
26
26
  "default": "./dist/pagesProxy.js"
27
27
  },
28
- "./theme.css": "./theme.css"
28
+ "./theme.css": "./theme.css",
29
+ "./tour.css": "./tour.css"
29
30
  },
30
31
  "files": [
31
32
  "dist",
32
33
  "theme.css",
34
+ "tour.css",
33
35
  "README.md",
34
36
  "LICENSE"
35
37
  ],
@@ -55,5 +57,8 @@
55
57
  },
56
58
  "publishConfig": {
57
59
  "access": "public"
60
+ },
61
+ "dependencies": {
62
+ "driver.js": "1.9.0"
58
63
  }
59
64
  }