@tollbooth-dpyc/web 1.7.0 → 1.8.0

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.
package/README.md CHANGED
@@ -261,6 +261,48 @@ with the step under way lit from the gate's real stage, and `SignInLinks` —
261
261
  headless and exported on their own; `classNames.steps` / `classNames.links`
262
262
  restyle them inside the gate. `steps={false}` / `links={false}` remove them.
263
263
 
264
+ ### Guided tour
265
+
266
+ A page can walk itself through: small popovers in turn, each pointing at a
267
+ control and saying what it is for. The package carries the mechanics — the
268
+ popover (driver.js), the preference, who sees it, the offer, the copy lint
269
+ and the two tours every site shares (the sign-in gate and the account page's
270
+ panels). A site supplies its `data-tour` names and its own words.
271
+
272
+ ```tsx
273
+ import { signInTour, accountTour, type Tour } from "@tollbooth-dpyc/web";
274
+ import { TourProvider, useTour, useTourSwitch, useTourReplay, tourTarget } from "@tollbooth-dpyc/web/react";
275
+
276
+ const TOURS: Record<string, Tour> = {
277
+ signin: signInTour(),
278
+ account: accountTour("account", [{ target: "account.sound", title: "Sound", says: "The hive hums. Switch it off here." }]),
279
+ play: { page: "play", steps: [
280
+ { target: "play.practice", title: "Practice", says: "A round against the house, free. Tap it to learn the moves." },
281
+ ]},
282
+ };
283
+
284
+ <TourProvider tours={TOURS}>…</TourProvider> // once, around the app
285
+ useTour("play", { actions: { "open-lobby": () => setMode("live") } }); // once per page
286
+ <button {...tourTarget("play.practice")}>Practice</button>
287
+ ```
288
+
289
+ Who sees it: a new device starts with the live tutorial on; a device that
290
+ already holds a patron npub starts with it off. On a page that is due, after
291
+ ten seconds untouched (`configureTollbooth({ tourIdleMs })`, or
292
+ `TourProvider idleMs`), a centred popover asks whether they would like a tour;
293
+ Yes starts it, No marks the page. A step whose control is absent is skipped;
294
+ a step with `before` has the page open something first, by the name the page
295
+ registered in `actions`. "Stop the tutorial" sits in every popover. The site's
296
+ own switch and replay button take their mechanics from `useTourSwitch()` and
297
+ `useTourReplay()`.
298
+
299
+ Import `@tollbooth-dpyc/web/tour.css` beside `theme.css`; the popover takes the
300
+ site's `--tb-*` tokens, and `.tb-tour` can be restyled further. The package's
301
+ markup carries `tb.gate.*` and `tb.account.*` names (`TB_TARGETS`); the two
302
+ shared tours point at them. `lintTours(TOURS)` in a site's test holds the copy
303
+ to the voice: one or two short sentences saying what a thing is. A sentence
304
+ carrying "not", "no", "never", "instead of" or "rather than" fails it.
305
+
264
306
  ### Debug panel
265
307
 
266
308
  `callTool` logs to a shared, scrubbed ring buffer; `DebugPanel` shows it as a
package/dist/config.d.ts CHANGED
@@ -29,11 +29,14 @@ export interface TollboothConfig {
29
29
  quietTools?: string[];
30
30
  /** Avatar glyphs in this site's voice. Defaults to a general set. */
31
31
  avatarChoices?: string[];
32
+ /** How long a page sits untouched before its guided tour is offered, in ms. Defaults to 10 s. */
33
+ tourIdleMs?: number;
32
34
  }
33
- interface Resolved extends Required<Omit<TollboothConfig, "extraBootstrapTools" | "quietTools" | "avatarChoices">> {
35
+ interface Resolved extends Required<Omit<TollboothConfig, "extraBootstrapTools" | "quietTools" | "avatarChoices" | "tourIdleMs">> {
34
36
  extraBootstrapTools: string[];
35
37
  quietTools: string[];
36
38
  avatarChoices?: string[];
39
+ tourIdleMs?: number;
37
40
  }
38
41
  export declare function configureTollbooth(config: TollboothConfig): void;
39
42
  export declare function tollboothConfig(): Resolved;
package/dist/config.js CHANGED
@@ -20,6 +20,7 @@ export function configureTollbooth(config) {
20
20
  extraBootstrapTools: config.extraBootstrapTools ?? [],
21
21
  quietTools: config.quietTools ?? [],
22
22
  avatarChoices: config.avatarChoices,
23
+ tourIdleMs: config.tourIdleMs,
23
24
  };
24
25
  }
