@tollbooth-dpyc/web 0.3.0 → 0.5.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
@@ -10,7 +10,7 @@ features. It is the peer of the `tollbooth-dpyc` Python wheel.
10
10
  - **MCP client** — one shared connection to the operator, typed wrappers for
11
11
  the standard tools (balance, top-up, statement, price, profile)
12
12
  - **React components** — `NpubGate`, `NostrProfilePanel`, `SessionKeyClaim`, `WalletCard`,
13
- `AvatarPicker`, `DebugPanel`, `useSession`, `useDebugLog`
13
+ `AvatarPicker`, `DebugPanel`, `QuoteScroller`, `useSession`, `useDebugLog`
14
14
  - **Debug log** — every call, result and error from `callTool`, scrubbed of
15
15
  nsecs, hex keys, tokens and proofs before it is stored
16
16
  - **Pages proxy** — the `/mcp` Cloudflare Pages Function
@@ -61,6 +61,46 @@ configureDebugLog({ persist: true }); // optional: outlive a reload; max defau
61
61
  A site's own `debugPush` lines are scrubbed the same way. Stamps follow the
62
62
  zone the site stores under `<prefix>:timezone`, if any.
63
63
 
64
+ ### Quote scroller
65
+
66
+ Something to read while a page loads. The quotes are the site's own; the
67
+ package only scrolls them. Name a remote corpus to edit the set without a
68
+ redeploy — it is fetched once per page and replaces the inline set when it
69
+ arrives, or never, if it cannot be had.
70
+
71
+ The package owns the mechanics (timing, cross-fade, reduced motion, pausing
72
+ while the tab is hidden, `aria-live`, and a reserved height taken from the
73
+ tallest quote as *your* styles lay it out). How the quotes look is yours:
74
+ without `classNames` they are plain text inheriting from the page.
75
+
76
+ ```tsx
77
+ import { QuoteScroller } from "@tollbooth-dpyc/web/react";
78
+ import type { Quote } from "@tollbooth-dpyc/web";
79
+
80
+ const QUOTES: Quote[] = [{ text: "Observe the seasons.", author: "Hesiod" }];
81
+
82
+ <QuoteScroller
83
+ quotes={QUOTES}
84
+ source="https://raw.githubusercontent.com/lonniev/dpyc-community/main/quotes-agrarian.json"
85
+ heading="Reading the ledger…"
86
+ spinner
87
+ classNames={{
88
+ root: "px-4 py-6 text-center",
89
+ heading: "mb-5 font-mono text-[11px] uppercase tracking-[0.3em] text-[var(--tb-accent)]",
90
+ figure: "mx-auto flex max-w-xl flex-col gap-3",
91
+ text: "font-serif text-[17px] italic leading-relaxed",
92
+ mark: "not-italic text-[var(--tb-accent)]",
93
+ author: "font-mono text-[10.5px] uppercase tracking-[0.22em] text-[var(--tb-muted)]",
94
+ }}
95
+ />
96
+ ```
97
+
98
+ Props: `quotes`, `source`, `heading`, `spinner`, `intervalMs` (default 3500),
99
+ `classNames` (`root`, `heading`, `spinner`, `figure`, `text`, `mark`,
100
+ `author`), `marks` (curly quotes by default; `false` for none, or
101
+ `[open, close]`), and `renderQuote(quote)` to draw a quote's markup yourself
102
+ (then `text`, `mark` and `author` are unused).
103
+
64
104
  ### Styling
65
105
 
66
106
  Components use Tailwind classes coloured only through CSS variables. Tell
package/dist/index.d.ts CHANGED
@@ -11,4 +11,5 @@ export { canSignFor, isProven, type Claim } from "./signedIn.ts";
11
11
  export { readSignInFailure, type Situation } from "./signInSituation.ts";
12
12
  export { canSignProfile, fetchProfile, publishProfile } from "./nostrProfile.ts";
13
13
  export * from "./avatar.ts";
