localharness 0.10.27

A Rust-native agent SDK for Gemini. Streaming, custom tools, safety policies, background triggers — zero external binaries.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
//! Browser-resident IDE for the localharness SDK.
//!
//! Compiled into the crate only when both `feature = "browser-app"`
//! and `target_arch = "wasm32"` are active (see `lib.rs`). The shipping
//! bundle is built by `scripts/build-web.{sh,ps1}` running
//! `wasm-pack build --features browser-app --no-default-features`.
//!
//! Design rule: **no imperative DOM manipulation**. All HTML is built
//! by [`maud`] templates and shipped into the document via
//! `set_inner_html` or `insert_adjacent_html` swaps targeted by `id=`.
//! Event handling uses one delegated click + one delegated keydown
//! listener at the document level — UI elements declare intent through
//! `data-action="..."` attributes, the way HTMX does. There is no
//! per-element `Closure::wrap` chain anywhere in this module.

use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;

use wasm_bindgen::prelude::*;

use crate::filesystem::OpfsFilesystem;
use crate::Agent;

mod chat;
mod compose;
mod dom;
mod embed;
mod events;
mod history;
mod key_store;
mod opfs;
mod owner;
mod pricing;
mod signer;
mod sponsor;
mod system_prompt;
mod templates;
mod tool_allowlist;
mod tenant;
mod verify;
mod wallet_store;

// Re-export the crate-level public registry module as `app::registry`
// so the existing intra-app imports keep working unchanged.
pub(crate) use crate::registry;

/// Per-tab state. One instance lives in [`APP`] for the lifetime of the
/// page. Nothing here is `Send`/`Sync` — wasm32 is single-threaded.
pub(crate) struct App {
    pub(crate) agent: Option<Rc<Agent>>,
    /// API key the current `agent` was started with. Used to detect
    /// "user pasted a new key" and reset the session.
    pub(crate) session_key: Option<String>,
    pub(crate) turn_count: u32,
    /// Monotonic id used for unique DOM ids on turns, segments, tool
    /// blocks. Never reused across resets so stale event targets are
    /// safe to drop.
    pub(crate) next_id: u32,
    /// Current working directory for the OPFS panel, as a sequence of
    /// directory names from the OPFS root. Empty means root.
    pub(crate) opfs_cwd: Vec<String>,
    /// Shared OPFS handle used by the panel. Built lazily so a missing
    /// browser-OPFS just leaves the panel idle rather than panicking.
    pub(crate) opfs: Option<Arc<OpfsFilesystem>>,
    /// Restored-from-OPFS history bytes from a previous session. Set
    /// once on mount (if the marker file exists) and consumed by the
    /// next `start_session`. None after first use so it doesn't get
    /// re-applied on subsequent key changes.
    pub(crate) pending_history: Option<Vec<u8>>,
    /// Master wallet at the apex origin. Cached after first load so
    /// the "reveal seed" affordance can read it without re-touching
    /// OPFS. `None` everywhere except the apex chrome path.
    pub(crate) wallet: Option<wallet_store::MasterWallet>,
    /// Result of the most-recent on-chain owner verification on a
    /// tenant subdomain. Updated asynchronously after `paint_tenant`
    /// renders the chrome; the UI pill reflects whatever's here.
    pub(crate) verify_state: VerifyState,
    /// Agent's ERC-6551 token-bound account, populated by
    /// `kick_verification` after the on-chain TBA lookup. Read by
    /// the payment flow so the visitor pays the right address.
    pub(crate) tba_address: Option<String>,
    /// Per-turn payment price in wei for this tenant. Populated by
    /// the same load step that reads the verify state; consulted by
    /// `chat::run_send` to decide whether to gate the next turn.
    /// `None` means "haven't checked yet"; `Some(0)` means "free".
    pub(crate) pricing_wei: Option<u128>,
}

