chore: merge main into the token-forge migration branch - #200
Merged
lmcorbalan merged 133 commits intoSep 9, 2026
Conversation
- an unset actAs lets the wallet pick its own primary, which may not be that party - execute sends the connected party; a caller's own actAs passes through - execute and signMessage refuse when the session reports no party
`ConnectButton` was one component that swapped between a connect trigger and a Zag popover holding the account, with a `mode` prop to pin it to one. A host that wants a dropdown of its own had to take ours or take nothing. `WalletButton/` replaces it with three exports from `/connect`: `ConnectButton` and `DisconnectButton`, each a plain button that renders whatever the session says and calls one method, and `WalletButton`, a chooser that follows `useWalletStatus().isConnected` and hands its props to the face it picks. The session and not the party decides that face, because a locked wallet keeps the session and clears the party. The popover goes with it, and `@zag-js/popover` with the popover: no component here needs a popper any more. `LogoutIcon` and `ChevronDownIcon` were rendered only inside it.
`.cnc-disconnect-button` shares the connect button's rules, since they are the same object with different words. The popover's own block goes with the component, taking the second `z-index` value and the only `[hidden]` rule in the file with it. Two rules the file was already following go into CLAUDE.md while there is a case to point at: hoist a shared trailing part into `:is()` where every entry weighs the same, and never nest with `&`, which hides a class from the regex `check:anatomy` harvests selectors with.
The kit's account popover was the whole of the connected face, so the app could show the party id and nothing else. `TopBar/AccountMenu` replaces it with a dropdown the app owns: a copyable party id, the network the session is on, and the kit's `DisconnectButton`. The top bar picks the face rather than mounting the kit's `WalletButton`, on the party or the lock and not on `isConnected` alone. A standing session reports no party while the account read is in flight and again after it fails, and the connect face answers both — pending copy for the first, a retry for the second. A lock is the one state that clears the party for good, and there the dropdown is replaced whole by a disabled button. `useParty` reports that lock beside the party, so nothing else in the app reaches for a canton-connect hook. Dismissal is `hooks/useDismissable`, shared with `RoleSelect`: the focusout test against the root, the Escape close, the focus returned to the trigger, and the mousedown guard without which Safari unmounts the panel before the click lands.
The shell ended in a hardcoded status strip. `Footer` ports dappbooster.dev's: the BootNode credit, the four social links and the two resource links, over one outbound anchor and six new icons.
`ConnectButton` was one component in every doc that named it, and its popover was the example for how Zag earns its place and how depth is decided. Both seams move: the kit's is three components behind `/connect` that require a provider ancestor, the app's is a top bar picking a face and an account menu holding the session. The shared pieces the app now has one of each of are listed with them, and the published reference is at its own domain rather than the Vercel one.
Adds the shared writing style section to the issue and PR wizards and bumps the starter kit stamp.
A wallet reports a lock and its own disconnect as the same push, so the header cannot know which it is. The disabled button also disagreed with the refresh path, which lands on the connect face for the same wallet state.
TopBar already picks between the two faces on party, so the menu re-reading the hook only to return null was a second gate on the same condition.
- toParty mints a new object per push, so execute churned on a re-push - the dApp then rebuilt its backend and re-read the ACS
- useParty follows the primary among usable accounts, not the wallet's - a status with no party is legal; a party with no session is not - the result types and README omitted the no-party refusal
…ayed-party fix(connect): act as the party the kit shows, or refuse
- Node 26 ships localStorage as a lazy global; vitest 4 keeps a global it finds over jsdom's - Node 26 failed 40/186 connect, 274/274 dappbooster and 18/155 frontend tests; Node 24 passes - --no-experimental-webstorage in the workers drops Node's global, so jsdom's lands as on Node 24 - Fixed in vitest 5 (vitest-dev/vitest#10293), refused for v4 (#10873); drop the line on that bump
- engines.node has no upper bound, but every job pins .nvmrc, so Node 26 was never exercised - A separate job keeps the `3 - test` name the ruleset requires; add this one there after merge
canton-barebones 0.3.0 -> 0.5.0, which moves the Splice checkout from 0.6.11 to 0.7.5, so an existing .canton-localnet/ re-scaffolds and the stack needs a reset before the next run. wallet-service v0.1.4 -> v0.2.1, which reads holdings off one paged active-contracts snapshot instead of replaying the update stream. The old read fired thousands of requests per balance and tripped the validator ingress rate limit, which answered 503. The allowBuilds key carries the resolved tarball id, so it moves to the new commit sha with the ref.
…llet-service chore: update localnet and wallet-service to their latest releases
- `connect.cancel` retires the sdk and lands in the `retiring` variant a closed picker would have taken, so the resume rules are shared - per-child, since a parent transition cannot tell `new` from `changing` and a wallet change must keep its session on the way back - no timeout anywhere: a wallet login takes as long as it takes, and a bound firing mid-approval would invite a second, colliding connect - docs say the connect wait ends on an answer or a cancel, not a clock
- `guardedConnect` takes the abort signal xstate fires when it stops the connect actor, so leaving `connecting` needs no extra plumbing - on abort it closes the window it captured, forges the picker's own result so a pre-pick listener settles, and rejects - rejecting is not cosmetic: the poll and the `window.open` borrow are released in the race's `finally`, which an unsettled race never reaches - a cancel skips the `status()` recovery a closed picker skips, for the same reason: both leave a connect running on an sdk about to be retired
- `stranded`, `walked-out` and `orphan` all named the same thing: a `sdk.connect()` still running with no listener and no way to stop it - `abandoned` wins because it cannot move: it is already in `settleAbandonedConnect`, in `abandonOn`, and in the wire value `providerId: 'abandoned'` - comments, test titles, two local names, the README line and the popup-close-guard chapter, heading included
- fire-and-forget, no bridge: the pending `connect()` is what reports the outcome, rejecting on the `connect.cancelled` it already waited for - on the context value too, so the fake session double answers it - `hangingPicker`, a picker that never answers, is the wallet this exists for; suite-local, not on the testing barrel - docs: three actions became four
- `aria-disabled` and the click gate are gone: while pending the click routes to `cancelConnect` instead of `connect` - the pending state names the cancel in `aria-label`, since nothing visible here says a click ends the attempt - no visible hint: one outside the button is a consumer's, and one positioned over it is clipped by the `overflow-hidden` a consumer's own button classes carry - `composeAction` takes a sync action, wrapping it because `.catch` on a void return throws - `[data-pending]` stops sharing the `:disabled` look and rejoins `:hover` and `:active`
`formatFigureCompact` already answers `N/A` for an amount it cannot read, while `formatFigureFull` threw, so which of the two crashed a render was something a caller had to remember. It now checks with the kit's `parseAmount` first and answers `N/A` too. The exposed caller was the success toast in `Claim.tsx`, which formats whatever the backend returned after a claim.
…an reach The dev server folds the published DevNet entries into the LocalNet asset list, so the token list is a real catalogue rather than the one coin a LocalNet issues. Those entries name registries on remote hosts, and the app asked each of them for metadata straight from the browser. Every one of those reads failed: - The hosts send no `Access-Control-Allow-Origin`, so the browser blocked the reply whatever it held. - The replies were 403 or 404 anyway, since the DevNet scan is gated and that host serves none of those registrars. `readInstruments` already catches the failure, so the cost was a console full of red rather than a missing row. Filter the list down to same-origin addresses instead, which is `/registry` and whatever else Vite proxies. Those tokens keep the symbol and logo the curated list carries; only the name and decimals go, and those never arrived.
feat: build the token picker's list from the ledger and the registries
… switch The strip only reflected what was true at connect. Nothing re-read either synchronizer id, so switching networks in the wallet left it stale, which made the warning useless in the case it exists for. - useWrongNetwork re-reads on three triggers: the party changing, the page regaining focus, and every 30 seconds. Focus catches a switch as it happens, because using the wallet takes focus off the page. visibilitychange misses it, since an extension popup draws over the tab rather than hiding it. - synchronizer.ts reads /v2/state/connected-synchronizers in one request. The wallet's side no longer comes from the factory row in config.ts: that read is three round trips and its answer rebuilds the backend, so polling it would re-run every ledger read. - wrongNetwork tests membership instead of equality. A participant can be connected to several synchronizers, and reaching the app's one is what decides whether a write lands. - A failed read stays silent and leaves the last answer standing. It has to poll: CIP-0103 defines no network-change event and the SDK pushes accounts only.
The strip claimed to name a target network to switch to, and nothing can name one a reader could act on. CIP-0103 defines `networkId` for a wallet only, and the label wallet-service could supply from its NETWORK variable is typed by hand on both sides, so the two read the same for different networks as easily as differently for one. - The strip now reads "Wrong network: wallet is connected to bootnode-devnet, switch networks to proceed." It names the wallet's own label and no target, and drops the synchronizer ids, which were unreadable and told the user nothing they could act on. - networkLabel drops the CAIP-2 namespace, since every network this app sees is under canton. A label with no namespace stays whole. - wrongNetwork returns a boolean. Nothing rendered the ids any more, so the result payload went with them. - architecture.md records why there is no target label and that a wallet quiet about its network reads as canton:local.
The connected-synchronizers read put the party in the resource string. A wallet that checks the resource against the ledger API's own route list rejects that, and the hook swallows the failure, so the strip never appears. The party now travels in `query`, the way every other read passes one. Three fixes in `useWrongNetwork`: - Only the last check started may write, so a slow older read cannot overwrite a newer verdict. - The verdict carries the party it was read for, so the previous party's answer is not shown against the new one's network. - The app's synchronizer id is read once and kept. wallet-service answers for one network, so every later check is a single participant read rather than another `amulet.tap` every 30 seconds. On the wrong network `config.ts` finds no operator and throws its `run pnpm run bootstrap` advice, which filled the page with a symptom. The card now names the network, and the advice stays for a ledger that really has no deployment. Adds `synchronizer.test.ts`: the happy path, the request shape, the empty cases.
Split `fetchAppNetwork` out of `fetchTransferContext`. That builder needs an AmuletRules and an open mining round both, and the SV opens the first round minutes after a LocalNet start, so the check could not answer until then. The network id sits on the AmuletRules disclosure alone. Also: - Box the cached app network, so an answer carrying no id counts as read. Before, the ref held the bare id, so every later check re-ran the tap. - Read the wallet's synchronizers through `call` in config.ts instead of a second inline cast. - Simplify `networkLabel` to one expression and drop a redundant test row. - Move the rule about passing a filter in `query` into dapp/frontend/CLAUDE.md, where the next author looks for it. - Trim comments that restated the code or copied architecture.md.
# Conflicts: # dapp/frontend/architecture.md
ConnectButton cancelled the connect when clicked while it read "Connecting...". A double-click therefore connected and cancelled itself, and a button whose label says one thing and whose click does another is a trap. - ConnectButton connects only, and is inert while an attempt is in flight - CancelButton is new: it cancels only, and is inert while there is none - Either button ignores every click past the first Both buttons carry aria-disabled rather than the native disabled attribute, so they stay focusable and announced. That leaves the click live, so resolveInert pairs the attribute with a handler that calls preventDefault, and a caller's type="submit" cannot submit a form from an inert button. CancelButton spins while the attempt runs and keeps its accessible name on the action. The spinner is aria-hidden, so the wait reaches a screen reader through a live region beside the button, filled after the first paint: a region that arrives already filled is never read out. The theme dims either button on :disabled or aria-disabled, which is the look #142 removed.
The prompt used to show the connect button plus a "(click to cancel)" hint, because that button did both jobs. Now that each button does one thing, the connect button is swapped for the cancel one while an attempt is in flight, so a single button is on screen at a time and the hint has nothing left to explain. ConnectFace is the swap, rendered by both the prompt and the top bar. An attempt started in the top bar can now be abandoned there rather than only from the prompt further down the page. It is a leaf on purpose, holding the only useConnect subscription of the two, so an attempt re-renders one button instead of the whole header or the empty state around it. A swap costs the keyboard its place, since the focused button is the one being unmounted. ConnectFace hands focus to whichever button took over, and only after a real swap, so nothing moves focus on first paint.
Step 5 of the dapp-sdk walkthrough told the reader to click Connect twice, which was how a connect was cancelled before the split. The cancel is its own button now, and it replaces the connect one, so the step names what to click instead.
WalletButton follows the session, and a connect in flight is a session state, but it only knew two faces. Since the connect button went inert while pending, a consumer taking WalletButton had no way out of a wallet prompt that never answers. - Cancel face while a connect is in flight - Disconnect face once a session stands, connect face otherwise - Focus moves to whichever face took over, since a swap unmounts the focused button The three faces publish a ref for that focus move, which is what WalletButton hands them. WalletButton itself takes none, because which element it landed on would depend on the session. The vesting dApp still builds its own swap. Its connected face is a dropdown rather than a plain disconnect button, and its prompt must never offer to disconnect.
The span existed only to give the focus handoff something to run querySelector on. The kit's wallet buttons publish a ref now, so ConnectFace puts it straight on the face it renders and the span, its inline-flex class and the DOM query all go. The top bar's button is a direct flex item of the header row again, which is where it sat before this branch.
What is left is only what the code cannot carry: the live region filling a paint late, the focus move skipped on first paint, why aria-disabled needs its click refused, and the two test-fixture constraints. Every barrel export keeps its JSDoc, which pnpm docs:check requires.
The inert helper paired aria-disabled with its refused click so neither could be written without the other. There is no third inert button to protect, and the module cost more lines than the two it saved in each button, so each one now writes the attribute and the handler itself.
feat: split the cancel out of the connect button
feat: warn when the connected wallet is on a different network than the dApp
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
lmcorbalan
force-pushed
the
chore/199-merge-main
branch
from
September 9, 2026 18:35
3b43680 to
fee830e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Part of #157.
The epic branch had drifted 132 commits behind
mainand no longer merged. This bringsmainin and resolves the 33 conflicting files, keeping both sides: the token-forge vesting model from the epic, and the interface rewrite frommain.Changes
main's Ark UI create dialog and token pickerformatFigureeverywhereDeviations
mainread it throughVITE_WALLET_RPC_URLis no longer read; nothing calls wallet-service directly any moreAcceptance criteria
mainshipped is present: Ark UI dialogs, lucide icons, account menu, wrong-network strip, token pickerTest plan
Automated tests
pnpm installpnpm lint,pnpm typecheck,pnpm build,pnpm test,pnpm knip,pnpm docs:checkandpnpm run check:anatomyManual verification
./scripts/dev-stack.sh upBreaking changes
VITE_WALLET_RPC_URLis no longer read. A deployment setting it can drop the variable; nothing else changes.Checklist
Screenshots
To be added before review.