14
+ export { loadQuotes, peekQuotes, shuffle, validQuotes, type Quote } from "./quotes.ts";
14
15
  export { captureGlobalErrors, clearDebug, configureDebugLog, debugEntries, debugLogText, debugPush, debugSeverity, onDebug, redact, type DebugEntry, type DebugLogOptions, type DebugSeverity, } from "./debugLog.ts";
package/dist/index.js CHANGED
@@ -10,4 +10,5 @@ export { canSignFor, isProven } from "./signedIn.js";
10
10
  export { readSignInFailure } from "./signInSituation.js";
11
11
  export { canSignProfile, fetchProfile, publishProfile } from "./nostrProfile.js";
12
12
  export * from "./avatar.js";
13
+ export { loadQuotes, peekQuotes, shuffle, validQuotes } from "./quotes.js";
13
14
  export { captureGlobalErrors, clearDebug, configureDebugLog, debugEntries, debugLogText, debugPush, debugSeverity, onDebug, redact, } from "./debugLog.js";
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Quotations for a wait: something to read while a page loads, instead of a
3
+ * frozen "Loading…".
4
+ *
5
+ * Each site keeps its own set — agrarian for a farm, copywriters for a posting
6
+ * tool — and may name a remote corpus (a `{ quotes: [{ text, author }] }` JSON
7
+ * file) that supersedes it once it loads, so the set can be edited without a
8
+ * redeploy. The remote file is fetched at most once per page per URL; two
9
+ * sites or two corpora never share a cache entry. Anything that goes wrong —
10
+ * offline, a 404, a malformed file, an empty list — leaves the site's own set
11
+ * on screen.
12
+ */
13
+ export interface Quote {
14
+ text: string;
15
+ author: string;
16
+ }
17
+ /** Keep only well-formed rows: both fields present as non-blank strings. */
18
+ export declare function validQuotes(rows: unknown): Quote[];
19
+ /** A shuffled copy (Fisher–Yates); the input is left alone. */
20
+ export declare function shuffle<T>(items: ReadonlyArray<T>, random?: () => number): T[];
21
+ /** The remote list already loaded for `source` this page, if there is one. */
22
+ export declare function peekQuotes(source: string): Quote[] | undefined;
23
+ /**
24
+ * The quotes at `source`, or `fallback` when they cannot be had. Fetched once
25
+ * per page per URL; a failure is remembered too, so a broken corpus costs one
26
+ * request, not one per spinner.
27
+ */
28
+ export declare function loadQuotes(source: string, fallback: ReadonlyArray<Quote>, fetchImpl?: typeof fetch): Promise<Quote[]>;
package/dist/quotes.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Quotations for a wait: something to read while a page loads, instead of a
3
+ * frozen "Loading…".
4
+ *
5
+ * Each site keeps its own set — agrarian for a farm, copywriters for a posting
6
+ * tool — and may name a remote corpus (a `{ quotes: [{ text, author }] }` JSON
7
+ * file) that supersedes it once it loads, so the set can be edited without a
8
+ * redeploy. The remote file is fetched at most once per page per URL; two
9
+ * sites or two corpora never share a cache entry. Anything that goes wrong —
10
+ * offline, a 404, a malformed file, an empty list — leaves the site's own set
11
+ * on screen.
12
+ */
13
+ /** Keep only well-formed rows: both fields present as non-blank strings. */
14
+ export function validQuotes(rows) {
15
+ if (!Array.isArray(rows))
16
+ return [];
17
+ return rows.flatMap((row) => {
18
+ if (typeof row !== "object" || row === null)
19
+ return [];
20
+ const { text, author } = row;
21
+ if (typeof text !== "string" || typeof author !== "string")
22
+ return [];
23
+ if (!text.trim() || !author.trim())
24
+ return [];
25
+ return [{ text, author }];
26
+ });
27
+ }
28
+ /** A shuffled copy (Fisher–Yates); the input is left alone. */
29
+ export function shuffle(items, random = Math.random) {
30
+ const copy = items.slice();
31
+ for (let i = copy.length - 1; i > 0; i--) {
32
+ const j = Math.floor(random() * (i + 1));
33
+ [copy[i], copy[j]] = [copy[j], copy[i]];
34
+ }
35
+ return copy;
36
+ }
37
+ // Per source URL: the validated remote list ([] when the fetch failed), and
38
+ // the one request in flight so concurrent callers share it.
39
+ const loaded = new Map();
40
+ const inflight = new Map();
41
+ /** The remote list already loaded for `source` this page, if there is one. */
42
+ export function peekQuotes(source) {
43
+ const list = loaded.get(source);
44
+ return list && list.length ? list : undefined;
45
+ }
46
+ /**
47
+ * The quotes at `source`, or `fallback` when they cannot be had. Fetched once
48
+ * per page per URL; a failure is remembered too, so a broken corpus costs one
49
+ * request, not one per spinner.
50
+ */
51
+ export async function loadQuotes(source, fallback, fetchImpl = globalThis.fetch) {
52
+ const pick = (list) => (list.length ? list : fallback.slice());
53
+ const done = loaded.get(source);
54
+ if (done)
55
+ return pick(done);
56
+ let pending = inflight.get(source);
57
+ if (!pending) {
58
+ pending = (async () => {
59
+ let list = [];
60
+ try {
61
+ const res = await fetchImpl(source, { cache: "no-cache" });
62
+ if (res.ok) {
63
+ const body = (await res.json());
64
+ list = validQuotes(body?.quotes);
65
+ }
66
+ }
67
+ catch {
68
+ list = [];
69
+ }
70
+ loaded.set(source, list);
71
+ inflight.delete(source);
72
+ return list;
73
+ })();
74
+ inflight.set(source, pending);
75
+ }
76
+ return pick(await pending);
77
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Something worth reading while a page loads — rotating quotations instead of
3
+ * a frozen "Loading…". First written for optionality-mcp as a port of the
4
+ * Pricing Studio app's LoadingQuoteView, then copied across the fleet; this is
5
+ * the one copy.
6
+ *
7
+ * The site brings its own quotes (`quotes`), so the scroller always has
8
+ * something to show without the network. `source` may name a remote corpus
9
+ * that supersedes them once it loads (see `loadQuotes`).
10
+ *
11
+ * The order is shuffled and then walked, so nothing repeats until every quote
12
+ * has shown. It cross-fades (instantly under reduced motion), stops while the
13
+ * tab is hidden, and reserves the height of its longest quote so the page
14
+ * does not jump as quotes change.
15
+ *
16
+ * Mechanics only. The package owns the timing, the fade, the cache and the
17
+ * reserved height; every visual choice — type, size, colour, spacing,
18
+ * alignment — belongs to the site, through `classNames` or `renderQuote`.
19
+ * With neither, the quotes are plain text that inherits from the page. The
20
+ * reserved height is the tallest quote as the SITE styles it: every quote is
21
+ * laid out, invisibly, in the same grid cell as the one on show.
22
+ */
23
+ import { type ReactNode } from "react";
24
+ import { type Quote } from "../quotes.ts";
25
+ export interface QuoteScrollerProps {
26
+ /** The site's own quotes: shown at once, and kept if `source` fails. Pass a
27
+ * module constant. */
28
+ quotes: ReadonlyArray<Quote>;
29
+ /** URL of a `{ quotes: [{ text, author }] }` JSON file that supersedes
30
+ * `quotes` once it loads. */
31
+ source?: string;
32
+ /** A status line above the quotes ("Reading the ledger…"). */
33
+ heading?: string;
34
+ /** Show a spinner beside the heading. Default false. */
35
+ spinner?: boolean;
36
+ /** How long each quote stays, in ms. Default 3500. */
37
+ intervalMs?: number;
38
+ /** Classes for each part. The package adds no typography or colour of its
39
+ * own to any of them. `text`, `mark` and `author` are unused when
40
+ * `renderQuote` is given. */
41
+ classNames?: QuoteScrollerClassNames;
42
+ /** Full control of one quote's markup. The package still wraps it for the
43
+ * fade and the reserved height. */
44
+ renderQuote?: (quote: Quote) => ReactNode;
45
+ /** Quotation marks around the text: the curly pair by default, `false` for
46
+ * none, or your own `[open, close]`. */
47
+ marks?: boolean | readonly [open: string, close: string];
48
+ }
49
+ export interface QuoteScrollerClassNames {
50
+ /** The outer box. */
51
+ root?: string;
52
+ /** The status line. */
53
+ heading?: string;
54
+ /** The spinner icon. Given, it replaces the default 1em size. */
55
+ spinner?: string;
56
+ /** Each quote's `<figure>`. */
57
+ figure?: string;
58
+ /** The `<blockquote>` holding the text and its marks. */
59
+ text?: string;
60
+ /** Each quotation mark. */
61
+ mark?: string;
62
+ /** The `<figcaption>` naming the author. */
63
+ author?: string;
64
+ }
65
+ export default function QuoteScroller({ quotes, source, heading, spinner, intervalMs, classNames, renderQuote, marks, }: QuoteScrollerProps): import("react").JSX.Element;
@@ -0,0 +1,118 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Something worth reading while a page loads — rotating quotations instead of
4
+ * a frozen "Loading…". First written for optionality-mcp as a port of the
5
+ * Pricing Studio app's LoadingQuoteView, then copied across the fleet; this is
6
+ * the one copy.
7
+ *
8
+ * The site brings its own quotes (`quotes`), so the scroller always has
9
+ * something to show without the network. `source` may name a remote corpus
10
+ * that supersedes them once it loads (see `loadQuotes`).
11
+ *
12
+ * The order is shuffled and then walked, so nothing repeats until every quote
13
+ * has shown. It cross-fades (instantly under reduced motion), stops while the
14
+ * tab is hidden, and reserves the height of its longest quote so the page
15
+ * does not jump as quotes change.
16
+ *
17
+ * Mechanics only. The package owns the timing, the fade, the cache and the
18
+ * reserved height; every visual choice — type, size, colour, spacing,
19
+ * alignment — belongs to the site, through `classNames` or `renderQuote`.
20
+ * With neither, the quotes are plain text that inherits from the page. The
21
+ * reserved height is the tallest quote as the SITE styles it: every quote is
22
+ * laid out, invisibly, in the same grid cell as the one on show.
23
+ */
24
+ import { useEffect, useRef, useState } from "react";
25
+ import { Loader2 } from "lucide-react";
26
+ import { loadQuotes, peekQuotes, shuffle } from "../quotes.js";
27
+ const CURLY = ["\u201c", "\u201d"];
28
+ const FADE_MS = 450;
29
+ function useReducedMotion() {
30
+ const query = "(prefers-reduced-motion: reduce)";
31
+ const [reduced, setReduced] = useState(() => typeof window !== "undefined" && !!window.matchMedia?.(query).matches);
32
+ useEffect(() => {
33
+ const mq = window.matchMedia?.(query);
34
+ if (!mq)
35
+ return;
36
+ const on = () => setReduced(mq.matches);
37
+ mq.addEventListener("change", on);
38
+ return () => mq.removeEventListener("change", on);
39
+ }, []);
40
+ return reduced;
41
+ }
42
+ function usePageVisible() {
43
+ const [shown, setShown] = useState(() => typeof document === "undefined" || document.visibilityState !== "hidden");
44
+ useEffect(() => {
45
+ const on = () => setShown(document.visibilityState !== "hidden");
46
+ document.addEventListener("visibilitychange", on);
47
+ return () => document.removeEventListener("visibilitychange", on);
48
+ }, []);
49
+ return shown;
50
+ }
51
+ export default function QuoteScroller({ quotes, source, heading, spinner = false, intervalMs = 3500, classNames = {}, renderQuote, marks = true, }) {
52
+ const reduced = useReducedMotion();
53
+ const pageVisible = usePageVisible();
54
+ // Content, not identity: a site that passes an inline array still gets one
55
+ // shuffle, not one per render.
56
+ const signature = quotes.map((q) => `${q.text}\u0000${q.author}`).join("\u0001");
57
+ const [list, setList] = useState(() => shuffle((source && peekQuotes(source)) || quotes));
58
+ const [index, setIndex] = useState(0);
59
+ const [shown, setShown] = useState(false);
60
+ const key = `${source ?? ""}\u0002${signature}`;
61
+ const first = useRef(key);
62
+ useEffect(() => {
63
+ if (first.current !== key) {
64
+ first.current = key;
65
+ setList(shuffle((source && peekQuotes(source)) || quotes));
66
+ setIndex(0);
67
+ }
68
+ if (!source || peekQuotes(source))
69
+ return; // nothing to fetch, or already showing it
70
+ let alive = true;
71
+ void loadQuotes(source, quotes).then((loaded) => {
72
+ if (!alive || !peekQuotes(source))
73
+ return; // the site's own set is already up
74
+ setList(shuffle(loaded));
75
+ setIndex(0);
76
+ });
77
+ return () => {
78
+ alive = false;
79
+ };
80
+ // `key` stands for `quotes` and `source`, by content.
81
+ }, [key]);
82
+ // Fade the first quote in.
83
+ useEffect(() => {
84
+ const id = window.setTimeout(() => setShown(true), 30);
85
+ return () => window.clearTimeout(id);
86
+ }, []);
87
+ useEffect(() => {
88
+ if (list.length <= 1 || !pageVisible)
89
+ return;
90
+ const fade = reduced ? 0 : FADE_MS;
91
+ let swap;
92
+ const tick = window.setInterval(() => {
93
+ setShown(false);
94
+ swap = window.setTimeout(() => {
95
+ setIndex((i) => (i + 1) % list.length);
96
+ setShown(true);
97
+ }, fade);
98
+ }, intervalMs);
99
+ return () => {
100
+ window.clearInterval(tick);
101
+ window.clearTimeout(swap);
102
+ setShown(true);
103
+ };
104
+ }, [list, intervalMs, reduced, pageVisible]);
105
+ const q = list[index % Math.max(list.length, 1)];
106
+ const pair = marks === true ? CURLY : marks === false ? null : marks;
107
+ const draw = renderQuote ??
108
+ ((quote) => (_jsxs("figure", { className: classNames.figure, children: [_jsxs("blockquote", { className: classNames.text, children: [pair && _jsx("span", { className: classNames.mark, children: pair[0] }), quote.text, pair && _jsx("span", { className: classNames.mark, children: pair[1] })] }), _jsx("figcaption", { className: classNames.author, children: quote.author })] })));
109
+ return (_jsxs("div", { className: classNames.root, children: [heading && (_jsxs("div", { className: classNames.heading, children: [spinner && (_jsx(Loader2, { "aria-hidden": true, className: `${classNames.spinner ?? "inline-block h-[1em] w-[1em]"} motion-safe:animate-spin` })), spinner && " ", heading] })), q && (_jsxs("div", { style: STAGE, children: [list.map((each, i) => (_jsx("div", { style: HOLDER, "aria-hidden": true, children: draw(each) }, i))), _jsx("div", { style: {
110
+ ...CELL,
111
+ opacity: shown ? 1 : 0,
112
+ transition: reduced ? "none" : `opacity ${FADE_MS}ms ease`,
113
+ }, "aria-live": "polite", "aria-atomic": true, children: draw(q) })] }))] }));
114
+ }
115
+ // Structure for the mechanism only, inline so it needs no stylesheet.
116
+ const STAGE = { display: "grid" };
117
+ const CELL = { gridArea: "1 / 1", minWidth: 0 };
118
+ const HOLDER = { ...CELL, visibility: "hidden" };
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Renders ONLY when this browser holds the session nsec for the signed-in
5
5
  * npub. NIP-07 and courier-proof sign-ins see nothing: no disabled control,
6
- * no placeholder, no mention. Reveal takes a deliberate second step so an
7
- * account page opened in front of other people does not paint the key.
6
+ * no placeholder, no mention. Painting the key takes two deliberate taps —
7
+ * "Claim your session key", then the Reveal chip — so an account page opened
8
+ * in front of other people does not show it.
8
9
  *
9
10
  * Destinations: copy, download .env, password-manager prompt, and an
10
11
  * encrypted DM to another npub the patron names. The nsec never reaches a
@@ -4,8 +4,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  *
5
5
  * Renders ONLY when this browser holds the session nsec for the signed-in
6
6
  * npub. NIP-07 and courier-proof sign-ins see nothing: no disabled control,
7
- * no placeholder, no mention. Reveal takes a deliberate second step so an
8
- * account page opened in front of other people does not paint the key.
7
+ * no placeholder, no mention. Painting the key takes two deliberate taps —
8
+ * "Claim your session key", then the Reveal chip — so an account page opened
9
+ * in front of other people does not show it.
9
10
  *
10
11
  * Destinations: copy, download .env, password-manager prompt, and an
11
12
  * encrypted DM to another npub the patron names. The nsec never reaches a
@@ -161,10 +162,10 @@ function SessionKeyClaimInner() {
161
162
  setDmOpen(false);
162
163
  setFlash(null);
163
164
  setRecipient("");
164
- }, className: `text-[12px] ${muted} hover:text-[var(--tb-ink)]`, children: "Hide" })] }), _jsxs("p", { className: `mb-3 text-[12.5px] leading-relaxed ${muted}`, children: ["Anyone who holds this key is you. It leaves this page only when you choose a destination below \u2014 never logged, never sent to ", appName, "."] }), showKey ? (_jsx("div", { className: "mb-3 break-all rounded-lg border border-[var(--tb-line)] bg-[var(--tb-surface-2)] px-3 py-2 font-mono text-[12px] text-[var(--tb-ink)]", children: getSessionNsec() })) : (_jsx("button", { type: "button", onClick: () => setShowKey(true), className: `mb-3 text-[12.5px] ${muted} underline-offset-2 hover:text-[var(--tb-ink)] hover:underline`, children: "Show the key on screen" })), _jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx(DestButton, { onClick: () => void copyKey(), disabled: busy, children: copied ? "Copied" : "Copy" }), _jsx(DestButton, { onClick: () => void downloadEnv(), disabled: busy, children: "Download .env" }), _jsx(DestButton, { onClick: () => void passwordManagerSave(), disabled: busy, children: "Password manager" }), _jsx(DestButton, { onClick: () => {
165
+ }, className: `text-[12px] ${muted} hover:text-[var(--tb-ink)]`, children: "Hide" })] }), _jsxs("p", { className: `mb-3 text-[12.5px] leading-relaxed ${muted}`, children: ["Anyone who holds this key is you. It leaves this page only when you choose a destination below \u2014 never logged, never sent to ", appName, "."] }), _jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx(DestButton, { onClick: () => setShowKey((v) => !v), active: showKey, children: showKey ? "Conceal" : "Reveal" }), _jsx(DestButton, { onClick: () => void copyKey(), disabled: busy, children: copied ? "Copied" : "Copy" }), _jsx(DestButton, { onClick: () => void downloadEnv(), disabled: busy, children: "Download .env" }), _jsx(DestButton, { onClick: () => void passwordManagerSave(), disabled: busy, children: "Password manager" }), _jsx(DestButton, { onClick: () => {
165
166
  setDmOpen((v) => !v);
166
167
  setFlash(null);
167
- }, disabled: busy, active: dmOpen, children: "Send by DM" })] }), dmOpen && (_jsxs("div", { className: "mt-3 space-y-2 rounded-lg border border-[var(--tb-line)] bg-[var(--tb-surface-2)] p-3", children: [_jsx("p", { className: `text-[12px] leading-relaxed ${muted}`, children: "Encrypts the key to an npub you own and publishes the sealed message to public relays. Only that npub can open it." }), _jsxs("div", { className: "flex flex-wrap items-end gap-2", children: [_jsxs("label", { className: `block min-w-[16rem] flex-1 text-[12px] ${muted}`, children: ["Recipient npub", _jsx("input", { value: recipient, onChange: (e) => setRecipient(e.target.value), spellCheck: false, autoCapitalize: "off", autoCorrect: "off", placeholder: "npub1\u2026", className: `mt-1 h-11 ${input}` })] }), _jsx("button", { type: "button", onClick: () => void sendDm(), disabled: busy || recipient.trim().length < 60, 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)] disabled:opacity-40", children: busy ? "Sending…" : "Send encrypted DM" })] })] })), _jsxs("form", { ref: formRef, className: "absolute h-px w-px overflow-hidden opacity-0", "aria-hidden": "true", autoComplete: "on", onSubmit: (e) => {
168
+ }, disabled: busy, active: dmOpen, children: "Send by DM" })] }), showKey && (_jsx("div", { className: "mt-3 break-all rounded-lg border border-[var(--tb-line)] bg-[var(--tb-surface-2)] px-3 py-2 font-mono text-[12px] text-[var(--tb-ink)]", children: getSessionNsec() })), dmOpen && (_jsxs("div", { className: "mt-3 space-y-2 rounded-lg border border-[var(--tb-line)] bg-[var(--tb-surface-2)] p-3", children: [_jsx("p", { className: `text-[12px] leading-relaxed ${muted}`, children: "Encrypts the key to an npub you own and publishes the sealed message to public relays. Only that npub can open it." }), _jsxs("div", { className: "flex flex-wrap items-end gap-2", children: [_jsxs("label", { className: `block min-w-[16rem] flex-1 text-[12px] ${muted}`, children: ["Recipient npub", _jsx("input", { value: recipient, onChange: (e) => setRecipient(e.target.value), spellCheck: false, autoCapitalize: "off", autoCorrect: "off", placeholder: "npub1\u2026", className: `mt-1 h-11 ${input}` })] }), _jsx("button", { type: "button", onClick: () => void sendDm(), disabled: busy || recipient.trim().length < 60, 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)] disabled:opacity-40", children: busy ? "Sending…" : "Send encrypted DM" })] })] })), _jsxs("form", { ref: formRef, className: "absolute h-px w-px overflow-hidden opacity-0", "aria-hidden": "true", autoComplete: "on", onSubmit: (e) => {
168
169
  e.preventDefault();
169
170
  }, children: [_jsx("input", { ref: userRef, name: "username", autoComplete: "username", defaultValue: "", readOnly: true, tabIndex: -1 }), _jsx("input", { ref: passRef, name: "password", type: "password", autoComplete: "new-password", defaultValue: "", readOnly: true, tabIndex: -1 })] }), flash && (_jsx("p", { className: `mt-3 text-[12.5px] leading-relaxed ${flash.tone === "ok" ? "text-[var(--tb-ink)]" : "text-[var(--tb-err-ink)]"}`, role: "status", children: flash.text }))] }));
170
171
  }