/// Surface-level summary of the cross-origin verification flow,
/// mirrored into the chrome via a status pill.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) enum VerifyState {
    #[default]
    Pending,
    /// On-chain owner exists AND the iframe signer's signature
    /// recovered to that address.
    Verified {
        address: String,
    },
    /// On-chain owner exists but the visitor's wallet signed with a
    /// different address — they're browsing someone else's space.
    /// `visitor_address` is the recovered signer; payment flow uses it
    /// as the `from` of the on-chain payment tx.
    Visitor {
        owner_address: String,
        visitor_address: String,
    },
    /// Name has no on-chain owner; legacy local-OPFS marker is the
    /// only source of truth.
    Unregistered,
    /// Verification flow itself failed (RPC down, iframe failed,
    /// signer didn't respond). Treat as legacy-trust mode but show
    /// the user that verification didn't complete.
    Failed {
        reason: String,
    },
}

impl App {
    fn new() -> Self {
        Self {
            agent: None,
            session_key: None,
            turn_count: 0,
            next_id: 0,
            opfs_cwd: Vec::new(),
            opfs: None,
            pending_history: None,
            wallet: None,
            verify_state: VerifyState::Pending,
            tba_address: None,
            pricing_wei: None,
        }
    }

    pub(crate) fn alloc_id(&mut self) -> u32 {
        let id = self.next_id;
        self.next_id = self.next_id.wrapping_add(1);
        id
    }
}

thread_local! {
    pub(crate) static APP: RefCell<App> = RefCell::new(App::new());
}

/// Shared `OpfsFilesystem` handle — one per tab. Lazily initialised on
/// first use so the rest of the app doesn't have to care whether the
/// browser supports OPFS until something actually touches it.
pub(crate) fn shared_opfs() -> Arc<OpfsFilesystem> {
    APP.with(|cell| {
        let mut app = cell.borrow_mut();
        if app.opfs.is_none() {
            app.opfs = Some(Arc::new(OpfsFilesystem::new()));
        }
        app.opfs.as_ref().unwrap().clone()
    })
}

/// Auto-runs at module load. Renders the initial chrome into `#root`
/// and attaches the delegated event listeners. Everything else is
/// driven by user events from here on.
#[wasm_bindgen(start)]
fn start() {
    console_error_panic_hook::set_once();

    if let Err(err) = mount() {
        web_sys::console::error_1(&JsValue::from_str(&format!(
            "localharness app failed to mount: {err:?}"
        )));
    }
}

