Ship LNURL mint pages, indexing UI, and reviews rewrite.

Add lnurl list/detail routes, OG fixtures, i18n strings, and the write/
index client flows so the site surfaces the new mint type end to end.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-22 03:44:43 +02:00
co-authored by Cursor
parent 2a9444942b
commit c74c7fc187
66 changed files with 7603 additions and 1241 deletions
+121 -493
View File
@@ -13,6 +13,7 @@
import type { RatingDistribution } from '@cashumints/shared';
import type { ReviewSubject } from '../lib/review-subject';
import { renderBones } from 'boneyard-js';
import WriteForm from './WriteForm.astro';
import reviewsBones from '../bones/reviews-panel.bones.json';
import { BONE_COLOR } from '../lib/skeleton';
import { useI18n } from '../i18n';
@@ -20,8 +21,14 @@ import { formatters } from '../i18n/format';
import { pageLocale } from '../i18n/paths';
interface Props {
/** What is being reviewed: the tags a review carries and the filters that find them. */
subject: ReviewSubject;
/**
* What is being reviewed: the tags a review carries and the filters that find them.
*
* Null on the 404 page alone, which renders this panel for a mint it has not resolved
* yet: the resolver writes the subject onto the root and tells the island to start.
* Every prerendered page passes a real one.
*/
subject: ReviewSubject | null;
/** The counts the API already knows, prerendered so the panel is honest without JS. */
reviewCount: number;
distribution: RatingDistribution;
@@ -43,7 +50,7 @@ const dist = distribution;
* about anything. One key per ecosystem, falling back to the Cashu wording for a type
* whose own has not been written yet.
*/
const placeholderKey = `reviews.dialog.bodyPlaceholder.${subject.type}`;
const placeholderKey = `reviews.dialog.bodyPlaceholder.${subject?.type ?? 'cashu'}`;
const bodyPlaceholder = t.has(placeholderKey)
? t(placeholderKey)
: t('reviews.dialog.bodyPlaceholder.cashu');
@@ -98,7 +105,7 @@ function escapeText(value: string): string {
aria-labelledby="reviews-title"
data-reveal
data-reviews-panel
data-subject={JSON.stringify(subject)}
data-subject={subject ? JSON.stringify(subject) : ''}
>
<div class="panel-head">
<h2 class="panel-title" id="reviews-title">{t('reviews.title')}</h2>
@@ -144,7 +151,15 @@ function escapeText(value: string): string {
target for `pnpm bones`, which snapshots this element's single child.
*/}
<div data-review-list data-review-count={reviewCount}>
<div data-boneyard="reviews-panel" data-boneyard-config='{"excludeSelectors":[".sr-only"]}'>
{/*
`data-boneyard` only when there is something to measure.
`pnpm bones` captures two pages, and the second is a /mint/ URL with no prerendered
page — which now carries this panel too, with no subject and nothing in it. Marked
as a capture target there, it would overwrite the reviews bones taken from the real
mint page a moment earlier with the bones of an empty box.
*/}
<div data-boneyard={subject ? 'reviews-panel' : undefined} data-boneyard-config='{"excludeSelectors":[".sr-only"]}'>
<div data-review-items>
{
showBoneBlocks ? (
@@ -191,8 +206,8 @@ function escapeText(value: string): string {
every page load.
-->
<dialog class="modal write-dialog" data-write-dialog aria-labelledby="write-title">
<form method="dialog" class="modal-body write-form" data-write-form novalidate>
<div class="modal-head">
<WriteForm locale={locale} placeholder={bodyPlaceholder}>
<div class="modal-head" slot="head">
{/* The mint's name is data, dropped into a translated sentence. */}
<h2 id="write-title">{t('reviews.dialog.title', { name })}</h2>
<button type="button" class="copy-btn" data-write-close aria-label={t('common.close')}>
@@ -201,52 +216,7 @@ function escapeText(value: string): string {
</svg>
</button>
</div>
<p class="modal-note">{t('reviews.dialog.note')}</p>
<div class="write-id" data-write-identity></div>
<fieldset class="star-picker" data-star-picker>
<legend class="sr-only">{t('reviews.dialog.ratingLegend')}</legend>
{
[1, 2, 3, 4, 5].map((value) => (
<label class="star-option">
<input type="radio" name="rating" value={value} required />
<span aria-hidden="true">★</span>
<span class="sr-only">{t('reviews.dialog.starsSr', { n: value })}</span>
</label>
))
}
<span class="star-label" data-star-label aria-live="polite">
{t('reviews.dialog.chooseRating')}
</span>
</fieldset>
<label class="write-label" for="review-body">{t('reviews.dialog.bodyLabel')}</label>
<div class="write-box">
<textarea
id="review-body"
name="content"
rows="5"
aria-describedby="review-count"
placeholder={bodyPlaceholder}></textarea>
<span class="write-count" id="review-count" data-char-count>
{t('reviews.dialog.charCount', { n: 0, max: 2000 })}
</span>
</div>
<p class="write-status" data-write-status hidden role="status"></p>
<p class="write-error" data-write-error hidden role="alert"></p>
<div class="write-actions">
<span class="write-why" data-write-why></span>
<button type="button" class="btn" data-write-close>{t('common.cancel')}</button>
<button type="submit" class="btn primary" data-write-submit disabled>
<span class="write-spinner" aria-hidden="true"></span>
<span data-write-submit-label>{t('reviews.dialog.submit')}</span>
</button>
</div>
</form>
</WriteForm>
</dialog>
<style>
@@ -263,13 +233,11 @@ function escapeText(value: string): string {
import { useI18n } from '../i18n/client';
import { formatters } from '../i18n/format';
import { enterStagger, onReady, prefersReducedMotion, scrollBehavior } from '../scripts/reveal';
import { getExtension, loadProfiles, loadReviews, publishReview, safeNpub, type LoadedReview } from '../lib/reviews-client';
import { loadProfiles, loadReviews, safeNpub, type LoadedReview } from '../lib/reviews-client';
import type { ReviewSubject } from '../lib/review-subject';
import { activeSigner, onSessionChange, onSignerStatus, readSession, signerErrorMessage } from '../lib/nostr-signer';
// The identity row is the same identity the header shows, drawn by the same helpers.
import { avatarMarkup, identityLines, methodChipHtml, openLogin, resolveProfile, toNpub } from '../lib/auth-ui';
import { closeModal, openModal, wireModal } from '../lib/modal';
import type { Profile } from '@cashumints/shared';
// Stars, body, identity row, draft safety and publishing: shared with the
// review-by-URL dialog the index pages open, so there is one signing flow on the site.
import { takeFreshReview, wireWriteForm } from '../lib/write-review';
// The card itself lives in one place: /reviews renders the identical markup from
// the same functions.
import {
@@ -334,6 +302,12 @@ function escapeText(value: string): string {
const expected = Number(listWrap.dataset['reviewCount'] ?? '0');
let all: LoadedReview[] = [];
/**
* A review published from the review-by-URL dialog on an index page, on its way
* here. Read once, at setup, so a reload does not resurrect it; merged in below
* when the relays answer. See `takeFreshReview`.
*/
const handedForward = takeFreshReview(subject.key);
let filter: 'all' | '5' | 'critical' | 'recent' = 'all';
let page = 1;
/**
@@ -619,13 +593,37 @@ function escapeText(value: string): string {
// coming back for a mint the API counted reviews for means the relays did not
// answer, not that the reviews are gone. Saying "no reviews yet" there would
// be a lie the reader cannot check.
if (loaded.length === 0 && expected > 0) {
if (loaded.length === 0 && expected > 0 && !handedForward) {
showRelayError();
return;
}
all = loaded;
/*
* The author arrived here from the review-by-URL dialog moments ago. Their own
* review is on the relays but may not be in this answer yet, so it goes to the
* top as an optimistic card — the same card, with the same "propagating" note,
* that they would have seen had they written it on this page.
*/
if (handedForward && !all.some((review) => review.id === handedForward.id)) {
optimistic.set(handedForward.id, {
accepted: handedForward.accepted,
total: handedForward.total,
});
all.unshift({
id: handedForward.id,
pubkey: handedForward.pubkey,
npub: safeNpub(handedForward.pubkey),
created_at: handedForward.created_at,
rating: handedForward.rating,
content: handedForward.content,
mint_url: subject.key,
profile: null,
});
markNewest = true;
}
/*
* A `#review-{id}` deep link: land on the page that holds the card, then
* scroll to it once it is drawn. A shared link wins over the reader's own
@@ -731,451 +729,81 @@ function escapeText(value: string): string {
/* ---------- write a review ---------- */
/*
* The form itself is `lib/write-review.ts`, which the review-by-URL dialog on the
* index pages drives too. What stays here is the half that is about *this list*:
* putting the fresh review at the top, resetting the filters so it is visible, and
* pulsing the button that opened the dialog.
*/
const dialog = document.querySelector<HTMLDialogElement>('[data-write-dialog]');
const form = dialog?.querySelector<HTMLFormElement>('[data-write-form]');
const errorBox = dialog?.querySelector<HTMLElement>('[data-write-error]');
const statusBox = dialog?.querySelector<HTMLElement>('[data-write-status]');
const submit = dialog?.querySelector<HTMLButtonElement>('[data-write-submit]');
const submitLabel = dialog?.querySelector<HTMLElement>('[data-write-submit-label]');
const textarea = dialog?.querySelector<HTMLTextAreaElement>('textarea');
const charCount = dialog?.querySelector<HTMLElement>('[data-char-count]');
const starLabel = dialog?.querySelector<HTMLElement>('[data-star-label]');
const identityRow = dialog?.querySelector<HTMLElement>('[data-write-identity]');
const whyDisabled = dialog?.querySelector<HTMLElement>('[data-write-why]');
/** Reviews are capped at this length, and the counter turns red past it. */
const MAX_CHARS = 2000;
/** Below this a review says nothing anyone can use. */
const MIN_CHARS = 10;
// One phrase per star, in the reader's words rather than the site's. Shown as
// "2: had problems" beside the picker, and read out by the live region.
// One phrase per star, from the catalog. Read through `t` rather than held in a
// table here, so the phrases follow the page's language after a view transition.
const starPhrase = (rating: number): string => t(`reviews.dialog.stars.${rating}`);
/** True once a publish is in flight, so nothing else runs over it. */
let publishing = false;
/** Who is going to sign, resolved when the dialog opens. */
let canSign = false;
function showError(message: string): void {
if (!errorBox) return;
errorBox.textContent = message;
errorBox.hidden = message === '';
}
function showStatus(message: string): void {
if (!statusBox) return;
statusBox.textContent = message;
statusBox.hidden = message === '';
}
/* ---------- draft safety ---------- */
/**
* A written review survives an accidental close.
*
* sessionStorage, keyed by mint: it lasts as long as the tab and no longer, which
* is the life of the intention. Nothing here is secret, but nothing here belongs
* on the machine a week later either.
*/
const DRAFT_KEY = `cashumints:draft:${subject.key}`;
function saveDraft(): void {
if (!form) return;
const data = new FormData(form);
const rating = String(data.get('rating') ?? '');
const content = String(data.get('content') ?? '');
try {
if (!rating && !content.trim()) sessionStorage.removeItem(DRAFT_KEY);
else sessionStorage.setItem(DRAFT_KEY, JSON.stringify({ rating, content }));
} catch {
// Storage blocked. The draft lives in the open dialog and nowhere else.
}
}
function clearDraft(): void {
try {
sessionStorage.removeItem(DRAFT_KEY);
} catch {
// Nothing stored to begin with.
}
}
function restoreDraft(): void {
if (!form) return;
let draft: { rating?: string; content?: string } | null = null;
try {
const raw = sessionStorage.getItem(DRAFT_KEY);
draft = raw ? (JSON.parse(raw) as { rating?: string; content?: string }) : null;
} catch {
draft = null;
}
if (!draft) return;
if (draft.rating) {
const star = form.querySelector<HTMLInputElement>(`input[name="rating"][value="${draft.rating}"]`);
if (star) star.checked = true;
}
if (draft.content && textarea) textarea.value = draft.content;
syncStarLabel();
syncCount();
}
/* ---------- the form's own state ---------- */
function chosenRating(): number {
return Number(new FormData(form ?? undefined).get('rating') ?? 0);
}
function syncStarLabel(): void {
if (!starLabel) return;
const rating = chosenRating();
starLabel.textContent = rating
// The rating is a bare numeral here, so it is passed as a string: "3: good",
// never "3,0: good".
? t('reviews.dialog.starLabel', { n: String(rating), phrase: starPhrase(rating) })
: t('reviews.dialog.chooseRating');
}
function syncCount(): void {
const length = textarea?.value.length ?? 0;
if (charCount) {
charCount.textContent = t('reviews.dialog.charCount', { n: length, max: MAX_CHARS });
charCount.classList.toggle('over', length > MAX_CHARS);
}
syncSubmit();
}
/**
* Whether "Sign and publish" is available, and if not, why.
*
* The reason is always on screen: a button that is off for an unstated reason is
* a button people click twice and then leave.
*/
function syncSubmit(): void {
if (!submit) return;
const length = textarea?.value.trim().length ?? 0;
const rating = chosenRating();
const over = (textarea?.value.length ?? 0) - MAX_CHARS;
const reason = publishing
? ''
: !canSign ? t('reviews.dialog.why.login')
: !rating ? t('reviews.dialog.why.rating')
: length === 0 ? t('reviews.dialog.why.words')
: length < MIN_CHARS ? t('reviews.dialog.why.more', { n: MIN_CHARS - length })
: over > 0 ? t('reviews.dialog.why.over', { n: over })
: '';
submit.disabled = publishing || reason !== '';
if (whyDisabled) whyDisabled.textContent = publishing ? '' : reason;
}
/** The publish button, mid-flight or back to rest. */
function setBusy(busy: boolean, label = t('reviews.dialog.submit')): void {
publishing = busy;
submit?.classList.toggle('is-busy', busy);
if (submitLabel) submitLabel.textContent = label;
syncSubmit();
}
/* ---------- who is signing ---------- */
/**
* Draw the identity row, and decide whether publishing is possible at all.
*
* Three states, in the order they are likely:
* - logged in: the session's signer, named, with a way to switch
* - no session but a NIP-07 extension: quick-sign, exactly as before logins
* existed, and no session is created by using it
* - neither: the row becomes the way in, and the publish button says so
*
* The extension's pubkey is asked for here, on open, and never on page load: it
* is a question some extensions put to the reader, and a page that asks it for
* every visitor who might one day write a review is a page that nags.
*/
async function renderIdentity(): Promise<void> {
if (!identityRow) return;
const session = readSession();
if (session) {
// A session that names an extension is only as good as the extension: it can
// be uninstalled, or disabled for this site, between one visit and the next.
canSign = session.method !== 'extension' || getExtension() !== null;
syncSubmit();
if (!canSign) {
identityRow.innerHTML =
`<span class="write-id-who"><span class="write-id-lead">${escapeHtml(
t('reviews.dialog.identity.extensionGone'),
)}</span></span>` +
`<button type="button" class="btn" data-login-open>${escapeHtml(
t('login.open'),
)}</button>`;
return;
}
const npub = await toNpub(session.pubkey);
const draw = (profile: Profile | null): void => {
const { primary, secondary, named } = identityLines(profile, npub, t);
// Whichever of the two lines is the npub is the machine-flavoured one, and
// that is the one set in mono (DESIGN.md). The other is a name or a phrase.
identityRow.innerHTML =
avatarMarkup(session.pubkey, profile, npub, 34) +
`<span class="write-id-who">` +
`<span class="write-id-name${named ? '' : ' mono'}">${escapeHtml(primary)}</span>` +
`<span class="write-id-sub${named ? ' mono' : ''}">${escapeHtml(secondary)}</span></span>` +
methodChipHtml(session.method, t) +
`<button type="button" class="write-id-switch" data-login-open>${escapeHtml(
t('reviews.dialog.identity.switch'),
)}</button>`;
};
// Drawn from the npub first, then again if a name turns up: the row is the
// same height either way, so nothing under it moves when the name lands.
draw(null);
const profile = await resolveProfile(session.pubkey);
if (readSession()?.pubkey !== session.pubkey || !profile?.found) return;
draw(profile);
return;
}
if (!getExtension()) {
canSign = false;
syncSubmit();
identityRow.innerHTML =
`<span class="write-id-who"><span class="write-id-lead">${escapeHtml(
t('reviews.dialog.identity.needSigner'),
)}</span></span>` +
`<button type="button" class="btn" data-login-open>${escapeHtml(
t('reviews.dialog.identity.loginToPublish'),
)}</button>`;
return;
}
canSign = true;
syncSubmit();
identityRow.innerHTML =
`<span class="write-id-who"><span class="write-id-name">${escapeHtml(
t('reviews.dialog.identity.extension'),
)}</span>` +
`<span class="write-id-sub">${escapeHtml(
t('reviews.dialog.identity.readingKey'),
)}</span></span>` +
methodChipHtml('extension', t);
let pubkey = '';
try {
pubkey = (await getExtension()?.getPublicKey()) ?? '';
} catch {
// Declined, or the extension is locked. Publishing still works: it will ask
// again at signing time, which is the request that actually matters.
}
if (!/^[0-9a-f]{64}$/i.test(pubkey)) {
const sub = identityRow.querySelector('.write-id-sub');
if (sub) sub.textContent = t('reviews.dialog.identity.willAsk');
return;
}
const npub = await toNpub(pubkey);
const profile = await resolveProfile(pubkey);
const { primary, named } = identityLines(profile, npub, t);
identityRow.innerHTML =
avatarMarkup(pubkey, profile, npub, 34) +
`<span class="write-id-who">` +
`<span class="write-id-name${named ? '' : ' mono'}">${escapeHtml(primary)}</span>` +
`<span class="write-id-sub">${escapeHtml(
t('reviews.dialog.identity.extension'),
)}</span></span>` +
methodChipHtml('extension', t);
}
/* ---------- opening and closing ---------- */
if (dialog) {
wireModal(dialog, {
// A written review is not thrown away on a stray click outside the box.
onBackdrop: () =>
(textarea?.value.trim().length ?? 0) === 0 ||
window.confirm(t('reviews.dialog.confirmClose')),
onClose: () => {
saveDraft();
showStatus('');
setBusy(false);
const writer = wireWriteForm({
dialog,
subject: () => subject,
onProfile: (pubkey, profile) => {
// The relays answer the publish before the kind-0 lookup answers, near enough
// every time, so the card goes up as "Anon" and the author's name cross-fades
// in a moment later, exactly as it does on every other card.
for (const review of all) if (review.pubkey === pubkey) review.profile = profile;
patchProfiles();
},
onPublished: (published) => {
// Optimistic insert at the top. The relays will echo it back on next load.
optimistic.set(published.id, { accepted: published.accepted, total: published.total });
all.unshift({
id: published.id,
pubkey: published.pubkey,
npub: safeNpub(published.pubkey),
created_at: published.created_at,
rating: published.rating,
content: published.content,
mint_url: subject.key,
profile: null,
});
filter = 'all';
page = 1;
for (const b of root!.querySelectorAll('[data-filter]')) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-filter') === 'all'));
}
markNewest = true;
render();
updateCounts();
// The dialog is about to close, so the confirmation belongs on the button that
// opened it: one amber pulse, on success only.
const trigger = document.querySelector<HTMLElement>('[data-write-open]');
if (trigger && !prefersReducedMotion()) {
trigger.classList.remove('did-it');
void trigger.offsetWidth;
trigger.classList.add('did-it');
trigger.addEventListener('animationend', () => trigger.classList.remove('did-it'), { once: true });
}
},
});
// The signer talks back while a signature is in flight: reconnecting, waiting,
// approve-in-another-window. Mirrored into the dialog so the reader is never
// watching a spinner with nothing behind it.
onSignerStatus((update) => {
if (dialog.open && publishing) showStatus(update.message);
});
// Logging in from the identity row redraws it, without closing this dialog.
onSessionChange(() => {
if (dialog.open) void renderIdentity();
});
}
textarea?.addEventListener('input', () => {
syncCount();
saveDraft();
});
form?.addEventListener('change', (event) => {
if ((event.target as HTMLInputElement).name !== 'rating') return;
syncStarLabel();
syncSubmit();
saveDraft();
});
for (const button of document.querySelectorAll('[data-write-close]')) {
button.addEventListener('click', () => closeModal(dialog!));
}
for (const trigger of document.querySelectorAll('[data-write-open]')) {
trigger.addEventListener('click', () => {
if (!dialog) return;
showError('');
showStatus('');
setBusy(false);
restoreDraft();
openModal(dialog);
void renderIdentity();
// Somewhere to land that is not the close button: the rating is the first
// thing to decide, and a checked star keeps its own place in the group.
const checked = form?.querySelector<HTMLInputElement>('input[name="rating"]:checked');
(checked ?? form?.querySelector<HTMLInputElement>('input[name="rating"]'))?.focus();
});
}
/* ---------- publishing ---------- */
form?.addEventListener('submit', async (event) => {
event.preventDefault();
if (!form || !submit || publishing) return;
const rating = chosenRating();
const content = textarea?.value ?? '';
const signer = activeSigner();
if (!signer) {
// No session and no extension. The row already says so; open the way in.
openLogin(submit);
return;
for (const button of dialog.querySelectorAll('[data-write-close]')) {
button.addEventListener('click', () => writer.close());
}
showError('');
setBusy(true, t('reviews.dialog.signing'));
showStatus(
signer.method === 'extension'
? t('reviews.dialog.waitingExtension')
: t('reviews.dialog.waitingSigner'),
);
try {
/*
* The author's own kind 0, fetched while the relays are still taking the
* review. Their pubkey is only knowable once the event is signed, so
* `onSigned` starts this at the earliest moment there is: by the time the
* card below is inserted the name is usually already in hand, and the author
* does not watch their own review appear as "Anon" until the next reload.
*
* `refresh` skips the cache deliberately. Anyone who has reviewed before may
* be sitting on an hour-old negative entry for their own key, and they are
* exactly the reader who would notice their name missing.
*/
let profileLookup: Promise<Profile | null> | null = null;
const result = await publishReview({
signer,
subject,
rating,
content,
onSigned: (signed) => {
setBusy(true, t('reviews.dialog.publishing'));
profileLookup = loadProfiles([signed.pubkey], { refresh: true })
.then((profiles) => profiles.get(signed.pubkey) ?? null)
.catch(() => {
// Relays unreachable for the lookup. The card keeps "Anon" over its
// npub, which is what every other unresolved card shows.
return null;
});
},
onProgress: (accepted, _settled, total) => {
showStatus(t('reviews.dialog.publishingTo', { accepted, total }));
},
});
// Optimistic insert at the top. The relays will echo it back on next load.
optimistic.set(result.event.id, { accepted: result.accepted, total: result.total });
all.unshift({
id: result.event.id,
pubkey: result.event.pubkey,
npub: safeNpub(result.event.pubkey),
created_at: result.event.created_at,
rating,
content: content.trim(),
mint_url: subject.key,
profile: null,
});
filter = 'all';
page = 1;
for (const b of root!.querySelectorAll('[data-filter]')) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-filter') === 'all'));
}
markNewest = true;
render();
updateCounts();
// The dialog is about to close, so the confirmation belongs on the button
// that opened it: one amber pulse, on success only.
const trigger = document.querySelector<HTMLElement>('[data-write-open]');
if (trigger && !prefersReducedMotion()) {
trigger.classList.remove('did-it');
void trigger.offsetWidth;
trigger.classList.add('did-it');
trigger.addEventListener('animationend', () => trigger.classList.remove('did-it'), { once: true });
}
// The relays answer the publish before the lookup answers, near enough every
// time, so the card goes up as "Anon" and the author's name cross-fades in a
// moment later, exactly as it does on every other card.
void (profileLookup as Promise<Profile | null> | null)?.then((profile) => {
if (!profile?.found) return;
for (const review of all) {
if (review.pubkey === result.event.pubkey) review.profile = profile;
}
patchProfiles();
});
clearDraft();
form.reset();
syncStarLabel();
syncCount();
setBusy(false);
showStatus('');
closeModal(dialog!);
} catch (err) {
// Back to an editable dialog with everything still in it. A declined
// signature is not an error the reader made, and a relay that would not take
// it is worth another press of the same button.
setBusy(false);
showStatus('');
const message = err instanceof Error ? err.message : String(err);
showError(
message === 'no-relay'
? t('reviews.dialog.error.noRelay')
: signerErrorMessage(err, t),
);
for (const trigger of document.querySelectorAll('[data-write-open]')) {
trigger.addEventListener('click', () => writer.open());
}
});
}
}
};
onReady(setup);
/*
* The 404 resolver renders this panel for a mint that had no subject when the page was
* built, and dispatches this once it has one. Setup runs again and finds it.
*
* Safe to re-run: the first pass with no subject does nothing at all — every listener
* and every fetch is inside the `if (root && parsedSubject)` guard — so nothing is
* bound twice.
*/
document.addEventListener('cashumints:subject', setup);
</script>