@@ -3,6 +3,7 @@ export { default as AvatarPicker } from "./AvatarPicker.tsx";
3
3
  export { default as DebugPanel, type DebugPanelProps } from "./DebugPanel.tsx";
4
4
  export { default as NpubGate } from "./NpubGate.tsx";
5
5
  export { default as NostrProfilePanel } from "./NostrProfilePanel.tsx";
6
+ export { default as QuoteScroller, type QuoteScrollerProps, type QuoteScrollerClassNames } from "./QuoteScroller.tsx";
6
7
  export { default as SessionKeyClaim } from "./SessionKeyClaim.tsx";
7
8
  export { default as WalletCard } from "./WalletCard.tsx";
8
9
  export { useSession, type Session } from "./useSession.ts";
@@ -3,6 +3,7 @@ export { default as AvatarPicker } from "./AvatarPicker.js";
3
3
  export { default as DebugPanel } from "./DebugPanel.js";
4
4
  export { default as NpubGate } from "./NpubGate.js";
5
5
  export { default as NostrProfilePanel } from "./NostrProfilePanel.js";
6
+ export { default as QuoteScroller } from "./QuoteScroller.js";
6
7
  export { default as SessionKeyClaim } from "./SessionKeyClaim.js";
7
8
  export { default as WalletCard } from "./WalletCard.js";
8
9
  export { useSession } from "./useSession.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tollbooth-dpyc/web",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
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",