fn mount() -> Result<(), JsValue> {
    let doc = dom::document()?;
    let root = doc
        .get_element_by_id("root")
        .ok_or_else(|| JsValue::from_str("missing <div id=\"root\"> in the host page"))?;

    // Resolve which tenant we're being served as. On apex, we paint a
    // marketing chrome with a single "claim a subdomain" CTA. On a
    // tenant subdomain, we check the OPFS ownership marker and paint
    // either the unclaimed-prompt or the full app. On unknown hosts
    // (localhost, Vercel preview) we paint the full app for testing.
    let host = tenant::current();
    let host_for_listeners = host.clone();

    // Delegated listeners are installed first so the apex / unclaimed
    // templates' buttons work even before we hit the async branches.
    events::install_delegated_listeners(&doc)?;

    // Compose mode short-circuit (?compose=name1,name2,...). Renders a
    // grid of embed-mode iframes — the minimal host harness for the
    // composable-subdomain primitive. Works on any origin.
    if let Some(names) = compose::compose_names() {
        compose::paint_compose(names)?;
        return Ok(());
    }

    // Embed mode short-circuit (?embed=1). Paints just the identity
    // card sized for inclusion in a parent iframe. Activated on any
    // host so apex and tenants alike can present themselves as
    // modules.
    if embed::has_embed_hint() {
        root.set_inner_html(
            "<main style=\"padding:24px;color:#7a8493;font:14px ui-monospace,Menlo,Consolas,monospace\">embed · loading…</main>",
        );
        let host_for_embed = host.clone();
        wasm_bindgen_futures::spawn_local(async move {
            embed::paint_embed(host_for_embed).await;
        });
        return Ok(());
    }

    // Signer mode short-circuit. When apex is loaded with ?signer=1
    // (typically in a hidden iframe from a subdomain doing owner
    // verification), skip the marketing chrome entirely and just turn
    // this tab into a postMessage signing service.
    if matches!(&host, tenant::Host::Apex) && has_signer_hint() {
        root.set_inner_html("<main style=\"padding:48px;text-align:center;color:#7a8493;font:14px ui-monospace,Menlo,Consolas,monospace\">localharness signer · loading…</main>");
        signer::install_signer_listener()?;
        wasm_bindgen_futures::spawn_local(async move {
            // Loading the wallet warms it into App state so the
            // postMessage handler can pull it synchronously.
            paint_signer().await;
        });
        return Ok(());
    }

    match &host {
        tenant::Host::Apex => {
            // Wallet load is async (OPFS). Show a single-line placeholder
            // rather than the full chrome so we don't flash the
            // pre-identity sidecar before we know whether a wallet exists.
            let host_for_apex = host.clone();
            root.set_inner_html(
                "<main style=\"padding:48px;text-align:center;color:#7a8493;font:14px ui-monospace,Menlo,Consolas,monospace\">localharness · loading…</main>",
            );
            wasm_bindgen_futures::spawn_local(async move {
                paint_apex(host_for_apex).await;
            });
            return Ok(());
        }
        tenant::Host::Tenant(name) => {
            // Tenant subdomain — defer the chrome choice until we've
            // peeked at the ownership marker (async).
            let placeholder = format!(
                "<main style=\"padding:48px;text-align:center;color:#7a8493;\
                 font:14px ui-monospace,Menlo,Consolas,monospace\">\
                 resolving {name}…</main>"
            );
            root.set_inner_html(&placeholder);
            let name = name.clone();
            wasm_bindgen_futures::spawn_local(async move {
                paint_tenant(host_for_listeners, name).await;
            });
            return Ok(());
        }
        tenant::Host::Other(_) => {
            // Fall through to the existing chrome path.
        }
    }

    // Full-app chrome (localhost, Vercel preview, etc.).
    root.set_inner_html(&templates::chrome(&host).into_string());

    // sessionStorage is the synchronous fallback for the input field's
    // initial value. The OPFS-stored key (async) takes over once it
    // resolves; if both exist, OPFS wins.
    if let Some(storage) = dom::session_storage()? {
        if let Ok(Some(cached)) = storage.get_item("gemini_api_key") {
            if let Some(input) = dom::input_by_id("key") {
                input.set_value(&cached);
                events::refresh_keymeta();
            }
        }
    }

    // (no baseline status — terminal stays empty until something happens)

    // Initial OPFS panel paint + history restore + key restore. All
    // async; the key loader populates the input field if a persisted
    // key exists (overriding sessionStorage).
    wasm_bindgen_futures::spawn_local(async move {
        if let Some(persisted_key) = key_store::load().await {
            if let Some(input) = dom::input_by_id("key") {
                input.set_value(&persisted_key);
                events::refresh_keymeta();
            }
        }
        history::load_into_pending().await;
        opfs::refresh().await;
    });
    Ok(())
}

