@tollbooth-dpyc/web 0.2.0 → 0.4.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 +44 -1
- package/dist/debugLog.d.ts +36 -4
- package/dist/debugLog.js +179 -13
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/quotes.d.ts +28 -0
- package/dist/quotes.js +77 -0
- package/dist/react/DebugPanel.d.ts +28 -0
- package/dist/react/DebugPanel.js +81 -0
- package/dist/react/QuoteScroller.d.ts +33 -0
- package/dist/react/QuoteScroller.js +106 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.js +3 -0
- package/dist/react/useDebugLog.d.ts +6 -0
- package/dist/react/useDebugLog.js +9 -0
- package/package.json +1 -1
- package/theme.css +6 -0
package/README.md
CHANGED
|
@@ -10,7 +10,9 @@ 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`, `useSession`
|
|
13
|
+
`AvatarPicker`, `DebugPanel`, `QuoteScroller`, `useSession`, `useDebugLog`
|
|
14
|
+
- **Debug log** — every call, result and error from `callTool`, scrubbed of
|
|
15
|
+
nsecs, hex keys, tokens and proofs before it is stored
|
|
14
16
|
- **Pages proxy** — the `/mcp` Cloudflare Pages Function
|
|
15
17
|
|
|
16
18
|
## Use
|
|
@@ -39,6 +41,47 @@ import { makeMcpProxy } from "@tollbooth-dpyc/web/pages-proxy";
|
|
|
39
41
|
export const onRequest = makeMcpProxy("https://chartremotely-mcp.fastmcp.app/mcp");
|
|
40
42
|
```
|
|
41
43
|
|
|
44
|
+
### Debug panel
|
|
45
|
+
|
|
46
|
+
`callTool` logs to a shared, scrubbed ring buffer; `DebugPanel` shows it as a
|
|
47
|
+
bar along the bottom of the page with Copy and Clear. Put it once near the root.
|
|
48
|
+
Anything the site alone needs goes in as children, drawn above the log:
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
import { configureDebugLog, debugPush } from "@tollbooth-dpyc/web";
|
|
52
|
+
import { DebugPanel } from "@tollbooth-dpyc/web/react";
|
|
53
|
+
|
|
54
|
+
configureDebugLog({ persist: true }); // optional: outlive a reload; max defaults to 200
|
|
55
|
+
|
|
56
|
+
<DebugPanel>
|
|
57
|
+
<button onClick={loadSchedulerRuns}>Scheduler ↻</button>
|
|
58
|
+
</DebugPanel>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
A site's own `debugPush` lines are scrubbed the same way. Stamps follow the
|
|
62
|
+
zone the site stores under `<prefix>:timezone`, if any.
|
|
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
|
+
```tsx
|
|
72
|
+
import { QuoteScroller } from "@tollbooth-dpyc/web/react";
|
|
73
|
+
import type { Quote } from "@tollbooth-dpyc/web";
|
|
74
|
+
|
|
75
|
+
const QUOTES: Quote[] = [{ text: "Observe the seasons.", author: "Hesiod" }];
|
|
76
|
+
|
|
77
|
+
<QuoteScroller
|
|
78
|
+
quotes={QUOTES}
|
|
79
|
+
source="https://raw.githubusercontent.com/lonniev/dpyc-community/main/quotes-agrarian.json"
|
|
80
|
+
heading="Reading the ledger…"
|
|
81
|
+
spinner
|
|
82
|
+
/>
|
|
83
|
+
```
|
|
84
|
+
|
|
42
85
|
### Styling
|
|
43
86
|
|
|
44
87
|
Components use Tailwind classes coloured only through CSS variables. Tell
|
package/dist/debugLog.d.ts
CHANGED
|
@@ -1,18 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* An
|
|
2
|
+
* An activity log of MCP calls, for a site's debug panel.
|
|
3
3
|
*
|
|
4
4
|
* A ring buffer with subscribers, so the one `callTool` can push and any panel
|
|
5
|
-
* can listen. It survives route changes
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* can listen. Newest first. It survives route changes; with
|
|
6
|
+
* `configureDebugLog({ persist: true })` it survives a reload too, so an error
|
|
7
|
+
* that flips the view can still be read and copied afterward.
|
|
8
|
+
*
|
|
9
|
+
* Every message is scrubbed on the way in: an nsec, a 64-hex key, a bearer
|
|
10
|
+
* token, and the value of any secret-named field (dpop_token, proof, poison,
|
|
11
|
+
* password, api_key, …) never reach the buffer, the clipboard or storage.
|
|
12
|
+
* That is the one guarantee a panel relies on — it renders what is here.
|
|
13
|
+
*
|
|
14
|
+
* Merged from the fleet's copies (goodearth, beesknees, cypher, excalibur,
|
|
15
|
+
* optionality, roastify, taxsort). None of them scrubbed; this one does.
|
|
8
16
|
*/
|
|
9
17
|
export interface DebugEntry {
|
|
10
18
|
ts: string;
|
|
11
19
|
type: "info" | "call" | "result" | "error";
|
|
12
20
|
message: string;
|
|
13
21
|
}
|
|
22
|
+
export interface DebugLogOptions {
|
|
23
|
+
/** How many entries to keep. Default 200. */
|
|
24
|
+
max?: number;
|
|
25
|
+
/** Keep the log in localStorage so it outlives a reload. Default false. */
|
|
26
|
+
persist?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Scrub secrets from a log line. Deliberately over-eager: a 64-hex event id
|
|
30
|
+
* is hidden along with a hex private key, because the two look alike.
|
|
31
|
+
*/
|
|
32
|
+
export declare function redact(message: string): string;
|
|
33
|
+
/** Set the cap and persistence. Call after `configureTollbooth`. */
|
|
34
|
+
export declare function configureDebugLog(options: DebugLogOptions): void;
|
|
14
35
|
export declare function debugPush(type: DebugEntry["type"], message: string): void;
|
|
15
36
|
export declare function clearDebug(): void;
|
|
37
|
+
/** The entries, newest first. The same array until the log changes. */
|
|
16
38
|
export declare function debugEntries(): readonly DebugEntry[];
|
|
17
39
|
/** Subscribe to changes. Returns an unsubscribe function. */
|
|
18
40
|
export declare function onDebug(fn: () => void): () => void;
|
|
41
|
+
/** The whole log as plain text, newest first — for Copy and bug reports. */
|
|
42
|
+
export declare function debugLogText(): string;
|
|
43
|
+
export type DebugSeverity = "ok" | "notice" | "failure";
|
|
44
|
+
/** How a panel should flag an entry: a fault, a patron's next step, or neither. */
|
|
45
|
+
export declare function debugSeverity(entry: DebugEntry): DebugSeverity;
|
|
46
|
+
/**
|
|
47
|
+
* Fold uncaught errors and promise rejections into the log, once per page
|
|
48
|
+
* (guarded, so StrictMode and remounts do not double-register).
|
|
49
|
+
*/
|
|
50
|
+
export declare function captureGlobalErrors(): void;
|
package/dist/debugLog.js
CHANGED
|
@@ -1,29 +1,195 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* An
|
|
2
|
+
* An activity log of MCP calls, for a site's debug panel.
|
|
3
3
|
*
|
|
4
4
|
* A ring buffer with subscribers, so the one `callTool` can push and any panel
|
|
5
|
-
* can listen. It survives route changes
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* can listen. Newest first. It survives route changes; with
|
|
6
|
+
* `configureDebugLog({ persist: true })` it survives a reload too, so an error
|
|
7
|
+
* that flips the view can still be read and copied afterward.
|
|
8
|
+
*
|
|
9
|
+
* Every message is scrubbed on the way in: an nsec, a 64-hex key, a bearer
|
|
10
|
+
* token, and the value of any secret-named field (dpop_token, proof, poison,
|
|
11
|
+
* password, api_key, …) never reach the buffer, the clipboard or storage.
|
|
12
|
+
* That is the one guarantee a panel relies on — it renders what is here.
|
|
13
|
+
*
|
|
14
|
+
* Merged from the fleet's copies (goodearth, beesknees, cypher, excalibur,
|
|
15
|
+
* optionality, roastify, taxsort). None of them scrubbed; this one does.
|
|
8
16
|
*/
|
|
9
|
-
|
|
17
|
+
import { tollboothConfig } from "./config.js";
|
|
18
|
+
const DEFAULT_MAX = 200;
|
|
19
|
+
let max = DEFAULT_MAX;
|
|
20
|
+
let persist = false;
|
|
21
|
+
// Replaced, never mutated, so a snapshot reader (useSyncExternalStore) sees a
|
|
22
|
+
// new array exactly when something changed.
|
|
23
|
+
let log = [];
|
|
10
24
|
const listeners = new Set();
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
25
|
+
// ── Redaction ──────────────────────────────────────────────────────────────
|
|
26
|
+
const REDACTED = "[redacted]";
|
|
27
|
+
/** Field names whose value is a secret wherever it appears. */
|
|
28
|
+
const SECRET_FIELD = "(?:nsec|ncryptsec|dpop_token|proof|poison|password|passphrase|secret|token|authorization|api_?key|private_?key|client_secret|[a-z0-9_]*_(?:nsec|token|secret|password|passphrase|api_?key|private_?key))";
|
|
29
|
+
// `"dpop_token":"…"`, `\"dpop_token\":\"…\"` (JSON inside a JSON string),
|
|
30
|
+
// `dpop_token=…` (a query string) and `dpop_token: '…'` (a Python repr). A
|
|
31
|
+
// quoted value is taken whole, spaces and all; a bare one up to a delimiter.
|
|
32
|
+
function fieldValue(names) {
|
|
33
|
+
return new RegExp(`((?:\\\\*["'])?\\b${names}(?:\\\\*["'])?\\s*[:=]\\s*)(?:(\\\\*["'])[^"'\\\\]*|[^"'\\\\,}\\]&\\s]+)`, "gi");
|
|
34
|
+
}
|
|
35
|
+
const FIELD_VALUE = fieldValue(SECRET_FIELD);
|
|
36
|
+
// update_patron_credential / update_operator_credential carry the secret as
|
|
37
|
+
// `value` beside a `field` naming it, so any `value` in such a call goes too.
|
|
38
|
+
const CREDENTIAL_VALUE = fieldValue("value");
|
|
39
|
+
const HAS_FIELD = /\\*["']field\\*["']\s*:/i;
|
|
40
|
+
const NSEC = /\b(?:nsec|ncryptsec)1[02-9ac-hj-np-z]{20,}/gi;
|
|
41
|
+
const HEX_KEY = /\b[0-9a-f]{64,}\b/gi; // keys, and signatures (128)
|
|
42
|
+
const BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]+/g;
|
|
43
|
+
function scrubbed(_match, head, quote) {
|
|
44
|
+
return `${head}${quote ?? ""}${REDACTED}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Scrub secrets from a log line. Deliberately over-eager: a 64-hex event id
|
|
48
|
+
* is hidden along with a hex private key, because the two look alike.
|
|
49
|
+
*/
|
|
50
|
+
export function redact(message) {
|
|
51
|
+
let out = message
|
|
52
|
+
.replace(NSEC, REDACTED)
|
|
53
|
+
.replace(BEARER, `Bearer ${REDACTED}`)
|
|
54
|
+
.replace(FIELD_VALUE, scrubbed);
|
|
55
|
+
if (HAS_FIELD.test(out))
|
|
56
|
+
out = out.replace(CREDENTIAL_VALUE, scrubbed);
|
|
57
|
+
return out.replace(HEX_KEY, REDACTED);
|
|
58
|
+
}
|
|
59
|
+
// ── Storage (best effort) ──────────────────────────────────────────────────
|
|
60
|
+
function storageKey(name) {
|
|
61
|
+
try {
|
|
62
|
+
return `${tollboothConfig().storagePrefix}:${name}`;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null; // not configured yet: nothing to read or write
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function readLocal(name) {
|
|
69
|
+
const k = storageKey(name);
|
|
70
|
+
if (!k)
|
|
71
|
+
return null;
|
|
72
|
+
try {
|
|
73
|
+
return globalThis.localStorage?.getItem(k) ?? null;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function writeLocal(name, value) {
|
|
80
|
+
const k = storageKey(name);
|
|
81
|
+
if (!k)
|
|
82
|
+
return;
|
|
83
|
+
try {
|
|
84
|
+
globalThis.localStorage?.setItem(k, value);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* quota or blocked site data — the in-memory log still works */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const LOG_KEY = "debug-log:v1";
|
|
91
|
+
function save() {
|
|
92
|
+
if (persist)
|
|
93
|
+
writeLocal(LOG_KEY, JSON.stringify(log));
|
|
94
|
+
}
|
|
95
|
+
function hydrate() {
|
|
96
|
+
try {
|
|
97
|
+
const parsed = JSON.parse(readLocal(LOG_KEY) ?? "[]");
|
|
98
|
+
if (!Array.isArray(parsed))
|
|
99
|
+
return [];
|
|
100
|
+
return parsed
|
|
101
|
+
.filter((e) => !!e && typeof e.message === "string" && typeof e.ts === "string")
|
|
102
|
+
.map((e) => ({ ts: e.ts, type: e.type, message: redact(e.message) }));
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The patron's display zone, if the site stores one under `<prefix>:timezone`
|
|
110
|
+
* ("auto" or absent means the browser's own), so stamps match the rest of the UI.
|
|
111
|
+
*/
|
|
112
|
+
function stamp(now) {
|
|
113
|
+
try {
|
|
114
|
+
const pref = readLocal("timezone");
|
|
115
|
+
const zone = pref && pref !== "auto" ? pref : undefined;
|
|
116
|
+
return now.toLocaleTimeString(undefined, zone ? { timeZone: zone } : undefined);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return now.toLocaleTimeString();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function emit() {
|
|
123
|
+
save();
|
|
16
124
|
listeners.forEach((fn) => fn());
|
|
17
125
|
}
|
|
126
|
+
// ── API ────────────────────────────────────────────────────────────────────
|
|
127
|
+
/** Set the cap and persistence. Call after `configureTollbooth`. */
|
|
128
|
+
export function configureDebugLog(options) {
|
|
129
|
+
if (options.max !== undefined) {
|
|
130
|
+
if (!Number.isInteger(options.max) || options.max < 1)
|
|
131
|
+
throw new Error("max must be a positive integer");
|
|
132
|
+
max = options.max;
|
|
133
|
+
}
|
|
134
|
+
if (options.persist !== undefined) {
|
|
135
|
+
persist = options.persist;
|
|
136
|
+
if (persist)
|
|
137
|
+
log = [...log, ...hydrate()];
|
|
138
|
+
}
|
|
139
|
+
log = log.slice(0, max);
|
|
140
|
+
emit();
|
|
141
|
+
}
|
|
142
|
+
export function debugPush(type, message) {
|
|
143
|
+
log = [{ ts: stamp(new Date()), type, message: redact(String(message)) }, ...log].slice(0, max);
|
|
144
|
+
emit();
|
|
145
|
+
}
|
|
18
146
|
export function clearDebug() {
|
|
19
|
-
log
|
|
20
|
-
|
|
147
|
+
log = [];
|
|
148
|
+
emit();
|
|
21
149
|
}
|
|
150
|
+
/** The entries, newest first. The same array until the log changes. */
|
|
22
151
|
export function debugEntries() {
|
|
23
152
|
return log;
|
|
24
153
|
}
|
|
25
154
|
/** Subscribe to changes. Returns an unsubscribe function. */
|
|
26
155
|
export function onDebug(fn) {
|
|
27
156
|
listeners.add(fn);
|
|
28
|
-
return () =>
|
|
157
|
+
return () => {
|
|
158
|
+
listeners.delete(fn);
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** The whole log as plain text, newest first — for Copy and bug reports. */
|
|
162
|
+
export function debugLogText() {
|
|
163
|
+
return log.map((e) => `${e.ts} ${e.type.toUpperCase()} ${e.message}`).join("\n");
|
|
164
|
+
}
|
|
165
|
+
// Auth and funding outcomes are situations, not faults: the service answered
|
|
166
|
+
// correctly and the patron has a step to take (sign in, top up). These SDK
|
|
167
|
+
// error codes read as a notice and stay out of the failure count.
|
|
168
|
+
const NOTICE_CODE = /error_code\\?"\s*:\s*\\?"(npub_missing|proof_missing|proof_required|proof_refresh_needed|dpop_token_missing|oauth_not_yet_authorized|oauth_token_expired|upstream_auth_refresh_needed|insufficient_balance|authority_insufficient_balance|upstream_subscription_required|operator_llm_unfunded)\\?"/i;
|
|
169
|
+
function isFailure(entry) {
|
|
170
|
+
if (entry.type === "error")
|
|
171
|
+
return true;
|
|
172
|
+
if (entry.type !== "result")
|
|
173
|
+
return false;
|
|
174
|
+
const m = entry.message;
|
|
175
|
+
return m.includes('"success":false') || m.includes('"ok":false') || m.includes('"error"') || m.includes("error_code");
|
|
176
|
+
}
|
|
177
|
+
/** How a panel should flag an entry: a fault, a patron's next step, or neither. */
|
|
178
|
+
export function debugSeverity(entry) {
|
|
179
|
+
if (!isFailure(entry))
|
|
180
|
+
return "ok";
|
|
181
|
+
return NOTICE_CODE.test(entry.message) ? "notice" : "failure";
|
|
182
|
+
}
|
|
183
|
+
// ── Page-wide capture ──────────────────────────────────────────────────────
|
|
184
|
+
let captured = false;
|
|
185
|
+
/**
|
|
186
|
+
* Fold uncaught errors and promise rejections into the log, once per page
|
|
187
|
+
* (guarded, so StrictMode and remounts do not double-register).
|
|
188
|
+
*/
|
|
189
|
+
export function captureGlobalErrors() {
|
|
190
|
+
if (captured || typeof globalThis.addEventListener !== "function")
|
|
191
|
+
return;
|
|
192
|
+
captured = true;
|
|
193
|
+
globalThis.addEventListener("error", (e) => debugPush("error", `window.error: ${e.message}`));
|
|
194
|
+
globalThis.addEventListener("unhandledrejection", (e) => debugPush("error", `unhandledrejection: ${String(e.reason)}`));
|
|
29
195
|
}
|
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 {
|
|
14
|
+
export { loadQuotes, peekQuotes, shuffle, validQuotes, type Quote } from "./quotes.ts";
|
|
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 {
|
|
13
|
+
export { loadQuotes, peekQuotes, shuffle, validQuotes } from "./quotes.js";
|
|
14
|
+
export { captureGlobalErrors, clearDebug, configureDebugLog, debugEntries, debugLogText, debugPush, debugSeverity, onDebug, redact, } from "./debugLog.js";
|
package/dist/quotes.d.ts
ADDED
|
@@ -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,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The on-screen MCP activity log — a bar fixed to the bottom of the page that
|
|
3
|
+
* shows every call, result and error, so anyone can see what the front end is
|
|
4
|
+
* doing and paste it into a bug report.
|
|
5
|
+
*
|
|
6
|
+
* Collapsed it is one tab whose colour says whether anything failed (red) or
|
|
7
|
+
* is waiting on the patron (a notice: sign in, top up). Open, it lists the log
|
|
8
|
+
* newest first with Copy and Clear. Everything shown comes from the scrubbed
|
|
9
|
+
* store, so no nsec, proof or token can be painted here.
|
|
10
|
+
*
|
|
11
|
+
* `children` is the site's own section — eXcalibur puts its scheduler controls
|
|
12
|
+
* there — drawn above the log while the panel is open.
|
|
13
|
+
*
|
|
14
|
+
* Touch-first: every control is a real button at least 40px square, nothing
|
|
15
|
+
* hides behind hover, and the page under the bar stays tappable.
|
|
16
|
+
*/
|
|
17
|
+
import { type ReactNode } from "react";
|
|
18
|
+
export interface DebugPanelProps {
|
|
19
|
+
/** The tab's label. Default "Debug". */
|
|
20
|
+
title?: string;
|
|
21
|
+
/** Open on first visit; afterwards the patron's last choice wins. */
|
|
22
|
+
defaultOpen?: boolean;
|
|
23
|
+
/** Fold uncaught errors and proof expiries into the log. Default true. */
|
|
24
|
+
captureErrors?: boolean;
|
|
25
|
+
/** The site's own section, shown above the log while open. */
|
|
26
|
+
children?: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
export default function DebugPanel({ title, defaultOpen, captureErrors, children, }: DebugPanelProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The on-screen MCP activity log — a bar fixed to the bottom of the page that
|
|
4
|
+
* shows every call, result and error, so anyone can see what the front end is
|
|
5
|
+
* doing and paste it into a bug report.
|
|
6
|
+
*
|
|
7
|
+
* Collapsed it is one tab whose colour says whether anything failed (red) or
|
|
8
|
+
* is waiting on the patron (a notice: sign in, top up). Open, it lists the log
|
|
9
|
+
* newest first with Copy and Clear. Everything shown comes from the scrubbed
|
|
10
|
+
* store, so no nsec, proof or token can be painted here.
|
|
11
|
+
*
|
|
12
|
+
* `children` is the site's own section — eXcalibur puts its scheduler controls
|
|
13
|
+
* there — drawn above the log while the panel is open.
|
|
14
|
+
*
|
|
15
|
+
* Touch-first: every control is a real button at least 40px square, nothing
|
|
16
|
+
* hides behind hover, and the page under the bar stays tappable.
|
|
17
|
+
*/
|
|
18
|
+
import { useEffect, useState } from "react";
|
|
19
|
+
import { onProofExpired } from "../client.js";
|
|
20
|
+
import { captureGlobalErrors, clearDebug, debugLogText, debugPush, debugSeverity, } from "../debugLog.js";
|
|
21
|
+
import { readStored, writeStored } from "../storage.js";
|
|
22
|
+
import { useDebugLog } from "./useDebugLog.js";
|
|
23
|
+
const OPEN_KEY = "debug-open";
|
|
24
|
+
const TYPE_INK = {
|
|
25
|
+
info: "text-[var(--tb-accent)]",
|
|
26
|
+
call: "text-[var(--tb-warn-ink)]",
|
|
27
|
+
result: "text-[var(--tb-ok)]",
|
|
28
|
+
error: "text-[var(--tb-err-ink)]",
|
|
29
|
+
};
|
|
30
|
+
const FLAGGED = {
|
|
31
|
+
failure: { row: "bg-[var(--tb-err-bg)]", ink: "text-[var(--tb-err-ink)] font-bold" },
|
|
32
|
+
notice: { row: "bg-[var(--tb-notice-bg)]", ink: "text-[var(--tb-notice-ink)]" },
|
|
33
|
+
};
|
|
34
|
+
const tab = "min-h-10 min-w-10 rounded-t-lg px-4 text-xs font-medium border border-b-0 transition-colors";
|
|
35
|
+
const plainTab = `${tab} bg-[var(--tb-surface-2)] text-[var(--tb-ink)] border-[var(--tb-line)]`;
|
|
36
|
+
export default function DebugPanel({ title = "Debug", defaultOpen = false, captureErrors = true, children, }) {
|
|
37
|
+
const log = useDebugLog();
|
|
38
|
+
const [open, setOpen] = useState(() => {
|
|
39
|
+
const saved = readStored(OPEN_KEY);
|
|
40
|
+
return saved === "" ? defaultOpen : saved === "1";
|
|
41
|
+
});
|
|
42
|
+
const [copied, setCopied] = useState("");
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!captureErrors)
|
|
45
|
+
return;
|
|
46
|
+
captureGlobalErrors();
|
|
47
|
+
return onProofExpired((msg) => debugPush("info", `proof expired → back to sign-in: ${msg}`));
|
|
48
|
+
}, [captureErrors]);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!copied)
|
|
51
|
+
return;
|
|
52
|
+
const id = setTimeout(() => setCopied(""), 1500);
|
|
53
|
+
return () => clearTimeout(id);
|
|
54
|
+
}, [copied]);
|
|
55
|
+
function toggle() {
|
|
56
|
+
const next = !open;
|
|
57
|
+
setOpen(next);
|
|
58
|
+
writeStored(OPEN_KEY, next ? "1" : "0");
|
|
59
|
+
}
|
|
60
|
+
function copy() {
|
|
61
|
+
const text = debugLogText();
|
|
62
|
+
const clip = globalThis.navigator?.clipboard;
|
|
63
|
+
if (!clip) {
|
|
64
|
+
setCopied("failed");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
clip.writeText(text).then(() => setCopied("ok"), () => setCopied("failed"));
|
|
68
|
+
}
|
|
69
|
+
const failures = log.filter((e) => debugSeverity(e) === "failure").length;
|
|
70
|
+
const notices = log.filter((e) => debugSeverity(e) === "notice").length;
|
|
71
|
+
const toggleTone = failures > 0
|
|
72
|
+
? "bg-[var(--tb-err-bg)] text-[var(--tb-err-ink)] border-[var(--tb-err-line)]"
|
|
73
|
+
: notices > 0
|
|
74
|
+
? "bg-[var(--tb-notice-bg)] text-[var(--tb-notice-ink)] border-[var(--tb-notice-line)]"
|
|
75
|
+
: "bg-[var(--tb-surface-2)] text-[var(--tb-ink)] border-[var(--tb-line)]";
|
|
76
|
+
return (_jsxs("div", { 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) => {
|
|
77
|
+
const sev = debugSeverity(entry);
|
|
78
|
+
const flag = sev === "ok" ? null : FLAGGED[sev];
|
|
79
|
+
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));
|
|
80
|
+
})] }))] }));
|
|
81
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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. Coloured only through `--tb-*` tokens.
|
|
15
|
+
*/
|
|
16
|
+
import { type Quote } from "../quotes.ts";
|
|
17
|
+
export interface QuoteScrollerProps {
|
|
18
|
+
/** The site's own quotes: shown at once, and kept if `source` fails. Pass a
|
|
19
|
+
* module constant. */
|
|
20
|
+
quotes: ReadonlyArray<Quote>;
|
|
21
|
+
/** URL of a `{ quotes: [{ text, author }] }` JSON file that supersedes
|
|
22
|
+
* `quotes` once it loads. */
|
|
23
|
+
source?: string;
|
|
24
|
+
/** A status line above the quotes ("Reading the ledger…"). */
|
|
25
|
+
heading?: string;
|
|
26
|
+
/** Show a spinner beside the heading. Default false. */
|
|
27
|
+
spinner?: boolean;
|
|
28
|
+
/** How long each quote stays, in ms. Default 3500. */
|
|
29
|
+
intervalMs?: number;
|
|
30
|
+
/** Classes for the outer box (padding, placement). */
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
export default function QuoteScroller({ quotes, source, heading, spinner, intervalMs, className, }: QuoteScrollerProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,106 @@
|
|
|
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. Coloured only through `--tb-*` tokens.
|
|
16
|
+
*/
|
|
17
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
18
|
+
import { Loader2 } from "lucide-react";
|
|
19
|
+
import { loadQuotes, peekQuotes, shuffle } from "../quotes.js";
|
|
20
|
+
const FADE_MS = 450;
|
|
21
|
+
function useReducedMotion() {
|
|
22
|
+
const query = "(prefers-reduced-motion: reduce)";
|
|
23
|
+
const [reduced, setReduced] = useState(() => typeof window !== "undefined" && !!window.matchMedia?.(query).matches);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const mq = window.matchMedia?.(query);
|
|
26
|
+
if (!mq)
|
|
27
|
+
return;
|
|
28
|
+
const on = () => setReduced(mq.matches);
|
|
29
|
+
mq.addEventListener("change", on);
|
|
30
|
+
return () => mq.removeEventListener("change", on);
|
|
31
|
+
}, []);
|
|
32
|
+
return reduced;
|
|
33
|
+
}
|
|
34
|
+
function usePageVisible() {
|
|
35
|
+
const [shown, setShown] = useState(() => typeof document === "undefined" || document.visibilityState !== "hidden");
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const on = () => setShown(document.visibilityState !== "hidden");
|
|
38
|
+
document.addEventListener("visibilitychange", on);
|
|
39
|
+
return () => document.removeEventListener("visibilitychange", on);
|
|
40
|
+
}, []);
|
|
41
|
+
return shown;
|
|
42
|
+
}
|
|
43
|
+
export default function QuoteScroller({ quotes, source, heading, spinner = false, intervalMs = 3500, className = "", }) {
|
|
44
|
+
const reduced = useReducedMotion();
|
|
45
|
+
const pageVisible = usePageVisible();
|
|
46
|
+
// Content, not identity: a site that passes an inline array still gets one
|
|
47
|
+
// shuffle, not one per render.
|
|
48
|
+
const signature = quotes.map((q) => `${q.text}\u0000${q.author}`).join("\u0001");
|
|
49
|
+
const [list, setList] = useState(() => shuffle((source && peekQuotes(source)) || quotes));
|
|
50
|
+
const [index, setIndex] = useState(0);
|
|
51
|
+
const [shown, setShown] = useState(false);
|
|
52
|
+
const key = `${source ?? ""}\u0002${signature}`;
|
|
53
|
+
const first = useRef(key);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (first.current !== key) {
|
|
56
|
+
first.current = key;
|
|
57
|
+
setList(shuffle((source && peekQuotes(source)) || quotes));
|
|
58
|
+
setIndex(0);
|
|
59
|
+
}
|
|
60
|
+
if (!source || peekQuotes(source))
|
|
61
|
+
return; // nothing to fetch, or already showing it
|
|
62
|
+
let alive = true;
|
|
63
|
+
void loadQuotes(source, quotes).then((loaded) => {
|
|
64
|
+
if (!alive || !peekQuotes(source))
|
|
65
|
+
return; // the site's own set is already up
|
|
66
|
+
setList(shuffle(loaded));
|
|
67
|
+
setIndex(0);
|
|
68
|
+
});
|
|
69
|
+
return () => {
|
|
70
|
+
alive = false;
|
|
71
|
+
};
|
|
72
|
+
// `key` stands for `quotes` and `source`, by content.
|
|
73
|
+
}, [key]);
|
|
74
|
+
// Fade the first quote in.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const id = window.setTimeout(() => setShown(true), 30);
|
|
77
|
+
return () => window.clearTimeout(id);
|
|
78
|
+
}, []);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (list.length <= 1 || !pageVisible)
|
|
81
|
+
return;
|
|
82
|
+
const fade = reduced ? 0 : FADE_MS;
|
|
83
|
+
let swap;
|
|
84
|
+
const tick = window.setInterval(() => {
|
|
85
|
+
setShown(false);
|
|
86
|
+
swap = window.setTimeout(() => {
|
|
87
|
+
setIndex((i) => (i + 1) % list.length);
|
|
88
|
+
setShown(true);
|
|
89
|
+
}, fade);
|
|
90
|
+
}, intervalMs);
|
|
91
|
+
return () => {
|
|
92
|
+
window.clearInterval(tick);
|
|
93
|
+
window.clearTimeout(swap);
|
|
94
|
+
setShown(true);
|
|
95
|
+
};
|
|
96
|
+
}, [list, intervalMs, reduced, pageVisible]);
|
|
97
|
+
const longest = useMemo(() => list.reduce((a, q) => (!a || q.text.length + q.author.length > a.text.length + a.author.length ? q : a), undefined), [list]);
|
|
98
|
+
const q = list[index % Math.max(list.length, 1)];
|
|
99
|
+
return (_jsxs("div", { className: `px-4 py-6 text-center ${className}`, children: [heading && (_jsxs("div", { className: "mb-5 flex items-center justify-center gap-2 font-mono text-[11px] uppercase tracking-[0.3em] text-[var(--tb-accent)]", children: [spinner && _jsx(Loader2, { "aria-hidden": true, className: "h-3.5 w-3.5 motion-safe:animate-spin" }), heading] })), q && longest && (_jsxs("div", { className: "mx-auto grid max-w-xl", children: [_jsx(QuoteText, { quote: longest, className: "invisible [grid-area:1/1]", hidden: true }), _jsx(QuoteText, { quote: q, className: "[grid-area:1/1]", style: {
|
|
100
|
+
opacity: shown ? 1 : 0,
|
|
101
|
+
transition: reduced ? "none" : `opacity ${FADE_MS}ms ease`,
|
|
102
|
+
}, live: true })] }))] }));
|
|
103
|
+
}
|
|
104
|
+
function QuoteText({ quote, className, style, hidden = false, live = false, }) {
|
|
105
|
+
return (_jsxs("figure", { className: `m-0 flex flex-col justify-center gap-3 ${className}`, style: style, "aria-hidden": hidden || undefined, "aria-live": live ? "polite" : undefined, "aria-atomic": live || undefined, children: [_jsxs("blockquote", { className: "m-0 font-serif text-[17px] italic leading-relaxed text-[var(--tb-ink)]", children: [_jsx("span", { className: "not-italic text-[var(--tb-accent)]", children: "\u201C" }), quote.text, _jsx("span", { className: "not-italic text-[var(--tb-accent)]", children: "\u201D" })] }), _jsx("figcaption", { className: "font-mono text-[10.5px] uppercase tracking-[0.22em] text-[var(--tb-muted)]", children: quote.author })] }));
|
|
106
|
+
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
export { default as Avatar } from "./Avatar.tsx";
|
|
2
2
|
export { default as AvatarPicker } from "./AvatarPicker.tsx";
|
|
3
|
+
export { default as DebugPanel, type DebugPanelProps } from "./DebugPanel.tsx";
|
|
3
4
|
export { default as NpubGate } from "./NpubGate.tsx";
|
|
4
5
|
export { default as NostrProfilePanel } from "./NostrProfilePanel.tsx";
|
|
6
|
+
export { default as QuoteScroller, type QuoteScrollerProps } from "./QuoteScroller.tsx";
|
|
5
7
|
export { default as SessionKeyClaim } from "./SessionKeyClaim.tsx";
|
|
6
8
|
export { default as WalletCard } from "./WalletCard.tsx";
|
|
7
9
|
export { useSession, type Session } from "./useSession.ts";
|
|
10
|
+
export { useDebugLog } from "./useDebugLog.ts";
|
package/dist/react/index.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
export { default as Avatar } from "./Avatar.js";
|
|
2
2
|
export { default as AvatarPicker } from "./AvatarPicker.js";
|
|
3
|
+
export { default as DebugPanel } from "./DebugPanel.js";
|
|
3
4
|
export { default as NpubGate } from "./NpubGate.js";
|
|
4
5
|
export { default as NostrProfilePanel } from "./NostrProfilePanel.js";
|
|
6
|
+
export { default as QuoteScroller } from "./QuoteScroller.js";
|
|
5
7
|
export { default as SessionKeyClaim } from "./SessionKeyClaim.js";
|
|
6
8
|
export { default as WalletCard } from "./WalletCard.js";
|
|
7
9
|
export { useSession } from "./useSession.js";
|
|
10
|
+
export { useDebugLog } from "./useDebugLog.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The debug log's entries, newest first, re-rendering whenever they change.
|
|
3
|
+
* Entries are already scrubbed of secrets by `debugPush`.
|
|
4
|
+
*/
|
|
5
|
+
import { useSyncExternalStore } from "react";
|
|
6
|
+
import { debugEntries, onDebug } from "../debugLog.js";
|
|
7
|
+
export function useDebugLog() {
|
|
8
|
+
return useSyncExternalStore(onDebug, debugEntries, debugEntries);
|
|
9
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tollbooth-dpyc/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
package/theme.css
CHANGED
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
--tb-err-bg: #fdecec;
|
|
26
26
|
--tb-err-line: #f2b8b8;
|
|
27
27
|
--tb-err-ink: #9b1c1c;
|
|
28
|
+
--tb-notice-bg: #f4ecfd;
|
|
29
|
+
--tb-notice-line: #d6bdf5;
|
|
30
|
+
--tb-notice-ink: #5b2a91;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
@media (prefers-color-scheme: dark) {
|
|
@@ -43,5 +46,8 @@
|
|
|
43
46
|
--tb-err-bg: #2c1515;
|
|
44
47
|
--tb-err-line: #5e2525;
|
|
45
48
|
--tb-err-ink: #f4a3a3;
|
|
49
|
+
--tb-notice-bg: #241532;
|
|
50
|
+
--tb-notice-line: #4d2d6e;
|
|
51
|
+
--tb-notice-ink: #d8b4fe;
|
|
46
52
|
}
|
|
47
53
|
}
|