Two small fit-and-finish tweaks the previous restyle missed:
1. The tab strip's baseline border sat 1px above the rounded panel's top
edge, so the panel's rounded corners visually overlapped the active
tab's under-rule. TabsContent now carries an explicit mt-4 (16px) so
the panel floats cleanly under the tab strip with breathing room
above its rounded top corners.
2. Tab labels were spaced gap-1 (4px), too tight for labels that read
as section headers. Bumped to gap-8 (32px) so 'Comments & donations'
and 'Ledger' read as separate section titles.
CommentsSection's outer mt-4 is overridden with className=mt-0 at the
call site so the TabsContent gap controls the spacing — no more 32px
double-margin.
The shadcn default 'muted pill control' tab strip felt bolted onto the page
between the action bar and the comments panel. The tab labels also doubled
up with the 'Comments & donations' heading rendered inside the panel — two
section titles stacked.
Replaces the pill control with an underline-style tab strip that visually
serves as the section header for the panel below. The active tab label is
rendered at the same size and weight as the page's other h2 section
headings (text-lg font-semibold tracking-tight), inactive tabs are muted
siblings, and a 1px baseline border carries across the panel width while
the active tab paints a thicker primary under-rule that flows into the
content surface.
CommentsSection's title becomes optional and the campaign page omits it
now that the tab label owns the heading. The dead 'commentsAndDonations'
i18n key is removed from all 16 locales (tabComments carries the same
copy).
Flips the headline + subline on each ledger row: USD is now the prominent
top number and the BTC equivalent (full units, not sats) sits below it in
muted text. Falls back to BTC-as-headline when the BTC/USD price isn't
available yet. Renames the i18n leaf 'campaignsDetail.ledger.satsUnit' to
'btcUnit' across en.json and all 15 other locales — BTC is a universal
ticker, so the unit string itself is now 'BTC' everywhere.
Moves the existing comments + donations panel under a 'Comments & donations'
tab and introduces a sibling 'Ledger' tab that surfaces public on-chain
activity for the campaign's bc1 address — receives, sends, confirmation
status, block height, and the equivalent USD — sourced from the
already-configured Esplora endpoints with mempool.space deep links per row.
The Ledger tab is rendered only when the campaign declares an on-chain
endpoint ('bc1q…' / 'bc1p…'). Silent-payment-only campaigns intentionally
have no scannable address and degrade to the un-tabbed comments surface to
avoid showing a lone disabled tab.
A new fetchAddressTxs helper in src/lib/bitcoin.ts wraps Esplora's
/address/:addr/txs + /chain/:last_seen_txid pagination, and useAddressLedger
exposes it as an infinite query (50 confirmed tx page size, plus mempool on
the first page).
Sits in the Bitcoin Donations chapter right after 'Are donations on
{{appName}} public?', so the natural flow goes from how donations
work to where they show up to why some take a while to confirm.
Translated into all sixteen locales.
The campaign detail already verifies each donation receipt on-chain
and knows whether the underlying Bitcoin tx is confirmed, but the
flag was being dropped before it reached the UI. Mirror the wallet's
treatment so visitors can see funds that are inbound but still in
the mempool.
Three changes:
* Extract PendingBadge (orange + spinning RefreshCw) and use it on
both the wallet headline and the campaign donate column. The
wallet's inline JSX is replaced with the shared component.
* Widen useCampaignDonations to expose pendingSats (the mempool
delta from Esplora) and a confirmedByTxid lookup built from the
already-verified receipts.
* DonateColumn now renders the pending sats under the raised total
and DonorPreviewList swaps the relative timestamp for the badge
on any row whose underlying tx is still unconfirmed.
CreateActionPage, CreateCommunityPage, and CreateEventPage each
showed a dead-end 'Back to pledges' / 'Back to groups' / 'Back to
events' link as their only logged-out CTA. Mirror the campaign-gate
fix and render LoginArea instead so visitors can join or log in
without leaving the page, and drop the three now-unused i18n keys
from every locale.
Regression-of: 2870d7a6
The gate previously offered only a 'Go home' link, leaving visitors
to find the login flow themselves. Render the existing LoginArea CTA
in its place so the 'Join' / 'Log in' modal opens directly from the
gate, and drop the now-unused campaignsCreate.goHome string from all
sixteen locales.
Wraps the title/toolbar row with flex-col on mobile so the search input,
filter dropdown, and country picker no longer cram next to the section
title at narrow widths. The toolbar also lets the search input grow to
fill the row on mobile (flex-1) while keeping its compact 16rem width
from the sm breakpoint up.
Applies to /groups, /pledges, and /campaigns/all — every place the
DiscoverySearchToolbar sits on a section heading row.
The brand mark in the top nav was rendered in the default UI font
(Inter Bold at text-lg). Swap it for the same typographic recipe the
CampaignsPage hero uses — Bebas Neue (font-display), uppercase, tracked,
with a 0.022em currentColor text-stroke that fattens the weight-400
letterforms without the fuzz a synthetic-bold would produce. The size
is bumped to text-3xl and the bolt icon to size-9 so the wordmark reads
as a deliberate logo lockup instead of a piece of UI chrome.
Two deviations from the hero recipe, tuned for the nav-bar context:
- The hero uses Bebas Neue's native italic (~12° skew). Here we
render the roman face and apply a softer skewX(-6deg) transform.
Sharp italics at nav-bar size start to read as a glitch rather
than personality; a gentle oblique keeps the family identity
without sacrificing legibility next to the four right-leaning
nav links.
- A scaleX(1.1) widens the letterforms slightly. Bebas is naturally
tall+narrow, which gets visually crowded at this size next to the
chunky bolt mark — the horizontal scale rebalances the proportions
so the wordmark sits as a peer to the icon instead of receding.
transform-origin: 0 100% anchors the skew to the baseline so the icon-
to-text relationship stays stable, and -ml-0.5 tucks the wordmark
tight against the bolt to read as a single lockup.
While here, change the active page indicator in the same nav from
text-foreground (white in dark / black in light) to text-primary so
the current page reads in brand orange. The mobile drawer's active
state already had a bg-primary/10 wash but kept text-foreground on
top; switch its text to text-primary as well so the orange wash and
the orange label reinforce each other instead of fighting.
Every caller wraps the grid in a `max-w-7xl mx-auto px-4 sm:px-6`
page container that already supplies the page-edge gutter — matching
the Campaigns and Pledges discovery pages. CommunityGrid then added a
second `px-4 sm:px-6` of its own, which every caller defensively
overrode with `className="px-0"`. The override only neutralized the
base utility: tailwind-merge happily kept the responsive variant, so
the `sm:px-6` survived. The result was group cards inset 24px further
than campaign and pledge cards at every `sm` breakpoint and up.
Drop the internal padding from CommunityGrid and remove the redundant
`px-0` overrides on the eleven CommunitiesPage callers. The page
container is now the single source of truth for horizontal padding on
all three discovery surfaces.
A community is its own scope — narrowing groups by country isn't a
useful axis the way it is for campaigns and pledges (which are about
local action). Drop the country prop from CommunitiesPage's
DiscoverySearchToolbar call so the Globe button doesn't render at
all there. Campaigns and Pledges keep theirs.
The pledges page already had a Globe-icon country popover sitting next
to a sort dropdown and the relay-driven search toolbar. The three
side-by-side filter affordances duplicated each other and pulled focus
away from the section heading. Collapse the cluster into the shared
DiscoverySearchToolbar:
- Drop the page-local Recent / Bounty / Deadline sort dropdown on
pledges. The toolbar's Default / Top / New sort, backed by NIP-50,
already covers the same ground and applies to every discovery
surface; pledges fall through to a newest-first chronological view
inside each lifecycle section.
- Add the country picker as a first-class prop on
DiscoverySearchToolbar (`country` + `onCountryChange`) so all
three discovery pages share the same Globe button without a generic
trailing-slot escape hatch. Brand-orange icon in the neutral state,
country flag emoji when selected.
- Extend useNip50Search with an optional `iTags` array, forwarded as
a standard `#i` filter alongside the `search` field. A non-empty
iTags array also activates the hook so picking a country with no
typed query produces a country-scoped grid, narrowing kind 34550 /
36639 by NIP-73 external identifier.
- Extend useAllCampaigns with a `countryCode` parameter that adds
the same `#i` filter to the relay query. AllCampaignsPage tracks
the country on the URL alongside the existing `sort` and `q`
params so country-scoped views are linkable.
- Brand-orange the ListFilter icon on the toolbar to match the
pledges-page aesthetic the user wanted to spread to the other
discovery pages.
The DiscoverySearchToolbar (search input + sort + show-hidden) was
duplicating affordances the dedicated /campaigns/all page already
provides. The home page is now a curated landing: hero, featured row,
community grid, and moderator/creator sections.
The three discovery pages (Campaigns, Pledges, Communities) already
pass sortMode and getKeywordHaystack to useNip50Search, but the hook
only supported 'top' / 'new' and did no client-side keyword filter —
so 'default' fell back to 'new' (a chronological feed of the entire
kind on first paint, not the curated layout the page actually wants)
and queries for kinds whose human-readable title lives in tags
(33863 campaigns, 34550 organizations, 36639 pledges) silently
returned nothing whenever the relay's NIP-50 index only indexed
'content'.
Extend Nip50Sort with a 'default' variant: empty-query + 'default'
deactivates the hook so the page renders its featured/curated layout,
while a typed query still issues a chronological search. 'top' and
'new' keep their existing semantics; 'new' now also drives an empty-
query chronological feed (previously only 'top' could). Plumb the
new option through DiscoverySearchToolbar as a LayoutGrid-iconed
segmented button and adjust the active-filter detector.
Add an optional getKeywordHaystack callback that lets callers supply
per-event strings (title / name / summary tag values + content) for
a case-insensitive substring re-filter over the relay response. This
costs a small amount of recall (we still rely on the relay to surface
the candidate) but fixes the 'search returns nothing' failure mode
for tag-titled kinds without changing the relay protocol or pinning
to a different index.
Regression-of: c61e9a06
The moderation menu / overlay / review-queue refactor wired pledges
into the same shared moderation components as campaigns and groups,
and three call sites (ActionsPage, ModerationMenu, ModerationOverlay)
imported usePledgeModeration from @/hooks/usePledgeModeration. The
hook file itself was never staged, so the tree didn't build and any
fresh clone would have failed tsc at those imports.
Add the hook (two-axis model — hide + featured, no approval gate, same
agora.moderation namespace and Team Soapbox moderator pack as
campaigns/organizations) and document the kind 36639 surface in
NIP.md alongside the existing 33863 / 34550 entries so the spec
matches the implementation.
Regression-of: c61e9a06
The /pledges page introduces large bold section headers above its grid
("text-2xl sm:text-3xl font-bold tracking-tight" + muted tagline +
right-side controls). /campaigns/all had no section heading at all, and
/groups was using the smaller SectionHeader pattern ("text-base
font-semibold") for its 'My groups' and 'Featured groups' shelves —
even though its own search-mode branch already used the large-bold
pattern. Three pages, three different section-header treatments.
Aligns both pages with the /pledges style:
- /campaigns/all: add a heading above the grid that switches between
Search / Top / New labels based on toolbar state, with a constant
tagline ("Browse every cause on the network").
- /groups: replace the SectionHeader usages on the My groups / Featured
groups shelves with the same large-bold heading + tagline pattern;
drop the now-unused SectionHeader import. Inner shelves
(CommunityGrid, EmptyShelf, show-more) get their internal
px-4 sm:px-6 stripped since the outer container now provides it.
- Moderator review sections use the same px-0 grid + collapsed trigger
padding inside the new wrapper.
/ (Index → Feed) keeps tabs as its visual navigation, so it doesn't
need a heading — that exception is the rule that proves the pattern.
ActionCard rendered two kebabs side-by-side: ActionShareMenu (Copy link +
owner Delete) and the shared ModerationOverlay's mod-only kebab. The two
buttons were also styled differently — the share menu had no banner
backdrop, so on photo-heavy cards it floated against the image while
the moderator kebab next to it sat in a translucent pill.
Extract ModerationMenuItems from ModerationMenu — the dropdown rows
themselves (label + items) without the trigger/content wrapper. Standalone
ModerationMenu and ModerationOverlay still work the same; cards that need
to embed moderator actions inside an existing dropdown now compose them
directly:
<DropdownMenuContent>
<DropdownMenuItem onClick={copy}>Copy link</DropdownMenuItem>
{isOwner && <DropdownMenuItem onClick={del}>Delete</DropdownMenuItem>}
{isMod && <DropdownMenuSeparator />}
<ModerationMenuItems coord={…} surface="pledge" axes={…} entityTitle={…} />
</DropdownMenuContent>
ModerationOverlay grows a showMenu prop (default true) so a card can still
get the mod-gated 'Hidden' badge in its banner corner without the
redundant second kebab when the moderation items have been embedded in
another dropdown.
Then on ActionCard:
- ActionShareMenu absorbs ModerationMenuItems under a separator, so the
card carries exactly one kebab.
- Its trigger now uses the same 'h-8 w-8 bg-background/80 backdrop-blur
text-muted-foreground hover:text-foreground' classes as the campaign
and group card kebabs so the pill is consistent across surfaces.
- The mod-only 'Hidden' badge still renders, via ModerationOverlay with
showMenu={false}.
- Adds a generic pledges.card.actionsAriaLabel ('Pledge actions')
aria-label since the kebab is no longer just for sharing — translated
into all 15 non-English locales.
Campaigns, pledges, and groups each had their own moderation kebab,
'Hidden' badge, and collapsible review sections. The three implementations
had drifted: the campaign card inlined the badge + menu while the pledge
and group cards used per-surface overlay wrappers, the pledge cards
imported the kebab without its 'Hidden' badge (so hidden pledges showed
the kebab but no visible status), pledges had no page-level Pending /
Hidden moderator queues at all, and each page reimplemented the
collapsible review section as a local component.
Consolidate everything into src/components/moderation/:
HiddenBadge — the shared destructive chip with EyeOff + 'Hidden'.
ModerationMenu — the shared kebab with an `axes` config prop, so
campaigns can request ['approval', 'hide', 'featured'] and pledges /
groups can request ['hide', 'featured']. Per-surface inner components
each mount exactly one moderation hook so a pledge card never
subscribes to the campaign label query (and vice versa).
ModerationOverlay — the absolutely-positioned wrapper that bundles
HiddenBadge + ModerationMenu in a card corner. Same gating contract:
returns null for non-moderators.
ModeratorCollapsibleSection — the shared 'Pending / Hidden' collapsible
used by CampaignsPage, CommunitiesPage, and now ActionsPage.
Migrate the three card surfaces and three index pages onto these shared
pieces, fixing two concrete bugs along the way:
ActionCard now renders a 'Hidden' badge on hidden pledges. Previously
it imported PledgeModerationMenu directly (kebab only) instead of
PledgeModerationOverlay (badge + kebab), so moderators saw the
kebab but had no inline cue that a card was hidden.
ActionsPage now has page-level Pending and Hidden moderator review
queues at the bottom, mirroring the queues already present on
CampaignsPage and CommunitiesPage. Pledges share the two-axis model
with groups, so 'Pending' here means 'no curation decision yet' —
pledges not yet featured or hidden.
useActions grows an optional `enabled` flag so the page can skip the
moderator-only review-queue query entirely for non-moderators rather
than firing it with limit:0.
Locale work: add the new moderation namespace and the pledges.list
needsReview / hidden / *Desc / *Empty keys to en.json and all 15
non-English locales (ar, es, fa, fr, hi, id, km, ps, pt, ru, sn, sw,
tr, zh, zh-Hant).
Build a single search affordance shared by Campaigns home, All Campaigns,
Communities, and Pledges so the four discovery surfaces stop having
four different toolbars (or none at all).
New shared pieces
-----------------
DebouncedSearchInput renders a controlled shadcn Input with the lucide
Search icon on the left and a clear (X) button on the right that
appears once the user has typed something. Rounded-square corners
(rounded-lg, matching the global SearchPage). Stateless — the caller
pairs it with useDebounce and feeds the debounced value into a query
hook so the same component works for URL-synced searches, in-memory
searches, or anywhere else.
DiscoverySearchToolbar wraps DebouncedSearchInput with a compact filter
button on the right that opens a popover containing:
- Top / New sort pills wired to NIP-50 'sort:top' (Top) and no sort
token (New).
- An optional Show-hidden switch — only rendered when the caller
passes a showHidden prop, so surfaces without a hidden axis
(Pledges) just hide the row.
The toolbar matches the visual idiom of the global SearchPage filter
button (40-ish-px rounded-lg square, primary-tinted border when any
modifier is active). Inline against the page background — no card
framing — so the hero anchors visual hierarchy. Fully controlled:
parent owns search / sort / show-hidden state, the toolbar is purely
presentational.
useNip50Search<T> issues a kind-scoped NIP-50 search against
nostr.group(DITTO_RELAYS). Routing through the Ditto group rather
than the default pool keeps results predictable: most non-NIP-50
relays either ignore the 'search' field (returning everything matching
the other filters) or return nothing — both modes drown the result
set in noise. Hook is generic over an event parser so each surface
parses its own kind (parseCampaign / parseCommunityEvent / parseAction).
Sort modes
----------
- new (default): no sort token in the search payload; relay returns
chronological results.
- top: appends ' sort:top' to the search payload; relay scores by
engagement. Also works with an empty keyword (sends just
'sort:top') so 'Top' alone is a valid 'most-engaged' feed.
Empty keyword + New sort is the hook's only 'inactive' state, in
which case the page falls through to its existing curated layout
(featured rows, my-groups shelf, active-pledges sections, etc.).
Per-page wiring
---------------
Campaigns home (/campaigns) — toolbar with show-hidden; results
filtered against useCampaignModeration.hiddenCoords. Empty + New
falls through to the existing Featured / Community / moderator
sections.
Communities (/communities) — toolbar with show-hidden; results
filtered against useOrganizationModeration.hiddenCoords. The hook
is lifted to the page level so search results can drop hidden
groups (or include them via the switch). The ModeratorReviewSections
subtree still calls its own copy — query results are cached so the
second call is free.
Pledges (/pledges) — toolbar without show-hidden. Pledges lack a
moderator-driven hidden axis today, so the switch wouldn't have
anything to toggle. Adding it is tracked as a follow-up.
All Campaigns (/campaigns/all) — gets a new HeroBanner +
HeroAtmosphere hero matching the Pledges / Communities shape
(kicker, two-line heading, body, glass stat pill, glass CTA, warm
hope palette), so the route looks like a real discovery surface
instead of a bare grid under a plain H1. The page also migrates
from its bespoke inline toolbar to the shared one, with a small
adapter mapping the toolbar's 'top'/'new' vocabulary to the legacy
'top'/'none' URL param so existing share links keep working.
i18n
----
New common.* keys (sortAriaLabel, sortTop, sortNew, showHidden,
filtersAriaLabel) plus campaigns.all.* hero keys (heroKicker,
heroHeading, heroHeadingLine2, heroBody, campaignsCount_one,
campaignsCount_other). Translated across all sixteen locales.
The Communities page rendered its content column at max-w-5xl while
the Campaigns and Pledges bodies use max-w-7xl, making Communities
look squeezed by comparison on wide displays. Bump Communities to
max-w-7xl so the three discovery pages share a content width.
Pin / Unpin previously rendered as a full-width header bar above every
comment, adding vertical noise to every row even when no comment was
pinned. Replace the bar with a single absolute-positioned slot in the
note's top-right corner driven by three states:
- Not pinned, can manage → 'Pin' button. Hidden until hover on
hover-capable pointers; always visible on coarse pointers (touch)
so mobile moderators can find it.
- Pinned, can manage → 'Unpin' button, always visible.
- Pinned, cannot manage → 'Pinned' badge, always visible.
- Not pinned, cannot manage → nothing rendered.
ThreadedReplyList wraps each note in 'relative group/note' so the
corner overlay positions against the note rather than the surrounding
thread, and the hover variant matches the right note. Visibility on
non-hover devices uses the [@media(hover:hover)] arbitrary variant so
the affordance doesn't depend on a hover event that touch can't fire.
The campaign detail page presented comments inside a muted, primary-
tinted card with rounded corners and retinted per-note dividers, while
the community and pledge detail pages rendered the composer and
threaded reply list bare on the page background. The result was that
the same conceptual area read as three different surfaces.
Extract the campaign-detail treatment into CommentsSection: a thin
wrapper that owns the heading + optional count chip and the muted
panel. Each detail page passes in its own composer, reply list,
skeletons, and empty state, so per-page behavior (pin headers,
campaigner badges, pledge-specific placeholder) stays where it
belongs.
Apply CommentsSection to Campaigns, Communities, and Pledges. Also
swap the campaign-detail hero's bespoke avatar/Link block for
AuthorByline variant='hero', removing the now-dead Avatar /
useProfileUrl / useAuthor / genUserName imports and the
creatorName / creatorPicture / creatorProfileUrl plumbing through
CampaignHeroProps.
Groups, Pledges, and Campaigns each shipped a different author
treatment in their card footers: groups used a raw <img> with no
fallback, campaigns had no avatar at all, and pledges localized the
'by X' string while the others left it as a hardcoded English literal.
Extract AuthorByline as the canonical author element. It uses the
shadcn Avatar primitive (initials fallback), resolves the display name
through the centralized getDisplayName helper, and links to the
author's profile via useProfileUrl. The 'by Name' label is sourced
from the shared common.byAuthor i18n key so every surface ships the
same translated string in every locale.
Inside a card, the byline renders as a <button> that navigates and
stops propagation so the outer card <Link> keeps wrapping the whole
card without nesting <a> inside <a>.
CampaignCard also picks up a localized donor count via the new
common.donors plural key, replacing the inline English 'donor' /
'donors' ternary.
Two changes to ActionsPage that landed together:
- Replace the bare Plus icon on the 'Create pledge' CTA (both the
hero button and the in-feed Section CTA) with PlusCircle so the
Pledges page matches the Communities and Campaigns CTAs.
- Replace the bespoke 'by {name}' footer on ActionCard with the new
shared AuthorByline component, dropping the now-dead useAuthor /
getDisplayName / Trans imports. The byline carries the same avatar
+ i18n'd 'by Name' label + profile-navigation behavior as every
other discovery-page card.
The Pledges and Communities heroes layer their stat pills on top of
black scrims and protest photography. The pills used bg-background/55
plus text-muted-foreground / text-primary, which in light mode rendered
as low-contrast gray text on a dark photo — the "4 pledges open right
now" counter was effectively unreadable.
Switch both pills to a translucent dark surface (bg-black/30) with
explicit white text, white/85 secondary text, theme-tinted icons
(amber-200 for Pledges, cyan-200 for Communities), and drop-shadows
matching the hero headline above. The pill now reads the same in light
and dark mode.
The full equirectangular viewBox (-90..+90 lat) leaves a tall empty
ocean band at the bottom of the hero on wide viewports. Antarctica's
coastline only reaches ~lat -75 around its perimeter, so the bottom
~5° of the viewBox is always empty. Trim to -85..+85 so 'slice' fills
the hero with land texture instead of empty space. No distortion —
preserveAspectRatio still scales uniformly.
Cards in a multi-column grid now have a deterministic, predictable
silhouette. The body region is a fixed stack — title (1 line, truncates)
+ summary (1 line, truncates with a non-breaking-space placeholder when
absent) + progress (invisible-bar placeholder absorbs no-goal and
silent-payment cases) + creator footer — so no card has dead space or
ragged bottoms regardless of which optional fields are populated.
Country, deadline, hidden badge, and moderation menu all moved onto the
banner image as glass chips overlaid on a bottom gradient (chips
appear only when there's data to show, so a chip-free banner stays
clean). The donor-count chip became an inline aside on the creator
line. The variable meta row is gone entirely from the body.
CampaignPrivateNotice was rebuilt to mirror CampaignProgress's
vertical footprint (invisible bar + one text row) so silent-payment
cards line up with public-progress cards beside them.
Progress bars on cards and the detail-page DonateColumn use
`bg-foreground/15` for the track instead of the primitive's default
`bg-secondary`, which in dark mode shares its color with the card
surface and made the empty portion of the bar invisible. The new
foreground-tinted track reads cleanly on the card in both themes.
- Wrap composer + comments / composer + feed in a rounded muted panel
with continuous border-primary rails.
- Composer and campaign sidebar use a near-white surface with a
brand-orange border; dark mode swaps to a near-black warm surface.
- Per-article borders and backgrounds retinted via scoped selectors
so NoteCard stays untouched globally.
- Add hideCommentContext, authorBadge to NoteCard and
renderAuthorBadge, leafCardClassName to ThreadedReplyList; move the
Campaigner badge into the author row.
- Light-mode tokens: --card to 0 0% 98%, --muted to 30 12% 94% so
cards and muted regions read as real surface steps.
- Drop the unused campaignsDetail.commentCount locale key everywhere.
The wallet derives a 24-word BIP-39 seed phrase importable into any
standard Bitcoin wallet (Sparrow, Electrum, BlueWallet, Phoenix, Trezor,
Ledger) at BIP-86 + BIP-352 paths, but the FAQ never said so. Adds a
new payments-category entry covering the export flow, the nsec-only
restriction (extension/bunker logins can't derive the seed), and the
fact that the imported wallet is the same wallet — not a transfer.
Rewrites connect-wallet to lead with self-custodial and link to the
new entry. Rewrites the activist guide's movePromptly steps so the
sweep step describes importing the seed into a desktop/hardware wallet
(rather than implying the Agora wallet isn't one you control), and the
dontSit step reframes from 'campaign address as mailbox' to 'browser
session as warm storage; seed offline = cold storage'.
Updates all 15 non-English locales in lockstep.
Updates connect-wallet to mention the wallet is self-custodial and points
at the new export-wallet FAQ. Adds export-wallet covering BIP-39 seed
backup, import into Sparrow/Electrum/BlueWallet/Phoenix/Trezor/Ledger
via BIP-86 + BIP-352 paths, and the nsec-only restriction. Rewrites the
movePromptly steps in the activist guide to describe seed export and
offline backup.
The Swahili locale (9a3f1cac) was generated against an en.json snapshot
that still contained six receiveDialog keys (onChain, silentPayment,
onChainIntro, silentIntro, noIndexer, silentBalance). Upstream
a83df0f5 had already removed those keys when it combined SP and
on-chain into a single BIP-21 QR. The rebase silently carried the
extra keys through, which the locales test caught.
Regression-of: 9a3f1cac
The selected-language indicator collapsed every BCP-47 tag down to its
base subtag before comparing against the supported list, so picking
'zh-Hant' set i18n.language to 'zh-Hant' (and the UI strings switched
to Traditional Chinese as expected) but the checkmark moved to the
'zh' (Simplified) row because the comparison only saw 'zh' on both
sides.
Match in three passes instead:
1. Exact case-insensitive match against SUPPORTED_LANGUAGES — keeps
'zh-Hant' checked when active.
2. Alias map for zh-TW / zh-HK (registered as resource aliases in
i18n.ts) so device locales from Taiwan and Hong Kong land on the
zh-Hant row in the switcher.
3. Base-subtag fallback for en-US -> en, pt-BR -> pt, etc., preserved
from the original behavior.
The scan now always runs to the indexer tip. Users can hit Cancel to
stop mid-scan, so a configurable upper bound just complicates the UI
for a case nobody hits in practice.
Standard / Kiswahili sanifu (East African Standard) — the variety
used in BBC Swahili / VOA Swahili / DW Kiswahili news coverage,
intelligible across Tanzania, Kenya, Uganda, Rwanda, and eastern DRC.
Register: formal-conversational, not bureaucratic, not academic.
Reaches the WLC governance roster's East African leadership (Rwanda,
DRC, Burundi, and the broader ~230M-speaker activist audience).
Key choices:
- pochi for crypto wallet (modern Swahili tech vocab)
- mfadhili / wafadhili for donor(s), mwanaharakati / wanaharakati for
activist(s), mchango / michango for donation(s), kampeni for campaign
- relei (transliteration) for Nostr relay
- malipo ya kimya for silent payments, iliyogawanywa for decentralized,
inastahimili udhibiti for censorship-resistant
- Polite 2nd person wewe; m-/wa- and n-/n- noun classes used
consistently for actor/wallet concord
- heroTagline avoids the orphan-period bug: trailing period sits
inside <0>...</0> as in the English source
Sub-agent flagged several judgment calls (above-ground activism,
bleeding-edge, push notifications, honeypot metaphor) where the
English idiom doesn't carry across cleanly — these are v1 choices
that East African native speakers may want to refine. Treat this
locale as 'shippable v1, expect refinement from user feedback', same
quality bar as the other African / South-Asian locales we've shipped.
Key parity verified: 1677 leaves, same as en.json, zero missing or
extra, all {{placeholders}} and <N>markup</N> tokens preserved.
DialogContent is a CSS grid container, and grid items default to
`min-width: auto` — i.e. the intrinsic content width. Long
unbreakable strings (e.g. a combined BIP-21 `bitcoin:<bc1>?sp=<sp1>`
URI in the wallet receive dialog) refuse to shrink, blow past
`max-w-lg` / `w-[calc(100%-2rem)]`, and the `flex-1 min-w-0 truncate`
pattern on the inner span never engages because the parent already
accepted the full intrinsic width.
Adding `[&>*]:min-w-0` to the grid container fixes truncation for
every dialog without touching individual call sites.
Mirrors CampaignWalletDonatePanel's payload format
(`bitcoin:<bc1>?sp=<sp1>`) so BIP-352-aware wallets pick the
`sp=` parameter and legacy wallets fall back to the on-chain
address. The dialog now shows one QR and one copyable row with the
full URI, the tabs and per-tab intro copy are gone, and the
"Scan for payments" action lives in the wallet's overflow menu
instead of inside the receive dialog.
Strip the redundant in-dialog header, the BIP-86/Taproot explainer, and
both the Reveal and Copy buttons from the seed-phrase backup UI. The
seed-phrase box itself is now the reveal affordance — tap to expose the
words, tap again to hide them. Copy-to-clipboard is gone entirely (users
write the words down rather than paste them).
- WalletBackupMnemonic: drop the inline "Wallet seed phrase" h2 + KeyRound
icon (the DialogTitle already renders the same heading), drop the
Nostr-key-derivation explainer paragraph, drop the two-button row at the
bottom, drop the copied-state and useToast wiring. Wrap the seed-phrase
box in a <button> with aria-pressed and aria-label so screen readers know
it's a toggle.
- en.json:
- Rewrite walletBackup.dialogDescription to the new short copy: "You can
access your Agora wallet in any Bitcoin wallet by importing this 24-word
backup."
- Rewrite walletBackup.hidden to "Tap to display your 24-word seed
phrase." (dropping the "Tap Reveal" wording, since there is no Reveal
button anymore).
- Add walletBackup.revealAria / hideAria for the toggle button's
aria-label.
- Remove walletBackup.explainer, .reveal, .hide, .copy, .copied,
.copyFailedTitle, .copyFailedDescription.
- Mirror the same restructuring across all 14 other locales. Four locales
(hi, id, tr, zh-Hant) didn't previously have a walletBackup block at all
and were falling back to English — they now ship full translations.