/// Render a tenant subdomain. Three branches:
/// 1. Local `.lh_owner` marker → device thinks it owns the name. Paint
///    full chat app, kick verification in the background.
/// 2. No local marker but the name IS on-chain owned (by anyone) →
///    paint full chat app as a visitor; verification will recover the
///    visitor's address and the pricing/payment loop takes it from there.
/// 3. No local marker AND no on-chain owner → genuinely unclaimed;
///    paint the "claim this name?" prompt.
pub(crate) async fn paint_tenant(host: tenant::Host, name: String) {
    let Ok(doc) = dom::document() else { return };
    let Some(root) = doc.get_element_by_id("root") else { return };

    let mut owner = owner::current_owner().await;
    // Apex sends users here with ?claim=1 to skip the
    // "claim this name?" interstitial — the user has already expressed
    // intent on the previous page. Auto-claim, then strip the param so
    // a refresh doesn't trigger anything weird.
    if owner.is_none() && has_claim_hint() {
        if let Ok(id) = owner::claim().await {
            owner = Some(id);
            strip_claim_hint();
        }
    }
    if owner.is_none() {
        // No local-device claim. Check on-chain — if someone owns this
        // name in the registry, the visitor isn't claiming, they're
        // browsing. Paint the chat chrome and let verification figure
        // out whether they're the owner or a paying visitor.
        let on_chain = registry::owner_of_name(&name).await.ok().flatten();
        if on_chain.is_none() {
            root.set_inner_html(&templates::unclaimed(&host, &name).into_string());
            return;
        }
        // Fall through to chrome paint as visitor.
    }

    // Paint the full app — either we own it on this device or someone
    // else owns it on-chain and we're visiting.
    root.set_inner_html(&templates::chrome(&host).into_string());

    if let Ok(Some(storage)) = dom::session_storage() {
        if let Ok(Some(cached)) = storage.get_item("gemini_api_key") {
            if let Some(input) = dom::input_by_id("key") {
                input.set_value(&cached);
                events::refresh_keymeta();
            }
        }
    }
    // (no baseline status — terminal stays empty until something happens)

    if let Some(persisted_key) = key_store::load().await {
        if let Some(input) = dom::input_by_id("key") {
            input.set_value(&persisted_key);
            events::refresh_keymeta();
        }
    }
    history::load_into_pending().await;
    opfs::refresh().await;

    // Background: try to verify the visitor against the on-chain
    // owner via the apex iframe signer. Fire-and-forget so the
    // chrome paint doesn't block on a ~1-5s roundtrip; the pill
    // updates when the result lands.
    wasm_bindgen_futures::spawn_local(async move {
        kick_verification(name).await;
    });
}

/// Run `verify::verify_owner` for `name` and stash the result. The
/// pill in the chrome reflects whatever lands here. Falls back to
/// the legacy local-OPFS marker if the on-chain check fails.
async fn kick_verification(name: String) {
    let outcome = match verify::verify_owner(&name).await {
        Ok(verify::VerifyResult::VerifiedOwner { address }) => {
            VerifyState::Verified { address }
        }
        Ok(verify::VerifyResult::Visitor {
            owner_address,
            visitor_address,
        }) => VerifyState::Visitor {
            owner_address,
            visitor_address,
        },
        Ok(verify::VerifyResult::Unregistered) => VerifyState::Unregistered,
        Err(err) => VerifyState::Failed { reason: err },
    };
    APP.with(|cell| cell.borrow_mut().verify_state = outcome.clone());
    let html = templates::verify_pill(&outcome).into_string();
    dom::swap_outer("verify-pill", &html);

    // Surface the failure reason somewhere visible — the pill's
    // title tooltip alone isn't discoverable. Log to console too
    // for inspection across reloads.
    if let VerifyState::Failed { reason } = &outcome {
        dom::set_status(&format!("verify failed: {reason}"), true);
        web_sys::console::warn_1(&JsValue::from_str(&format!(
            "lh verify_owner failed: {reason}"
        )));
    }

    // Pricing — per-tenant OPFS, loaded once + stashed for chat send.
    let price = pricing::load().await.unwrap_or(0);
    APP.with(|cell| cell.borrow_mut().pricing_wei = Some(price));
    let is_owner = matches!(outcome, VerifyState::Verified { .. });

    // TBA + owner address — both needed for the agent tab.
    let on_chain = matches!(
        outcome,
        VerifyState::Verified { .. } | VerifyState::Visitor { .. }
    );
    let owner_addr: Option<String> = match &outcome {
        VerifyState::Verified { address } => Some(address.clone()),
        VerifyState::Visitor { owner_address, .. } => Some(owner_address.clone()),
        _ => None,
    };
    let mut tba_opt: Option<String> = None;
    if on_chain {
        if let Ok(Some(tba)) = registry::tba_of_name(&name).await {
            APP.with(|cell| cell.borrow_mut().tba_address = Some(tba.clone()));
            tba_opt = Some(tba);
        }
    }

    // Agent tab: owner + TBA + $LH balance + pricing.
    if let (Some(tba), Some(owner)) = (&tba_opt, &owner_addr) {
        let lh_balance = registry::token_balance_of(tba).await.unwrap_or(0);
        let html =
            templates::financial_card(&name, tba, owner, lh_balance, price, is_owner)
                .into_string();
        dom::swap_outer("financial-slot", &html);
    } else {
        dom::swap_outer(
            "financial-slot",
            r#"<div id="financial-slot" class="financial-empty"></div>"#,
        );
    }
}