25
26
  export function tollboothConfig() {
package/dist/index.d.ts CHANGED
@@ -22,3 +22,9 @@ export * from "./funding.ts";
22
22
  export { errorReport } from "./errorReport.ts";
23
23
  export { loadQuotes, peekQuotes, shuffle, validQuotes, type Quote } from "./quotes.ts";
24
24
  export { captureGlobalErrors, clearDebug, configureDebugLog, debugEntries, debugLogText, debugPush, debugSeverity, onDebug, redact, type DebugEntry, type DebugLogOptions, type DebugSeverity, } from "./debugLog.ts";
25
+ export type { Tour, TourActions, TourStep } from "./tour/types.ts";
26
+ export { fromStored as tourPrefsFromStored, markToured, replayAll, replayPage, shouldRun, stopAll, TOUR_DEFAULTS, type TourPrefs, } from "./tour/state.ts";
27
+ export { ACTIVITY_EVENTS, DEFAULT_IDLE_MS, idleThreshold, offerDue } from "./tour/idle.ts";
28
+ export { lintStep, lintTour, lintTours, MAX_SAYS, MAX_SENTENCES, MAX_TITLE } from "./tour/lint.ts";
29
+ export { accountPanelsSteps, accountTour, signInTour, TB_TARGETS } from "./tour/tours.ts";
30
+ export { onTourChange, readTourPrefs, settleTourPrefs, tourStorageKey, writeTourPrefs } from "./tourPrefs.ts";
package/dist/index.js CHANGED
@@ -21,3 +21,8 @@ export * from "./funding.js";
21
21
  export { errorReport } from "./errorReport.js";
22
22
  export { loadQuotes, peekQuotes, shuffle, validQuotes } from "./quotes.js";
23
23
  export { captureGlobalErrors, clearDebug, configureDebugLog, debugEntries, debugLogText, debugPush, debugSeverity, onDebug, redact, } from "./debugLog.js";
24
+ export { fromStored as tourPrefsFromStored, markToured, replayAll, replayPage, shouldRun, stopAll, TOUR_DEFAULTS, } from "./tour/state.js";
25
+ export { ACTIVITY_EVENTS, DEFAULT_IDLE_MS, idleThreshold, offerDue } from "./tour/idle.js";
26
+ export { lintStep, lintTour, lintTours, MAX_SAYS, MAX_SENTENCES, MAX_TITLE } from "./tour/lint.js";
27
+ export { accountPanelsSteps, accountTour, signInTour, TB_TARGETS } from "./tour/tours.js";
28
+ export { onTourChange, readTourPrefs, settleTourPrefs, tourStorageKey, writeTourPrefs } from "./tourPrefs.js";
@@ -84,7 +84,7 @@ export default function CouponsPanel({ heading = "My coupons", intro = DEFAULT_I
84
84
  setMsg({ ok: false, text: e.message });
85
85
  }
86
86
  }
87
- return (_jsxs("section", { className: c.root, children: [heading && _jsx("div", { className: c.heading, children: heading }), intro && _jsx("p", { className: c.intro, children: intro }), formHeading && _jsx("div", { className: c.subheading, children: formHeading }), _jsxs("div", { className: c.form, children: [_jsx("input", { type: "text", value: code, onChange: (e) => setCode(e.target.value.toUpperCase()), onKeyDown: (e) => {
87
+ return (_jsxs("section", { className: c.root, "data-tour": "tb.account.coupons", children: [heading && _jsx("div", { className: c.heading, children: heading }), intro && _jsx("p", { className: c.intro, children: intro }), formHeading && _jsx("div", { className: c.subheading, children: formHeading }), _jsxs("div", { className: c.form, children: [_jsx("input", { type: "text", value: code, onChange: (e) => setCode(e.target.value.toUpperCase()), onKeyDown: (e) => {
88
88
  if (e.key === "Enter")
89
89
  void redeem();
90
90
  }, placeholder: placeholder, "aria-label": "Coupon code", autoCapitalize: "characters", autoCorrect: "off", spellCheck: false, disabled: redeeming, className: c.input }), _jsx("button", { type: "button", onClick: () => void redeem(), disabled: redeeming || !code.trim(), className: c.primary ?? c.chip, children: redeeming ? "Redeeming…" : redeemLabel })] }), msg && (_jsx("p", { role: "status", className: cx(c.message, msg.ok ? c.ok : c.error), children: msg.text })), loading ? (_jsx("p", { className: c.loading, children: "Loading\u2026" })) : loadError ? (_jsx("p", { className: cx(c.message, c.error), children: loadError })) : coupons.length === 0 ? (empty && _jsx("p", { className: c.empty, children: empty })) : (_jsxs(_Fragment, { children: [listHeading && _jsx("div", { className: c.subheading, children: listHeading }), _jsx("ul", { className: c.list, children: coupons.map((coupon) => renderCoupon ? (_jsx("li", { className: c.row, children: renderCoupon(coupon, () => void forget(coupon)) }, coupon.coupon_id)) : (_jsx(CouponRow, { coupon: coupon, c: c, forgetLabel: forgetLabel, onForget: () => void forget(coupon) }, coupon.coupon_id))) })] }))] }));
@@ -14,8 +14,8 @@
14
14
  * Touch-first: every control is a real button at least 40px square, nothing
15
15
  * hides behind hover, and the page under the bar stays tappable.
16
16
  *
17
- * The panel keeps its own space: beside the fixed bar it renders an in-flow
18
- * spacer as tall as the bar is right now (collapsed or open, measured with a
17
+ * While open, the panel keeps its own space: beside the fixed bar it renders
18
+ * an in-flow spacer as tall as the log is right now (measured with a
19
19
  * ResizeObserver) plus the iOS safe-area inset, so the page's last line can
20
20
  * always scroll clear of it. Mount it LAST in the app shell so that spacer is
21
21
  * the last thing in the page's flow.
@@ -15,8 +15,8 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
15
15
  * Touch-first: every control is a real button at least 40px square, nothing
16
16
  * hides behind hover, and the page under the bar stays tappable.
17
17
  *
18
- * The panel keeps its own space: beside the fixed bar it renders an in-flow
19
- * spacer as tall as the bar is right now (collapsed or open, measured with a
18
+ * While open, the panel keeps its own space: beside the fixed bar it renders
19
+ * an in-flow spacer as tall as the log is right now (measured with a
20
20
  * ResizeObserver) plus the iOS safe-area inset, so the page's last line can
21
21
  * always scroll clear of it. Mount it LAST in the app shell so that spacer is
22
22
  * the last thing in the page's flow.
@@ -25,7 +25,7 @@ import { useEffect, useRef, useState } from "react";
25
25
  import { onProofExpired } from "../client.js";
26
26
  import { captureGlobalErrors, clearDebug, debugLogText, debugPush, debugSeverity, } from "../debugLog.js";
27
27
  import { readStored, writeStored } from "../storage.js";
28
- import { debugSpacerHeight, debugSpacerPx } from "./debugSpacer.js";
28
+ import { debugSpacerPx, debugSpacerRoom } from "./debugSpacer.js";
29
29
  import { useDebugLog } from "./useDebugLog.js";
30
30
  const OPEN_KEY = "debug-open";
31
31
  const TYPE_INK = {
@@ -100,7 +100,7 @@ export default function DebugPanel({ title = "Debug", defaultOpen = false, captu
100
100
  : notices > 0
101
101
  ? "bg-[var(--tb-notice-bg)] text-[var(--tb-notice-ink)] border-[var(--tb-notice-line)]"
102
102
  : "bg-[var(--tb-surface-2)] text-[var(--tb-ink)] border-[var(--tb-line)]";
103
- return (_jsxs(_Fragment, { children: [_jsx("div", { "aria-hidden": "true", "data-tb-debug-spacer": "", style: { height: debugSpacerHeight(barHeight), flexShrink: 0 } }), _jsxs("div", { ref: barRef, className: "pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col items-end", children: [_jsxs("div", { className: "pointer-events-auto flex flex-wrap justify-end gap-1 pr-3", children: [open && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: copy, className: plainTab, "aria-label": "Copy the whole log", children: copied === "ok" ? "Copied" : copied === "failed" ? "Copy failed" : "Copy" }), _jsx("button", { type: "button", onClick: clearDebug, className: plainTab, children: "Clear" })] })), _jsxs("button", { type: "button", onClick: toggle, "aria-expanded": open, className: `${tab} ${toggleTone}`, children: [open ? "Hide" : title, " (", log.length, failures > 0 ? ` · ${failures} err` : "", notices > 0 ? ` · ${notices} notice` : "", ")"] })] }), open && (_jsxs("div", { className: "pointer-events-auto max-h-[40vh] w-full overflow-y-auto border-t border-[var(--tb-line)] bg-[var(--tb-surface)] p-3 font-mono text-xs text-[var(--tb-ink)]", children: [children && _jsx("div", { className: "mb-2 border-b border-[var(--tb-line)] pb-2 font-sans", children: children }), log.length === 0 && _jsx("div", { className: "text-[var(--tb-muted)]", children: "No MCP activity yet." }), log.map((entry, i) => {
103
+ return (_jsxs(_Fragment, { children: [_jsx("div", { "aria-hidden": "true", "data-tb-debug-spacer": "", style: { height: debugSpacerRoom(open, barHeight), flexShrink: 0 } }), _jsxs("div", { ref: barRef, className: "pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col items-end", children: [_jsxs("div", { className: "pointer-events-auto flex flex-wrap justify-end gap-1 pr-3", children: [open && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: copy, className: plainTab, "aria-label": "Copy the whole log", children: copied === "ok" ? "Copied" : copied === "failed" ? "Copy failed" : "Copy" }), _jsx("button", { type: "button", onClick: clearDebug, className: plainTab, children: "Clear" })] })), _jsxs("button", { type: "button", onClick: toggle, "aria-expanded": open, className: `${tab} ${toggleTone}`, children: [open ? "Hide" : title, " (", log.length, failures > 0 ? ` · ${failures} err` : "", notices > 0 ? ` · ${notices} notice` : "", ")"] })] }), open && (_jsxs("div", { className: "pointer-events-auto max-h-[40vh] w-full overflow-y-auto border-t border-[var(--tb-line)] bg-[var(--tb-surface)] p-3 font-mono text-xs text-[var(--tb-ink)]", children: [children && _jsx("div", { className: "mb-2 border-b border-[var(--tb-line)] pb-2 font-sans", children: children }), log.length === 0 && _jsx("div", { className: "text-[var(--tb-muted)]", children: "No MCP activity yet." }), log.map((entry, i) => {
104
104
  const sev = debugSeverity(entry);
105
105
  const flag = sev === "ok" ? null : FLAGGED[sev];
106
106
  return (_jsxs("div", { className: `-mx-1 flex gap-2 rounded px-1 py-0.5 ${flag?.row ?? ""}`, children: [_jsx("span", { className: "shrink-0 text-[var(--tb-muted)]", children: entry.ts }), _jsxs("span", { className: `w-14 shrink-0 ${flag?.ink ?? TYPE_INK[entry.type]}`, children: [sev === "notice" ? "notice" : entry.type, sev === "failure" && entry.type !== "error" ? " !" : ""] }), _jsx("span", { className: `min-w-0 break-all ${flag?.ink ?? ""}`, children: entry.message })] }, log.length - i));
@@ -112,7 +112,7 @@ export default function NostrProfilePanel({ npub, onPublished, }) {
112
112
  setPublishing(false);
113
113
  }
114
114
  }
115
- return (_jsxs("div", { className: `${card} px-4 py-3`, children: [_jsxs("div", { className: "flex items-start gap-3", children: [_jsxs("div", { className: "relative flex-none", children: [_jsx(Avatar, { value: profile.picture || avatarFor(npub), size: 56 }), _jsx("button", { type: "button", onClick: () => {
115
+ return (_jsxs("div", { className: `${card} px-4 py-3`, "data-tour": "tb.account.profile", children: [_jsxs("div", { className: "flex items-start gap-3", children: [_jsxs("div", { className: "relative flex-none", children: [_jsx(Avatar, { value: profile.picture || avatarFor(npub), size: 56 }), _jsx("button", { type: "button", onClick: () => {
116
116
  setExpanded(true);
117
117
  setShowPicker((v) => !v);
118
118
  }, "aria-label": showPicker ? "Done changing avatar" : "Change avatar", "aria-expanded": showPicker, className: "absolute -bottom-0.5 -right-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full border border-[var(--tb-line)] bg-[var(--tb-surface)] shadow-sm", children: _jsx(Camera, { className: "h-3.5 w-3.5", "aria-hidden": "true" }) })] }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [_jsx("div", { className: "text-sm font-medium truncate", children: collapsedDisplayName(profile.display_name ?? "") }), mode === "how-to-set" && (_jsx("span", { className: `text-[11px] px-1.5 py-0.5 rounded-md bg-[var(--tb-surface-2)] ${muted}`, children: "Read-only" }))] }), _jsx("button", { type: "button", onClick: copyNpub, title: "Copy full npub", className: `mt-0.5 font-mono text-xs ${muted}`, children: copied ? "Copied" : shortNpub(npub) })] }), _jsxs("button", { type: "button", onClick: () => setExpanded((v) => !v), "aria-expanded": expanded, "aria-controls": fieldsId, className: "flex-none inline-flex items-center gap-1 rounded-full border border-[var(--tb-line)] px-2.5 py-1.5 text-xs", children: [expanded ? "Hide" : "Edit", _jsx(ChevronDown, { className: `h-3.5 w-3.5 transition-transform ${expanded ? "rotate-180" : ""}`, "aria-hidden": "true" })] })] }), showPicker && (_jsx("div", { className: "mt-3", children: _jsx(AvatarPicker, { value: profile.picture ?? "", onChange: pickAvatar }) })), loading ? (_jsxs("div", { className: `flex items-center gap-1.5 text-xs py-2 mt-3 ${muted}`, children: [_jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }), " Reading from relays\u2026"] })) : (_jsxs("div", { id: fieldsId, hidden: !expanded, className: expanded ? "mt-3 grid gap-x-3 gap-y-2 sm:grid-cols-2" : undefined, children: [FIELDS.map((f) => (_jsxs("label", { className: `block text-xs ${muted} ${f.key === "about" ? "sm:col-span-2" : ""}`, children: [f.label, f.key === "about" ? (_jsx("textarea", { value: profile[f.key] ?? "", onChange: (e) => set(f.key, e.target.value), rows: 2, className: `mt-1 ${input} font-sans resize-none`, placeholder: f.placeholder, readOnly: mode === "how-to-set" })) : (_jsx("input", { value: profile[f.key] ?? "", onChange: (e) => set(f.key, e.target.value), className: `mt-1 ${input} font-sans`, placeholder: f.placeholder, readOnly: mode === "how-to-set" }))] }, f.key))), msg && (_jsx("div", { className: `text-xs sm:col-span-2 ${msg.ok ? "text-[var(--tb-ok)]" : "text-[var(--tb-err-ink)]"}`, children: msg.text })), _jsxs("div", { className: "relative flex items-center gap-3 sm:col-span-2", ref: howToRef, children: [_jsx("button", { type: "button", onClick: () => (mode === "how-to-set" ? setShowHowTo((v) => !v) : void publish()), disabled: publishControlDisabled(mode, publishing), "aria-haspopup": mode === "how-to-set" ? "dialog" : undefined, "aria-expanded": mode === "how-to-set" ? showHowTo : undefined, className: mode === "publish"
@@ -167,7 +167,7 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
167
167
  setPendingProof("");
168
168
  setError("");
169
169
  }
170
- return (_jsxs("div", { className: cx("max-w-md mx-auto mt-12 px-4", c.root), children: [_jsxs("h1", { className: "text-xl font-semibold mb-1", children: ["Sign in to ", appName] }), welcome && _jsx("div", { className: cx("text-sm mb-3", c.welcome), children: welcome }), _jsxs("p", { className: cx(`text-sm mb-5 ${muted}`, c.register), children: [appName, " is an operator on the Tollbooth DPYC\u2122 network. DPYC\u2122 \u2014 Don't Pester Your Customer\u2122: no email, no password, no KYC. Your Nostr key is your identity."] }), notice && _jsx("div", { className: `mb-5 ${warnBox}`, children: notice }), stage === "begin" && recents.length > 0 && (_jsxs("div", { className: "mb-5", children: [_jsx("div", { className: `text-xs uppercase tracking-wider mb-2 ${muted}`, children: "Recent identities" }), _jsx("div", { className: "space-y-1.5", children: recents.map((e) => (_jsxs("div", { className: "flex gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => reuseRecent(e), disabled: busy, title: `Re-enter as ${e.npub} on the cached proof`, className: `${card} flex-1 flex items-center justify-between px-3 py-2.5 text-left hover:border-[var(--tb-accent)]`, children: [_jsxs("span", { className: "font-mono text-xs truncate", children: [e.npub.slice(0, 12), "\u2026", e.npub.slice(-6)] }), _jsxs("span", { className: `text-xs shrink-0 ml-2 ${muted}`, children: [ttl(e.expiresAt), " left"] })] }), _jsx("button", { type: "button", onClick: () => forget(e.npub), disabled: busy, title: "Forget this identity", "aria-label": "Forget this identity", className: `w-9 rounded-lg border border-[var(--tb-line)] ${muted}`, children: "\u00D7" })] }, e.npub))) })] })), _jsxs("div", { className: `${card} p-4 space-y-3`, children: [stage === "begin" ? (_jsxs(_Fragment, { children: [_jsx("label", { className: `block text-xs uppercase tracking-wider ${muted}`, htmlFor: "tb-npub-field", children: "Enter npub (secure) or nsec (convenient)" }), _jsx("input", { id: "tb-npub-field", type: isNsec ? "password" : "text", value: value, onChange: (e) => {
170
+ return (_jsxs("div", { className: cx("max-w-md mx-auto mt-12 px-4", c.root), children: [_jsxs("h1", { className: "text-xl font-semibold mb-1", children: ["Sign in to ", appName] }), welcome && _jsx("div", { className: cx("text-sm mb-3", c.welcome), children: welcome }), _jsxs("p", { className: cx(`text-sm mb-5 ${muted}`, c.register), children: [appName, " is an operator on the Tollbooth DPYC\u2122 network. DPYC\u2122 \u2014 Don't Pester Your Customer\u2122: no email, no password, no KYC. Your Nostr key is your identity."] }), notice && _jsx("div", { className: `mb-5 ${warnBox}`, children: notice }), stage === "begin" && recents.length > 0 && (_jsxs("div", { className: "mb-5", children: [_jsx("div", { className: `text-xs uppercase tracking-wider mb-2 ${muted}`, children: "Recent identities" }), _jsx("div", { className: "space-y-1.5", children: recents.map((e) => (_jsxs("div", { className: "flex gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => reuseRecent(e), disabled: busy, title: `Re-enter as ${e.npub} on the cached proof`, className: `${card} flex-1 flex items-center justify-between px-3 py-2.5 text-left hover:border-[var(--tb-accent)]`, children: [_jsxs("span", { className: "font-mono text-xs truncate", children: [e.npub.slice(0, 12), "\u2026", e.npub.slice(-6)] }), _jsxs("span", { className: `text-xs shrink-0 ml-2 ${muted}`, children: [ttl(e.expiresAt), " left"] })] }), _jsx("button", { type: "button", onClick: () => forget(e.npub), disabled: busy, title: "Forget this identity", "aria-label": "Forget this identity", className: `w-9 rounded-lg border border-[var(--tb-line)] ${muted}`, children: "\u00D7" })] }, e.npub))) })] })), _jsxs("div", { className: `${card} p-4 space-y-3`, children: [stage === "begin" ? (_jsxs(_Fragment, { children: [_jsx("label", { className: `block text-xs uppercase tracking-wider ${muted}`, htmlFor: "tb-npub-field", children: "Enter npub (secure) or nsec (convenient)" }), _jsx("input", { id: "tb-npub-field", "data-tour": "tb.gate.npub", type: isNsec ? "password" : "text", value: value, onChange: (e) => {
171
171
  setValue(e.target.value);
172
172
  setGeneratedHint(false);
173
173
  }, onKeyDown: (e) => {
@@ -177,7 +177,7 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
177
177
  signInWithNsec();
178
178
  else
179
179
  void begin();
180
- }, placeholder: "npub1\u2026", spellCheck: false, autoCapitalize: "off", autoCorrect: "off", className: input }), generatedHint && (_jsxs("div", { className: warnBox, children: ["New key generated. ", _jsx("b", { children: "Save this nsec" }), " somewhere safe \u2014 it's the only copy and it lives only in this browser."] })), _jsx("button", { type: "button", onClick: () => (isNsec ? signInWithNsec() : void begin()), disabled: !valid || busy, className: primary, children: busy ? "Sending…" : isNpub ? "Send message" : "Sign in" }), isNsec && _jsx("p", { className: `text-xs ${muted}`, children: "Your nsec stays in this browser and signs each call." }), isNpub && (_jsx("p", { className: `text-xs ${muted}`, children: "We send a message to your npub. Approve it from your Nostr client \u2014 your signature is the proof." })), _jsx("p", { className: `text-xs pt-1 ${muted}`, children: "Need a new identity?" }), _jsx("button", { type: "button", onClick: generateKey, className: ghost, children: "Generate a new key" })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: `${warnBox} space-y-1.5`, children: [_jsx("div", { className: "font-medium", children: "Message sent \u2014 open your Nostr client." }), _jsx("div", { children: "Reply with any text. Your signature on that reply is the proof." }), operatorHash && (_jsxs("div", { children: ["Sender fingerprint: ", _jsxs("span", { className: "font-mono", children: ["\uD83D\uDD12 ", operatorHash] })] }))] }), pendingProof && (_jsxs("div", { className: "rounded-lg p-3 text-xs border border-[var(--tb-accent)] space-y-2", children: [_jsx("div", { className: `uppercase tracking-wider text-[10px] ${muted}`, children: "Confirmation code" }), _jsx("div", { className: "font-mono text-base select-all", children: pendingProof }), _jsxs("div", { className: `leading-relaxed ${muted}`, children: ["The same code appears in the message. ", _jsx("b", { children: "Approve it only if the codes match." }), " If they differ \u2014 or the message points you somewhere other than this site \u2014 do not reply."] })] })), _jsx("button", { type: "button", onClick: () => void finish(), disabled: busy, className: primary, children: busy ? "Checking…" : "I've replied — verify" }), _jsx("button", { type: "button", onClick: () => void begin(), disabled: busy, className: ghost, children: "Resend" }), _jsx("button", { type: "button", onClick: reset, disabled: busy, className: `w-full text-xs py-1.5 ${muted}`, children: "Use a different npub" })] })), error && _jsx(Trouble, { raw: error, appName: appName }), note && _jsx("div", { className: `text-xs text-center italic ${muted}`, children: note })] }), steps && _jsx(SignInSteps, { stage: gateStage(stage, busy), classNames: { ...STEPS_LOOK, ...c.steps } }), links && _jsx(SignInLinks, { credit: linksCredit, classNames: { ...LINKS_LOOK, ...c.links } })] }));
180
+ }, placeholder: "npub1\u2026", spellCheck: false, autoCapitalize: "off", autoCorrect: "off", className: input }), generatedHint && (_jsxs("div", { className: warnBox, children: ["New key generated. ", _jsx("b", { children: "Save this nsec" }), " somewhere safe \u2014 it's the only copy and it lives only in this browser."] })), _jsx("button", { type: "button", onClick: () => (isNsec ? signInWithNsec() : void begin()), disabled: !valid || busy, className: primary, "data-tour": "tb.gate.send", children: busy ? "Sending…" : isNpub ? "Send message" : "Sign in" }), isNsec && _jsx("p", { className: `text-xs ${muted}`, children: "Your nsec stays in this browser and signs each call." }), isNpub && (_jsx("p", { className: `text-xs ${muted}`, children: "We send a message to your npub. Approve it from your Nostr client \u2014 your signature is the proof." })), _jsx("p", { className: `text-xs pt-1 ${muted}`, children: "Need a new identity?" }), _jsx("button", { type: "button", onClick: generateKey, className: ghost, children: "Generate a new key" })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: `${warnBox} space-y-1.5`, children: [_jsx("div", { className: "font-medium", children: "Message sent \u2014 open your Nostr client." }), _jsx("div", { children: "Reply with any text. Your signature on that reply is the proof." }), operatorHash && (_jsxs("div", { children: ["Sender fingerprint: ", _jsxs("span", { className: "font-mono", children: ["\uD83D\uDD12 ", operatorHash] })] }))] }), pendingProof && (_jsxs("div", { className: "rounded-lg p-3 text-xs border border-[var(--tb-accent)] space-y-2", children: [_jsx("div", { className: `uppercase tracking-wider text-[10px] ${muted}`, children: "Confirmation code" }), _jsx("div", { className: "font-mono text-base select-all", children: pendingProof }), _jsxs("div", { className: `leading-relaxed ${muted}`, children: ["The same code appears in the message. ", _jsx("b", { children: "Approve it only if the codes match." }), " If they differ \u2014 or the message points you somewhere other than this site \u2014 do not reply."] })] })), _jsx("button", { type: "button", onClick: () => void finish(), disabled: busy, className: primary, "data-tour": "tb.gate.verify", children: busy ? "Checking…" : "I've replied — verify" }), _jsx("button", { type: "button", onClick: () => void begin(), disabled: busy, className: ghost, children: "Resend" }), _jsx("button", { type: "button", onClick: reset, disabled: busy, className: `w-full text-xs py-1.5 ${muted}`, children: "Use a different npub" })] })), error && _jsx(Trouble, { raw: error, appName: appName }), note && _jsx("div", { className: `text-xs text-center italic ${muted}`, children: note })] }), steps && _jsx(SignInSteps, { stage: gateStage(stage, busy), classNames: { ...STEPS_LOOK, ...c.steps } }), links && _jsx(SignInLinks, { credit: linksCredit, classNames: { ...LINKS_LOOK, ...c.links } })] }));
181
181
  }
182
182
  /** A failure, read to the person: the lead first, the service's own words underneath. */
183
183
  function Trouble({ raw, appName }) {
@@ -155,9 +155,9 @@ function SessionKeyClaimInner({ actionClass }) {
155
155
  }
156
156
  }
157
157
  if (!revealed) {
158
- return (_jsxs("div", { className: `${card} px-4 py-3`, children: [_jsx("div", { className: "eyebrow mb-1", children: "Session key" }), _jsxs("div", { className: "flex flex-wrap items-center gap-x-4 gap-y-2", children: [_jsx("p", { className: `min-w-[14rem] flex-1 text-[12.5px] leading-snug ${muted}`, children: "This browser is holding the secret key that signs you in. Take a copy before you clear site data or switch devices \u2014 without it, this identity is gone." }), _jsx("button", { type: "button", onClick: () => setRevealed(true), className: "min-h-11 shrink-0 rounded-full border border-[var(--tb-accent)] bg-[var(--tb-accent)] px-4 text-[13px] font-semibold text-[var(--tb-on-accent)]", children: "Claim your session key" })] })] }));
158
+ return (_jsxs("div", { className: `${card} px-4 py-3`, "data-tour": "tb.account.sessionKey", children: [_jsx("div", { className: "eyebrow mb-1", children: "Session key" }), _jsxs("div", { className: "flex flex-wrap items-center gap-x-4 gap-y-2", children: [_jsx("p", { className: `min-w-[14rem] flex-1 text-[12.5px] leading-snug ${muted}`, children: "This browser is holding the secret key that signs you in. Take a copy before you clear site data or switch devices \u2014 without it, this identity is gone." }), _jsx("button", { type: "button", onClick: () => setRevealed(true), className: "min-h-11 shrink-0 rounded-full border border-[var(--tb-accent)] bg-[var(--tb-accent)] px-4 text-[13px] font-semibold text-[var(--tb-on-accent)]", children: "Claim your session key" })] })] }));
159
159
  }
160
- return (_jsxs("div", { className: `relative ${card} px-4 py-3`, children: [_jsxs("div", { className: "flex flex-wrap items-baseline justify-between gap-2", children: [_jsx("div", { className: "eyebrow mb-1", children: "Session key" }), _jsx("button", { type: "button", onClick: () => {
160
+ return (_jsxs("div", { className: `relative ${card} px-4 py-3`, "data-tour": "tb.account.sessionKey", children: [_jsxs("div", { className: "flex flex-wrap items-baseline justify-between gap-2", children: [_jsx("div", { className: "eyebrow mb-1", children: "Session key" }), _jsx("button", { type: "button", onClick: () => {
161
161
  setRevealed(false);
162
162
  setShowKey(false);
163
163
  setDmOpen(false);
@@ -2,5 +2,5 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from "./cx.js";
3
3
  import { SIGN_IN_LINKS, SIGN_IN_LINKS_CREDIT } from "./signInTools.js";
4
4
  export default function SignInLinks({ links = SIGN_IN_LINKS, lead = "New to Nostr? These two are all it takes.", credit = true, classNames: c = {}, }) {
5
- return (_jsxs("nav", { "aria-label": "Tools for signing in", className: c.root, children: [lead && _jsx("p", { className: c.lead, children: lead }), _jsx("ul", { className: c.list, children: links.map((l) => (_jsx("li", { className: c.item, children: _jsxs("a", { href: l.href, target: "_blank", rel: "noopener noreferrer", className: cx(c.link), children: [l.logo && _jsx("img", { src: l.logo, alt: "", width: 40, height: 40, className: c.logo }), _jsxs("span", { className: c.text, children: [_jsx("span", { className: c.name, children: l.name }), _jsx("span", { className: c.line, children: l.line })] })] }) }, l.id))) }), credit && _jsx("p", { className: c.credit, children: SIGN_IN_LINKS_CREDIT })] }));
5
+ return (_jsxs("nav", { "aria-label": "Tools for signing in", className: c.root, "data-tour": "tb.gate.links", children: [lead && _jsx("p", { className: c.lead, children: lead }), _jsx("ul", { className: c.list, children: links.map((l) => (_jsx("li", { className: c.item, children: _jsxs("a", { href: l.href, target: "_blank", rel: "noopener noreferrer", className: cx(c.link), children: [l.logo && _jsx("img", { src: l.logo, alt: "", width: 40, height: 40, className: c.logo }), _jsxs("span", { className: c.text, children: [_jsx("span", { className: c.name, children: l.name }), _jsx("span", { className: c.line, children: l.line })] })] }) }, l.id))) }), credit && _jsx("p", { className: c.credit, children: SIGN_IN_LINKS_CREDIT })] }));
6
6
  }
@@ -13,7 +13,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
13
  import { cx } from "./cx.js";
14
14
  import { SIGN_IN_ACTORS, SIGN_IN_STEPS, stepState, } from "./signInFlow.js";
15
15
  export default function SignInSteps({ stage, steps = SIGN_IN_STEPS, actors = SIGN_IN_ACTORS, label = "How signing in works", classNames: c = {}, }) {
16
- return (_jsx("ol", { "aria-label": label, className: c.root, children: steps.map((s, i) => {
16
+ return (_jsx("ol", { "aria-label": label, className: c.root, "data-tour": "tb.gate.steps", children: steps.map((s, i) => {
17
17
  const state = stepState(s, stage);
18
18
  return (_jsxs("li", { "data-state": state, "aria-current": state === "current" ? "step" : undefined, className: cx(c.step, c[state]), children: [_jsx("span", { "aria-hidden": true, className: c.glyph, children: s.glyph }), _jsx("span", { className: c.label, children: s.label }), _jsx("span", { className: c.actor, children: actors[s.actor] }), i < steps.length - 1 && (_jsx("span", { "aria-hidden": true, className: c.connector, children: "\u203A" }))] }, s.id));
19
19
  }) }));
@@ -48,6 +48,6 @@ export default function UsageSummary({ days = 30, heading, figures = ALL, labels
48
48
  void load();
49
49
  }, [load]);
50
50
  const shown = facts ? figures.filter((f) => f !== "expired" || facts.expired > 0) : [];
51
- return (_jsxs("section", { className: c.root, children: [_jsxs("div", { className: c.header, children: [_jsx("div", { className: c.heading, children: heading ?? `Last ${days} days` }), refreshLabel !== null && (_jsx("button", { type: "button", onClick: () => void load(), disabled: loading, className: c.chip, children: loading ? "Loading…" : refreshLabel }))] }), error ? (_jsx("p", { className: c.error, children: error })) : !facts ? (loading && _jsx("p", { className: c.loading, children: "Loading\u2026" })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: c.figures, children: shown.map((f) => (_jsxs("div", { className: c.figure, children: [_jsx("div", { className: c.value, children: facts[f].toLocaleString() }), _jsx("div", { className: c.label, children: labels[f] ?? LABELS[f] })] }, f))) }), topTools > 0 &&
51
+ return (_jsxs("section", { className: c.root, "data-tour": "tb.account.usage", children: [_jsxs("div", { className: c.header, children: [_jsx("div", { className: c.heading, children: heading ?? `Last ${days} days` }), refreshLabel !== null && (_jsx("button", { type: "button", onClick: () => void load(), disabled: loading, className: c.chip, children: loading ? "Loading…" : refreshLabel }))] }), error ? (_jsx("p", { className: c.error, children: error })) : !facts ? (loading && _jsx("p", { className: c.loading, children: "Loading\u2026" })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: c.figures, children: shown.map((f) => (_jsxs("div", { className: c.figure, children: [_jsx("div", { className: c.value, children: facts[f].toLocaleString() }), _jsx("div", { className: c.label, children: labels[f] ?? LABELS[f] })] }, f))) }), topTools > 0 &&
52
52
  (facts.tools.length === 0 ? (empty && _jsx("p", { className: c.empty, children: empty })) : (_jsxs(_Fragment, { children: [toolsHeading && _jsx("div", { className: c.subheading, children: toolsHeading }), _jsx("ul", { className: c.list, children: facts.tools.map((t) => (_jsx("li", { className: c.row, children: renderRow ? (renderRow(t)) : (_jsxs(_Fragment, { children: [_jsx("span", { className: c.tool, children: t.tool }), " ", _jsxs("span", { className: c.calls, children: [t.calls.toLocaleString(), " call", t.calls === 1 ? "" : "s"] }), " ", _jsxs("span", { className: c.sats, children: [t.sats.toLocaleString(), " sats"] })] })) }, t.tool))) })] })))] }))] }));
53
53
  }
@@ -1,11 +1,13 @@
1
1
  /**
2
- * The room the DebugPanel keeps for itself.
2
+ * The room the DebugPanel keeps for itself — only while its log is open.
3
3
  *
4
- * The panel is fixed to the bottom of the viewport, so on its own it would sit
5
- * over whatever the page puts last — on a phone, a loading quote or a Save
6
- * button. Beside it the panel renders an in-flow spacer as tall as the panel is
7
- * right now (the collapsed tab, or the open log), plus the iOS home-indicator
8
- * inset, so the page can always scroll its last line clear of the bar.
4
+ * The panel is fixed to the bottom of the viewport. Collapsed, it is one small
5
+ * tab in the corner and overlays the page: an in-flow spacer for it stole a
6
+ * whole row from every screen (owner, 2026-09-27: "we want an overlay and not
7
+ * a row stealer"). Open, the log is tall and would hide whatever the page puts
8
+ * last — a Save button, a loading quote — so then, and only then, the panel
9
+ * renders an in-flow spacer as tall as it is, plus the iOS home-indicator
10
+ * inset, so the page can scroll its last line clear.
9
11
  */
10
12
  /** The collapsed tab: one `min-h-10` (2.5rem) button. Used until measured. */
11
13
  export declare const DEBUG_BAR_PX = 40;
@@ -13,3 +15,5 @@ export declare const DEBUG_BAR_PX = 40;
13
15
  export declare function debugSpacerPx(measured: number | null | undefined): number;
14
16
  /** The spacer's CSS height: the panel's height plus the safe-area inset. */
15
17
  export declare function debugSpacerHeight(measured: number | null | undefined): string;
18
+ /** What the spacer takes: nothing while the panel is collapsed, its height while open. */
19
+ export declare function debugSpacerRoom(open: boolean, measured: number | null | undefined): string;
@@ -1,11 +1,13 @@
1
1
  /**
2
- * The room the DebugPanel keeps for itself.
2
+ * The room the DebugPanel keeps for itself — only while its log is open.
3
3
  *
4
- * The panel is fixed to the bottom of the viewport, so on its own it would sit
5
- * over whatever the page puts last — on a phone, a loading quote or a Save
6
- * button. Beside it the panel renders an in-flow spacer as tall as the panel is
7
- * right now (the collapsed tab, or the open log), plus the iOS home-indicator
8
- * inset, so the page can always scroll its last line clear of the bar.
4
+ * The panel is fixed to the bottom of the viewport. Collapsed, it is one small
5
+ * tab in the corner and overlays the page: an in-flow spacer for it stole a
6
+ * whole row from every screen (owner, 2026-09-27: "we want an overlay and not
7
+ * a row stealer"). Open, the log is tall and would hide whatever the page puts
8
+ * last — a Save button, a loading quote — so then, and only then, the panel
9
+ * renders an in-flow spacer as tall as it is, plus the iOS home-indicator
10
+ * inset, so the page can scroll its last line clear.
9
11
  */
10
12
  /** The collapsed tab: one `min-h-10` (2.5rem) button. Used until measured. */
11
13
  export const DEBUG_BAR_PX = 40;
@@ -19,3 +21,7 @@ export function debugSpacerPx(measured) {
19
21
  export function debugSpacerHeight(measured) {
20
22
  return `calc(${debugSpacerPx(measured)}px + env(safe-area-inset-bottom, 0px))`;
21
23
  }
24
+ /** What the spacer takes: nothing while the panel is collapsed, its height while open. */
25
+ export function debugSpacerRoom(open, measured) {
26
+ return open ? debugSpacerHeight(measured) : "0px";
27
+ }
@@ -33,3 +33,5 @@ export { useDebugLog } from "./useDebugLog.ts";
33
33
  export { useTheme } from "./useTheme.ts";
34
34
  export { useTimezone } from "./useTimezone.ts";
35
35
  export { useTopUp, type TopUp, type UseTopUpOptions } from "./useTopUp.ts";
36
+ export { TourProvider, tourTarget, useTour, useTourReplay, useTourSwitch, type TourProviderProps, type UseTourOptions, } from "./useTour.tsx";
37
+ export { buildTour, offerTour, OFFER_WORDS, type Offer, type OfferHooks, type OfferWords, type TourHooks, type TourRun } from "../tour/run.ts";
@@ -32,3 +32,5 @@ export { useDebugLog } from "./useDebugLog.js";
32
32
  export { useTheme } from "./useTheme.js";
33
33
  export { useTimezone } from "./useTimezone.js";
34
34
  export { useTopUp } from "./useTopUp.js";
35
+ export { TourProvider, tourTarget, useTour, useTourReplay, useTourSwitch, } from "./useTour.js";
36
+ export { buildTour, offerTour, OFFER_WORDS } from "../tour/run.js";
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The guided tour: who sees it, when it is offered, and how a page asks for
3
+ * its own.
4
+ *
5
+ * `TourProvider` holds the site's tours and the preference. A page calls
6
+ * `useTour(page, {actions})` once; when this device is due that page's tour,
7
+ * the page is watched for a quiet stretch and then asked, centred, whether it
8
+ * would like one. Yes starts the tour; No marks the page. A page asked to
9
+ * replay from its guide starts at once. `actions` are what a step may ask
10
+ * the page to do first — open the add form, pick a tag — by the names the
11
+ * copy uses.
12
+ *
13
+ * The site keeps its own switch and its own replay button: `useTourSwitch`
14
+ * and `useTourReplay` give them their mechanics.
15
+ */
16
+ import { type ReactNode } from "react";
17
+ import { type OfferWords } from "../tour/run.ts";
18
+ import type { Tour, TourActions } from "../tour/types.ts";
19
+ export interface TourProviderProps {
20
+ /** The site's tours, by page key — its own and the package's two. */
21
+ tours: Record<string, Tour>;
22
+ /** The quiet stretch before a page offers its tour. Defaults to the site's `tourIdleMs`, else 10 s. */
23
+ idleMs?: number;
24
+ /** The offer's words, when a site wants its own. */
25
+ words?: OfferWords;
26
+ children: ReactNode;
27
+ }
28
+ export declare function TourProvider({ tours, idleMs, words, children }: TourProviderProps): import("react").JSX.Element;
29
+ export interface UseTourOptions {
30
+ actions?: TourActions;
31
+ /** False while the page has yet to draw what the tour points at. */
32
+ ready?: boolean;
33
+ }
34
+ /** Ask for this page's tour, if this device is due it. */
35
+ export declare function useTour(page: string, { actions, ready }?: UseTourOptions): void;
36
+ /** The site's "Live tutorial" switch: on replays every page, off stops them all. */
37
+ export declare function useTourSwitch(): {
38
+ on: boolean;
39
+ setOn: (on: boolean) => void;
40
+ };
41
+ /** The site's "Show me on the page" button: that page's tour once more, at once. */
42
+ export declare function useTourReplay(): (page: string) => void;
43
+ /** The attribute to spread on the element a step points at. */
44
+ export declare function tourTarget(name: string): {
45
+ "data-tour": string;
46
+ };
@@ -0,0 +1,128 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * The guided tour: who sees it, when it is offered, and how a page asks for
4
+ * its own.
5
+ *
6
+ * `TourProvider` holds the site's tours and the preference. A page calls
7
+ * `useTour(page, {actions})` once; when this device is due that page's tour,
8
+ * the page is watched for a quiet stretch and then asked, centred, whether it
9
+ * would like one. Yes starts the tour; No marks the page. A page asked to
10
+ * replay from its guide starts at once. `actions` are what a step may ask
11
+ * the page to do first — open the add form, pick a tag — by the names the
12
+ * copy uses.
13
+ *
14
+ * The site keeps its own switch and its own replay button: `useTourSwitch`
15
+ * and `useTourReplay` give them their mechanics.
16
+ */
17
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
18
+ import { tollboothConfig } from "../config.js";
19
+ import { ACTIVITY_EVENTS, idleThreshold, offerDue } from "../tour/idle.js";
20
+ import { buildTour, offerTour } from "../tour/run.js";
21
+ import { markToured, replayAll, replayPage, shouldRun, stopAll } from "../tour/state.js";
22
+ import { onTourChange, readTourPrefs, settleTourPrefs, tourStorageKey, writeTourPrefs } from "../tourPrefs.js";
23
+ const Ctx = createContext(null);
24
+ function subscribe(changed) {
25
+ const stopPage = onTourChange(changed);
26
+ const onStorage = (e) => {
27
+ try {
28
+ if (e.key === tourStorageKey())
29
+ changed();
30
+ }
31
+ catch {
32
+ /* not configured yet: nothing of ours changed */
33
+ }
34
+ };
35
+ globalThis.addEventListener?.("storage", onStorage);
36
+ return () => {
37
+ stopPage();
38
+ globalThis.removeEventListener?.("storage", onStorage);
39
+ };
40
+ }
41
+ const onServer = () => ({ on: false, toured: {}, replay: null });
42
+ export function TourProvider({ tours, idleMs, words, children }) {
43
+ const prefs = useSyncExternalStore(subscribe, readTourPrefs, onServer);
44
+ const update = useCallback((change) => {
45
+ writeTourPrefs(change(readTourPrefs()));
46
+ }, []);
47
+ const threshold = idleThreshold(tollboothConfig().tourIdleMs, idleMs);
48
+ // A newcomer's first reading is written down, so signing in a minute later
49
+ // does not turn them into an old hand.
50
+ useEffect(() => { settleTourPrefs(); }, []);
51
+ const value = useMemo(() => ({ prefs, update, tours, idleMs: threshold, words }), [prefs, update, tours, threshold, words]);
52
+ return _jsx(Ctx.Provider, { value: value, children: children });
53
+ }
54
+ /** Ask for this page's tour, if this device is due it. */
55
+ export function useTour(page, { actions, ready = true } = {}) {
56
+ const ctx = useContext(Ctx);
57
+ const acts = useRef(actions);
58
+ acts.current = actions;
59
+ const due = ctx ? shouldRun(ctx.prefs, page) : false;
60
+ const atOnce = ctx?.prefs.replay === page;
61
+ const update = ctx?.update;
62
+ const tour = ctx?.tours[page];
63
+ const idleMs = ctx?.idleMs ?? 0;
64
+ const words = ctx?.words;
65
+ useEffect(() => {
66
+ if (!due || !ready || !update || !tour)
67
+ return;
68
+ let gone = false;
69
+ let run = null;
70
+ let offer = null;
71
+ const start = () => {
72
+ if (gone)
73
+ return;
74
+ run = buildTour(tour, {
75
+ run: (name) => acts.current?.[name]?.(),
76
+ onEnd: () => update((p) => markToured(p, page)),
77
+ onStop: () => update(stopAll),
78
+ });
79
+ run.start();
80
+ };
81
+ // From a guide, at once; otherwise after a quiet while, and only asked.
82
+ if (atOnce) {
83
+ const timer = window.setTimeout(start, 600);
84
+ return () => { gone = true; window.clearTimeout(timer); run?.stop(true); };
85
+ }
86
+ let last = Date.now();
87
+ const touched = () => { last = Date.now(); };
88
+ for (const ev of ACTIVITY_EVENTS)
89
+ window.addEventListener(ev, touched, { passive: true, capture: true });
90
+ const tick = window.setInterval(() => {
91
+ if (!offerDue(last, Date.now(), document.visibilityState === "visible", idleMs))
92
+ return;
93
+ window.clearInterval(tick);
94
+ for (const ev of ACTIVITY_EVENTS)
95
+ window.removeEventListener(ev, touched, { capture: true });
96
+ offer = offerTour({
97
+ onYes: start,
98
+ onNo: () => update((p) => markToured(p, page)),
99
+ }, words);
100
+ offer.show();
101
+ }, 1000);
102
+ return () => {
103
+ gone = true;
104
+ window.clearInterval(tick);
105
+ for (const ev of ACTIVITY_EVENTS)
106
+ window.removeEventListener(ev, touched, { capture: true });
107
+ offer?.dismiss();
108
+ run?.stop(true);
109
+ };
110
+ }, [page, due, atOnce, ready, update, tour, idleMs, words]);
111
+ }
112
+ /** The site's "Live tutorial" switch: on replays every page, off stops them all. */
113
+ export function useTourSwitch() {
114
+ const ctx = useContext(Ctx);
115
+ const update = ctx?.update;
116
+ const setOn = useCallback((on) => { update?.(on ? replayAll : stopAll); }, [update]);
117
+ return { on: ctx?.prefs.on ?? false, setOn };
118
+ }
119
+ /** The site's "Show me on the page" button: that page's tour once more, at once. */
120
+ export function useTourReplay() {
121
+ const ctx = useContext(Ctx);
122
+ const update = ctx?.update;
123
+ return useCallback((page) => { update?.((p) => replayPage(p, page)); }, [update]);
124
+ }
125
+ /** The attribute to spread on the element a step points at. */
126
+ export function tourTarget(name) {
127
+ return { "data-tour": name };
128
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * When to offer a tour: after a stretch with nothing touched.
3
+ *
4
+ * A tour that starts the moment a page opens gets in the way of someone who
5
+ * knows the page. One offered after a pause finds the person who is looking
6
+ * for where to start. The policy is data here, so it is tested; the hook
7
+ * that listens for the events is in `react/useTour.ts`.
8
+ */
9
+ /** How long a page may sit untouched before a tour is offered. */
10
+ export declare const DEFAULT_IDLE_MS = 10000;
11
+ /** Anything a person does to a page. */
12
+ export declare const ACTIVITY_EVENTS: readonly ["pointerdown", "pointermove", "keydown", "wheel", "touchstart", "scroll"];
13
+ /** The idle stretch a site asked for, or the default; never under a second. */
14
+ export declare function idleThreshold(configured: number | undefined, override?: number): number;
15
+ /**
16
+ * Whether a page has sat untouched long enough. A hidden tab is never idle:
17
+ * someone reading another tab has not gone quiet on this one.
18
+ */
19
+ export declare function offerDue(lastActivityAt: number, now: number, visible: boolean, idleMs: number): boolean;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * When to offer a tour: after a stretch with nothing touched.
3
+ *
4
+ * A tour that starts the moment a page opens gets in the way of someone who
5
+ * knows the page. One offered after a pause finds the person who is looking
6
+ * for where to start. The policy is data here, so it is tested; the hook
7
+ * that listens for the events is in `react/useTour.ts`.
8
+ */
9
+ /** How long a page may sit untouched before a tour is offered. */
10
+ export const DEFAULT_IDLE_MS = 10_000;
11
+ /** Anything a person does to a page. */
12
+ export const ACTIVITY_EVENTS = ["pointerdown", "pointermove", "keydown", "wheel", "touchstart", "scroll"];
13
+ /** The idle stretch a site asked for, or the default; never under a second. */
14
+ export function idleThreshold(configured, override) {
15
+ const ms = override ?? configured ?? DEFAULT_IDLE_MS;
16
+ return Number.isFinite(ms) && ms >= 1_000 ? ms : DEFAULT_IDLE_MS;
17
+ }
18
+ /**
19
+ * Whether a page has sat untouched long enough. A hidden tab is never idle:
20
+ * someone reading another tab has not gone quiet on this one.
21
+ */
22
+ export function offerDue(lastActivityAt, now, visible, idleMs) {
23
+ return visible && now - lastActivityAt >= idleMs;
24
+ }
@@ -0,0 +1,18 @@
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
+ import type { Tour, TourStep } from "./types.ts";
10
+ export declare const MAX_SAYS = 180;
11
+ export declare const MAX_TITLE = 28;
12
+ export declare const MAX_SENTENCES = 2;
13
+ /** The problems with one step's words, each a sentence; none when it is well said. */
14
+ export declare function lintStep(s: TourStep, page?: string): string[];
15
+ /** Every problem in a tour's words. */
16
+ export declare function lintTour(t: Tour): string[];
17
+ /** Every problem across a site's tours. */
18
+ export declare function lintTours(tours: Record<string, Tour>): string[];