/// Paint the apex chrome. Reads (never creates) the master wallet,
/// then renders the unified claim flow. Fresh visitors and returning
/// visitors see the same surface — a name input + create button. The
/// wallet, if it doesn't exist yet, gets generated as a side effect of
/// the user's first claim submit (handled in `run_apex_claim`).
pub(crate) async fn paint_apex(host: tenant::Host) {
    let Ok(doc) = dom::document() else { return };
    let Some(root) = doc.get_element_by_id("root") else { return };

    let wallet = wallet_store::load().await;
    let addr_hex = wallet.as_ref().map(|w| w.address_hex());
    APP.with(|cell| cell.borrow_mut().wallet = wallet);

    root.set_inner_html(
        &templates::apex(&host, addr_hex.as_deref()).into_string(),
    );

    // Pre-fill the claim input + trigger the live-check if the user
    // landed here via `?prefill=<name>` (e.g. from a tenant subdomain's
    // "claim on-chain" CTA, or any external link).
    if let Some(prefill) = read_query_param("prefill") {
        let cleaned = tenant::sanitize(&prefill);
        if !cleaned.is_empty() {
            if let Some(input) = dom::input_by_id("apex-input") {
                input.set_value(&cleaned);
                if let Ok(event) = web_sys::Event::new("input") {
                    let _ = input.dispatch_event(&event);
                }
                let _ = input.focus();
            }
        }
    }

    // Fetch the "your agents" list only when there's an identity to
    // fetch for. On fresh visits the list stays empty — that's expected
    // and the placeholder div in the template covers it.
    if let Some(owner_addr) = addr_hex {
        wasm_bindgen_futures::spawn_local(async move {
            match registry::list_owned_tokens(&owner_addr).await {
                Ok(agents) => {
                    // MAIN lookup is best-effort — facet might not be
                    // cut on a given diamond, in which case the badge
                    // simply doesn't appear.
                    let main_id = registry::main_of(&owner_addr).await.unwrap_or(0);
                    let html = templates::agents_list(&agents, main_id).into_string();
                    dom::swap_outer("agents-list", &html);
                }
                Err(err) => {
                    dom::swap_outer(
                        "agents-list",
                        &format!(
                            r#"<div id="agents-list" class="agents-list"><p class="apex-fine" style="color:var(--error)">couldn't list agents: {err}</p></div>"#
                        ),
                    );
                }
            }
        });
    }
}

/// Paint the minimal signer chrome once we've checked for a wallet.
/// If the apex origin has no wallet yet, render a "no identity" notice
/// instead of conjuring one — the parent subdomain will see signing
/// requests rejected by [`signer::handle_message`] in that case.
pub(crate) async fn paint_signer() {
    let Ok(doc) = dom::document() else { return };
    let Some(root) = doc.get_element_by_id("root") else { return };
    match wallet_store::load().await {
        Some(wallet) => {
            let addr = wallet.address_hex();
            APP.with(|cell| cell.borrow_mut().wallet = Some(wallet));
            root.set_inner_html(&templates::signer_chrome(&addr).into_string());
        }
        None => {
            root.set_inner_html(&templates::signer_no_identity().into_string());
        }
    }

    // Tell the parent we're ready to receive sign requests.
    // The verify-side waits for this ping before posting the challenge
    // — avoids the race where parent posted before the wasm bundle
    // had finished loading + installed its postMessage listener.
    // Sent regardless of wallet presence so the parent can detect
    // "no identity" via the challenge's response error instead of
    // a generic timeout.
    if let Ok(window) = dom::window() {
        if let Ok(Some(parent)) = window.parent() {
            let ready = js_sys::Object::new();
            let _ = js_sys::Reflect::set(
                &ready,
                &JsValue::from_str("type"),
                &JsValue::from_str("lh-signer-ready"),
            );
            // Target "*" — the message carries no sensitive data, only
            // a presence ping. The PARENT enforces origin matching on
            // its receive side (it only accepts replies from
            // SIGNER_ORIGIN).
            let _ = parent.post_message(&ready.into(), "*");
        }
    }
}

/// Format a wei value as a human-readable test-ETH string with up to
/// 6 decimals trimmed. Cosmetic only — the wei integer is what gets
/// signed and submitted on-chain.
pub(crate) fn format_wei_as_test_eth(wei: u128) -> String {
    const WEI_PER_ETH: u128 = 1_000_000_000_000_000_000;
    let whole = wei / WEI_PER_ETH;
    let frac_wei = wei % WEI_PER_ETH;
    if frac_wei == 0 {
        return whole.to_string();
    }
    let frac = (frac_wei * 1_000_000) / WEI_PER_ETH;
    format!("{whole}.{:06}", frac)
        .trim_end_matches('0')
        .trim_end_matches('.')
        .to_string()
}

/// Read a `?key=value` query parameter from the current URL, naive
/// implementation that avoids pulling a URL crate. Returns `None` if
/// the param is missing or empty.
fn read_query_param(key: &str) -> Option<String> {
    let window = dom::window().ok()?;
    let search = window.location().search().ok()?;
    let stripped = search.trim_start_matches('?');
    for pair in stripped.split('&') {
        if let Some((k, v)) = pair.split_once('=') {
            if k == key && !v.is_empty() {
                return Some(decode_uri_component(v));
            }
        }
    }
    None
}

pub(crate) fn decode_uri_component(s: &str) -> String {
    js_sys::decode_uri_component(s)
        .map(|js| js.as_string().unwrap_or_else(|| s.to_string()))
        .unwrap_or_else(|_| s.to_string())
}

/// `true` iff `?signer=1` is in the URL.
fn has_signer_hint() -> bool {
    let Ok(window) = dom::window() else { return false };
    let Ok(search) = window.location().search() else { return false };
    search.contains("signer=1")
}

/// `true` iff `?claim=1` (or `?claim=anything`) is in the URL.
fn has_claim_hint() -> bool {
    let Ok(window) = dom::window() else { return false };
    let Ok(search) = window.location().search() else { return false };
    // search is like "?claim=1" or "" — naive contains() is fine for our flag.
    search.contains("claim=1") || search.contains("claim=true")
}

/// Remove the claim-hint query param without reloading the page. Used
/// once auto-claim succeeds so the URL looks clean.
fn strip_claim_hint() {
    let Ok(window) = dom::window() else { return };
    let Ok(history) = window.history() else { return };
    let url = window.location().pathname().unwrap_or_else(|_| "/".into());
    let _ = history.replace_state_with_url(&JsValue::NULL, "", Some(&url));
}