From 37b724168a7215b007ccc89b49848c1ff3a02987 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 09:58:44 +0000 Subject: [PATCH 01/35] refactor(mail): say each privacy fact once, and name the open thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reading pane repeated what the rest of the screen already said. Every letter carried a boxed "recipient identities are sealed and absent from MessagePosted" note between its header and its body, so a letter opened with boilerplate instead of prose — while "What the chain sees" states the same absence directly under the public record, and the message meta row already carries the recipient count. The letter sheet's "N recipients · count public" stamp said the count a third time. Both are gone; nothing is lost, because the chain panel and the meta row keep the facts where they are decidable. The per-message reply row and the rail's standing note are cut to one line each for the same reason. The thread head was the constant "Correspondence" under a kicker that duplicated the reading toolbar's, so the pane never said which record was open. It now names the counterparty and its detail, reusing the rail's own conversationCorrespondent so neither pane can call the same counterparty something different; the helper moves to correspondent.ts, which both panes import. The section keeps a stable "Correspondence" accessible name, and the localnet spec addresses the focused heading by id rather than by prose. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB --- src/components/mail/ConversationList.tsx | 101 ++------------------ src/components/mail/Thread.tsx | 59 +++++++----- src/components/mail/correspondent.ts | 112 +++++++++++++++++++++++ src/components/mail/mail.module.css | 31 +++++-- tests/ui/app20.localnet.spec.ts | 5 +- 5 files changed, 177 insertions(+), 131 deletions(-) create mode 100644 src/components/mail/correspondent.ts diff --git a/src/components/mail/ConversationList.tsx b/src/components/mail/ConversationList.tsx index 6b07ea5..ab58f71 100644 --- a/src/components/mail/ConversationList.tsx +++ b/src/components/mail/ConversationList.tsx @@ -1,8 +1,7 @@ "use client"; import { memo } from "react"; -import { feltEquals } from "@/lib/addresses"; -import { findAliasByAddress, type AliasRecord } from "@/lib/aliases"; +import type { AliasRecord } from "@/lib/aliases"; import { parseBackupPointer } from "@/lib/backup-blob"; import { decodeBackupSnapshot } from "@/lib/backup-snapshot"; import { parseCompositePayload } from "@/lib/composite"; @@ -20,7 +19,10 @@ import { parseEscrowFundPayload, parseEscrowTimeoutPayload, } from "@/lib/escrow"; -import { formatDeviceSentRecipients } from "@/lib/mail-correspondents"; +import { + conversationCorrespondent, + type ConversationCorrespondent, +} from "./correspondent"; import { mailMessageDateTime, mailboxMatchesFilter, @@ -30,8 +32,8 @@ import { import type { LocalMailMessage } from "./Thread"; import styles from "./mail.module.css"; -export type { MailboxFilter }; -export { mailboxMatchesFilter }; +export type { MailboxFilter, ConversationCorrespondent }; +export { mailboxMatchesFilter, conversationCorrespondent }; function backupKind( message: LocalMailMessage, @@ -94,91 +96,6 @@ function envelopeLabel(message: LocalMailMessage): string { } } -export type ConversationCorrespondent = { - primary: string; - detail?: string; - fullAddress?: string; -}; - -export function conversationCorrespondent( - message: LocalMailMessage, - aliases: AliasRecord[], - selfAddress: string, -): ConversationCorrespondent { - const payload = - message.envelope.type === "unsupported" ? null : message.envelope.payload; - const address = (() => { - switch (message.envelope.type) { - case "offer": - return parseOfferPayload(payload)?.offerer; - case "payment_request": - return parsePaymentRequestPayload(payload)?.requester; - case "escrow_fund": - return parseEscrowFundPayload(payload)?.maker; - case "composite": { - const composite = parseCompositePayload(payload); - for (const attachment of composite?.attachments ?? []) { - if (attachment.type === "offer") return attachment.payload.offerer; - if (attachment.type === "payment_request") { - return attachment.payload.requester; - } - if (attachment.type === "escrow_fund") - return attachment.payload.maker; - } - return undefined; - } - default: - return undefined; - } - })(); - - if (address && (!selfAddress || !feltEquals(address, selfAddress))) { - const alias = findAliasByAddress(aliases, address)?.label; - return { - primary: `Claimed address: ${address}`, - detail: alias - ? `Unauthenticated payload claim · local alias “${alias}”` - : "Unauthenticated payload claim · verify out-of-band", - fullAddress: address, - }; - } - if ( - message.direction === "outgoing" && - (message.recipients?.length ?? 0) > 0 - ) { - return formatDeviceSentRecipients(message.recipients ?? [], aliases); - } - if ( - message.envelope.type === "contact_snapshot" || - message.envelope.type === "backup_snapshot" || - message.envelope.type === "backup_pointer" - ) { - return { - primary: "This mailbox", - detail: "Encrypted self-backup · verify before restore", - }; - } - if (message.envelope.type === "text") { - return { - primary: - message.direction === "outgoing" - ? "Private recipient" - : "Sealed sender", - detail: - message.direction === "outgoing" - ? "Device-local Sent copy · recipient was not stored" - : "Sealed letter · no public sender", - }; - } - if (message.direction === "outgoing") { - return { - primary: "Private recipient", - detail: "Device-local Sent copy · recipient was not stored", - }; - } - return { primary: "Sealed counterparty" }; -} - function messagePreview(message: LocalMailMessage): string { const payload = message.envelope.type === "unsupported" ? null : message.envelope.payload; @@ -323,9 +240,7 @@ function ConversationList({
Local browser index—not encrypted at rest - “Unread/opened” is session-only and changes only after you activate a - message while its reading pane is visible. Unsigned letters cannot - reveal a sender address, so Mail labels them “Sealed sender.” + “Unread/opened” is session-only. Sealed rows carry no public sender.
diff --git a/src/components/mail/Thread.tsx b/src/components/mail/Thread.tsx index f5f8cf8..8cf9fa7 100644 --- a/src/components/mail/Thread.tsx +++ b/src/components/mail/Thread.tsx @@ -53,6 +53,10 @@ import { conversationKeyForMessage, } from "@/lib/mail-thread"; import { senderProofLabel, type SenderProof } from "@/lib/sender-proof"; +import { + conversationCorrespondent, + correspondentHeadline, +} from "./correspondent"; import styles from "./mail.module.css"; export type LocalMailMessage = { @@ -343,10 +347,7 @@ function ConversationControls({ {fields.conversationId ? ( Conversation tag {fields.conversationId.slice(0, 18)}… ) : ( - - This letter has no conversation tag. A reply can start one locally and - in the next ciphertext. - + No conversation tag — a reply starts one. )} {canContinue ? ( + {/* The CTA has to be the step that is actually available: + offering compose to a disconnected desk only produced a + notice pointing at a control in the app header. The gate + notice carries the same control, so only one of the two + asks for the wallet at a time. */} + {walletGateShown ? null : !address || !chainId ? ( +
+ +
+ ) : ( + + )} )} diff --git a/src/components/mail/mail.module.css b/src/components/mail/mail.module.css index 9d0a5b8..acd7802 100644 --- a/src/components/mail/mail.module.css +++ b/src/components/mail/mail.module.css @@ -2246,6 +2246,49 @@ color: var(--shell-text); } +/* A notice that names a missing wallet carries the wallet control itself. */ +.storageNotice { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-5); +} + +.connectAction { + display: inline-flex; + flex: none; +} + +/* SelectWallet ships the app's legacy rounded CTA; on the desk it has to be + the same square orange control as every other primary action here. */ +.connectAction button { + width: auto; + margin: 0; + padding: 9px 12px; + border: 1px solid var(--shell-orange); + border-radius: 0; + color: var(--on-accent); + background: var(--shell-orange); + font: inherit; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; +} + +.connectAction button:hover { + color: var(--shell-bg); + background: var(--shell-text); + border-color: var(--shell-text); + filter: none; +} + +.welcomeConnect { + margin-top: 12px; +} + .threadPanel, .composerSheet, .welcomeState { From 32d69de2f0d24e4b6e769ae04ffb0c31ad202bf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 12:06:35 +0000 Subject: [PATCH 03/35] style(mail): give each record colour back its one meaning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design system assigns orange to what you are doing, teal to public on-chain evidence, mauve to negotiated terms. The mail module had drifted on all three, so none of them carried information any more: - mauve had become a second severity colour (Forget this device, restore warnings, wallet errors, preflight errors, Remove attachment, Delete draft) and a generic caption colour (every rail row's provenance line). Severity is --shell-danger; the row captions are plain secondary text. Mauve is now left only on deal surfaces: the deal sheet and fields, the terms sentence, the claimed payment address, the deal-kind toggle. - teal headed the rail's "local browser index" note and stamped the welcome sheet "LOCAL ONLY" — the opposite of public evidence — and tinted the selected row. Selection is accent, and both local-state labels are neutral. - orange sat on the inert rail total and on every "decrypted on this device" footer, competing with the controls that are actually actionable. A standing caveat is neither a deal nor an error, so .actionWarning takes the design system's risk frame instead of the deal frame. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB --- src/components/mail/mail.module.css | 51 +++++++++++++++-------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/src/components/mail/mail.module.css b/src/components/mail/mail.module.css index acd7802..fac1117 100644 --- a/src/components/mail/mail.module.css +++ b/src/components/mail/mail.module.css @@ -326,8 +326,8 @@ .restoreWarning { padding: 12px; - border: var(--frame-deal); - background: var(--shell-deal-quiet); + border: 1px solid var(--shell-danger); + background: color-mix(in srgb, var(--shell-danger) 12%, var(--shell-bg)); color: var(--shell-text); font-size: 12px; line-height: 1.5; @@ -340,9 +340,9 @@ .warningButton { margin-top: 10px; padding: 10px 12px; - border: 1px solid var(--shell-line); + border: 1px solid var(--shell-danger); color: var(--shell-bg); - background: var(--shell-deal, #c496ae); + background: var(--shell-danger); font: inherit; font-weight: 700; cursor: pointer; @@ -827,8 +827,8 @@ .actionWarning { padding: 10px 12px; - border: var(--frame-deal); - background: var(--shell-deal-quiet); + border: 1px solid var(--shell-text); + background: var(--shell-panel); } .inlineStatus { @@ -1120,10 +1120,12 @@ line-height: 1.5; } +/* Where the plaintext lives is a fact about the record, not an action, so it + reads as data — accent stays with what the user is doing. */ .localLabel { display: inline-block; margin-top: 16px; - color: var(--shell-orange); + color: var(--shell-muted); font-family: var(--font-mono-ui), monospace; font-size: 11px; font-weight: 700; @@ -1597,7 +1599,7 @@ } .walletErrorText { - color: var(--shell-deal, #c496ae); + color: var(--shell-danger); } .sidebarWalletActions { @@ -1651,7 +1653,7 @@ margin-top: 9px; padding: 8px; border: 0; - background: var(--shell-deal-quiet, #2e2129); + background: var(--shell-panel); font-size: 10px; line-height: 1.5; } @@ -1665,7 +1667,7 @@ } .walletResultError { - background: var(--shell-deal-quiet, #2e2129); + background: color-mix(in srgb, var(--shell-danger) 12%, var(--shell-bg)); } .walletResult p, @@ -1985,8 +1987,8 @@ width: 32px; height: 32px; place-items: center; - border: 1px solid var(--shell-orange); - color: var(--shell-orange); + border: 1px solid var(--shell-line); + color: var(--shell-muted); font-size: 10px; font-weight: 700; } @@ -2005,7 +2007,7 @@ } .railPrivacyNote strong { - color: var(--shell-public, #8cafb7); + color: var(--shell-text); font-family: var(--font-mono-ui), monospace; font-size: 10px; letter-spacing: 0.08em; @@ -2048,8 +2050,7 @@ .conversationItemActive { border-left-color: var(--shell-orange); background: var(--shell-panel); - box-shadow: inset -4px 0 0 - var(--shell-public-shadow, rgba(140, 175, 183, 0.14)); + box-shadow: none; } .conversationTopline { @@ -2071,8 +2072,10 @@ line-height: 1.25; } +/* Provenance, not deal terms: the mauve record class is reserved for + negotiated values, so a "Sealed letter" caption reads as plain secondary. */ .correspondentIdentity small { - color: var(--shell-deal, #c496ae); + color: var(--shell-muted); font-size: 10px; font-weight: 700; line-height: 1.3; @@ -2497,8 +2500,8 @@ top: 17px; right: 18px; padding: 5px 7px; - border: 2px solid var(--shell-public, #8cafb7); - color: var(--shell-public, #8cafb7); + border: 2px solid var(--shell-muted); + color: var(--shell-muted); content: "LOCAL ONLY"; font-family: var(--font-mono-ui), monospace; font-size: 10px; @@ -2990,7 +2993,7 @@ .attachmentHeading button { padding: 6px 9px; - color: var(--shell-deal, #c496ae); + color: var(--shell-danger); } .composePreflight { @@ -3029,12 +3032,12 @@ } .composePreflightError { - border-color: var(--shell-deal, #c496ae); - background: var(--shell-deal-quiet, #2e2129); + border-color: var(--shell-danger); + background: color-mix(in srgb, var(--shell-danger) 12%, var(--shell-bg)); } .composePreflightError .preflightIssue { - color: var(--shell-deal, #c496ae); + color: var(--shell-danger); font-weight: 700; } @@ -3060,7 +3063,7 @@ .deleteDraftButton { width: 100%; padding: 9px; - color: var(--shell-deal, #c496ae); + color: var(--shell-danger); background: transparent; } @@ -3131,7 +3134,7 @@ right: 10px; bottom: 7px; border: 0; - color: var(--shell-deal, #c496ae); + color: var(--shell-danger); background: transparent; font: inherit; font-size: 10px; From 433c357e02052f9a19601ec2c25658121ba87bd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 12:10:30 +0000 Subject: [PATCH 04/35] fix(mail): give the composer a measure, and stop delete from flanking send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You wrote at ~110ch and read at 68ch: the letter body is capped at --measure-reading in the reading pane, while the composer's fields spanned the whole 880px pane. The composer's prose now takes the same measure. The recipient field is exempt and sized to fit a full felt on one line, because an address is an identifier rather than prose. Delete draft was a full-width control directly under Send encrypted message — a destructive action at the primary action's weight and adjacency. It moves below a rule, right-aligned at its own size. Two hints were mono at 9-10px, which is the desk's label face, and one of them restated the PUBLIC column of the privacy strip two rows above. Both are now body text at a readable size, and the attachment note says what happens to the document instead of describing the UI's own design decision. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB --- src/components/mail/Compose.tsx | 43 +++++++++++++++------------- src/components/mail/mail.module.css | 44 +++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/src/components/mail/Compose.tsx b/src/components/mail/Compose.tsx index bb33f33..7ca6280 100644 --- a/src/components/mail/Compose.tsx +++ b/src/components/mail/Compose.tsx @@ -1212,12 +1212,11 @@ export default function Compose({ } rows={hasAnyAttachment ? 2 : 3} required + hintClassName={styles.fieldHint} hint={ <> {recipientEntries.length} / {MAX_MULTI_RECIPIENTS} recipients. - Recipient count is public; identities are absent from - MessagePosted. Attachments are bilateral; body-only delivery can - be multi-recipient. + Attachments are bilateral; a body-only letter can go to several. } /> @@ -1267,8 +1266,8 @@ export default function Compose({ })}

- This is one document, not a mode picker. Add any combination; text - and existing attachments are never cleared. + Everything you add goes into this one document. Adding an + attachment never clears your text or the others.

@@ -1477,21 +1476,25 @@ export default function Compose({ > {sendButtonLabel} - + {/* Discarding the draft is not a second way to send it: it leaves the + primary action's block and sits at its own weight. */} +
+ +
diff --git a/src/components/mail/mail.module.css b/src/components/mail/mail.module.css index fac1117..1ccb0b8 100644 --- a/src/components/mail/mail.module.css +++ b/src/components/mail/mail.module.css @@ -2427,11 +2427,34 @@ } .letterInput { + max-width: var(--measure-reading); font-family: var(--font-reading), Georgia, serif !important; font-size: 15px !important; line-height: 1.7 !important; } +/* The recipient list and every hint under it read at the same width as the + letter: the reading pane caps the body at one measure, and the composer + used the full 880px pane. */ +.composeFieldset .field, +.fieldHint { + max-width: var(--measure-reading); +} + +/* An address is an identifier, not prose: it gets the width it needs so a + recipient list stays one address per line. */ +.composeFieldset .field[data-address-book-field] { + max-width: 42rem; +} + +/* A three-sentence hint is prose, not a label: mono at 9px is for identifiers. */ +.fieldHint { + color: var(--shell-muted); + font-size: 11px; + font-weight: 400; + line-height: 1.5; +} + .paymentAttachment { padding: 13px; border: 1px solid var(--shell-public, #8cafb7); @@ -2928,15 +2951,18 @@ } .attachmentTray > header > span, -.attachmentTray > p, .attachmentHeading .fieldBadge { color: var(--shell-muted); font-family: var(--font-mono-ui), monospace; font-size: 10px; } +/* Sentences take the body face; mono is for labels, numbers and identifiers. */ .attachmentTray > p { + max-width: var(--measure-reading); margin-top: 7px; + color: var(--shell-muted); + font-size: 11px; line-height: 1.5; } @@ -3060,13 +3086,25 @@ letter-spacing: 0.08em; } +.composeFooter { + display: flex; + justify-content: flex-end; + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid var(--shell-line); +} + .deleteDraftButton { - width: 100%; - padding: 9px; + width: auto; + padding: 7px 10px; color: var(--shell-danger); background: transparent; } +.deleteDraftButton:hover { + border-color: var(--shell-danger); +} + .stepProgress { margin-top: 14px; color: var(--shell-public, #8cafb7); From eb7937b5b88c32940097c0483755cbaefbf16206 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 12:13:46 +0000 Subject: [PATCH 05/35] style(mail): make the sidebar one control set instead of four MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FOLDERS was a single-column list with boxed counts; SHOW was a two-column grid with a smaller face, plain counts, an orphaned last cell and a solid orange chip flush to the window edge — two controls doing the same job, in two geometries. SHOW now uses the folder list's rows, counts and active treatment, and both inset their text to the same 12px as Compose, which no longer spans the pane edge-to-edge like a title bar. "Encrypted mailbox recovery" and "Device safety" were collapsed sections with no disclosure affordance at all, so they read as headings for empty regions; they take the design system's +/- marker. Checking for mail is the sidebar's most-used action and was two unequal buttons crammed into "1fr auto", the primary one reading as a muted box. Both now fill the panel, and the section heading matches the labels above it instead of being a third heading style. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB --- src/components/mail/mail.module.css | 101 +++++++++++++++++++++------- 1 file changed, 77 insertions(+), 24 deletions(-) diff --git a/src/components/mail/mail.module.css b/src/components/mail/mail.module.css index 1ccb0b8..0da059d 100644 --- a/src/components/mail/mail.module.css +++ b/src/components/mail/mail.module.css @@ -671,10 +671,8 @@ } .scanActions { - display: flex; - justify-content: flex-end; - gap: 8px; - flex-wrap: wrap; + display: grid; + gap: 5px; } .scanActions .secondaryButton { @@ -1346,6 +1344,7 @@ display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; + padding-inline: 12px; } .composeButton, @@ -1405,7 +1404,7 @@ } .mailboxNav > .sidebarLabel { - margin: 0 8px 5px; + margin: 0 12px 5px; } .mailboxNav button { @@ -1413,7 +1412,7 @@ align-items: center; justify-content: space-between; width: 100%; - padding: 6px 10px; + padding: 6px 12px; border: 0; color: var(--shell-text); background: transparent; @@ -1684,6 +1683,17 @@ background: var(--shell-public-wash, #121a1c); } +/* The section heading matches FOLDERS and SHOW instead of inventing a third + heading style two rows below them. */ +.sidebarScan .scanHeading strong { + color: var(--shell-muted); + font-family: var(--font-mono-ui), monospace; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + .sidebarScan .scanProgress, .sidebarScan .envelopeProgress { border: 0; @@ -1804,7 +1814,10 @@ display: flex; min-height: var(--hit-min); align-items: center; + justify-content: space-between; + gap: var(--space-5); padding: 0 12px; + list-style: none; color: var(--shell-muted); font-family: var(--font-mono-ui), monospace; font-size: var(--label-md); @@ -1814,6 +1827,25 @@ cursor: pointer; } +.contactBackupPanel > summary::-webkit-details-marker, +.forgetDevice > summary::-webkit-details-marker { + display: none; +} + +.contactBackupPanel > summary::after, +.forgetDevice > summary::after { + color: var(--shell-orange); + content: "+"; + font-family: var(--font-mono-ui), monospace; + font-size: 15px; + line-height: 1; +} + +.contactBackupPanel[open] > summary::after, +.forgetDevice[open] > summary::after { + content: "\2212"; +} + .contactBackupPanel[open] { background: var(--shell-panel); } @@ -1898,7 +1930,6 @@ .scanActions { display: grid; - grid-template-columns: 1fr auto; gap: 5px; margin-top: 10px; } @@ -1906,8 +1937,9 @@ .scanActions button, .scanButton, .scanOlderButton { + width: 100%; padding: 7px 8px; - border: 0; + border: 1px solid transparent; color: var(--shell-public, #8cafb7); background: var(--shell-bg); font: inherit; @@ -1921,6 +1953,15 @@ background: var(--shell-public, #8cafb7); } +.scanActions button:last-child { + border-color: var(--shell-line); + background: transparent; +} + +.scanActions button:hover:not(:disabled) { + border-color: var(--shell-public, #8cafb7); +} + .scanActions button:disabled { cursor: not-allowed; opacity: 0.45; @@ -2892,41 +2933,53 @@ /* Real-mail folders and secondary type labels. */ .typeFilterNav { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 5px; - padding-top: 12px; - border-top: 0; + display: flex; + flex-direction: column; + gap: 3px; } .typeFilterNav > .sidebarLabel { - grid-column: 1 / -1; - margin: 0 4px 2px; + margin: 0 12px 5px; } .typeFilterNav button { display: flex; align-items: center; justify-content: space-between; - gap: 4px; + gap: 8px; min-width: 0; - padding: 6px 7px; + padding: 6px 12px; border: 0; - color: var(--shell-muted); - background: var(--shell-panel); + color: var(--shell-text); + background: transparent; font: inherit; - font-size: 10px; + font-size: 12px; + font-weight: 600; + text-align: left; cursor: pointer; } +.typeFilterNav button:hover { + background: var(--shell-panel); +} + .typeFilterNav button[aria-pressed="true"] { - color: var(--on-accent); - background: var(--shell-orange); + color: var(--shell-orange); + background: var(--shell-panel); + font-weight: 800; } -.typeFilterNav button strong { +.typeFilterNav button strong, +.mailboxNav button strong { + display: grid; + min-width: 24px; + height: 20px; + padding: 0 5px; + place-items: center; + border: 0; + background: var(--shell-bg); font-family: var(--font-mono-ui), monospace; - font-size: 10px; + font-size: 11px; } .attachmentTray { From d176514a06596461683bde5c578927793881df7a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 14:00:26 +0000 Subject: [PATCH 06/35] refactor(mail): make a rail row say four different things MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every row carried the same fact three times and its identity twice: - "Sealed sender" and "Sealed counterparty" differed only by envelope type, which says nothing about who sent a record — no incoming record carries a public sender. One name now covers all of them, and it drops the caption that restated it; the rail's standing note already says sealed rows have no public sender. - The preview line repeated the type badge directly under it ("Invoice · 12,400 USDC" over INVOICE). Previews now spend themselves on terms — both legs of an offer or escrow, the amount requested, the reason a deal was declined — and say plainly when a payload cannot be read instead of falling back to the badge's own word. - Read state and provenance shared one slot, so POSTED ON-CHAIN read as a third value of unread/opened. Provenance is a chip beside the type badge, and the state slot belongs to incoming mail. - A 66-character felt was the row's primary identity and wrapped to two lines, breaking the row rhythm; the rail uses the same shortened headline as the thread head, with the full felt on hover. In the thread, consecutive records had no boundary, so one message's "sealed on this device" footer read as a heading for the next. They are separated. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB --- src/components/mail/ConversationList.tsx | 73 +++++++++++++----------- src/components/mail/correspondent.ts | 18 ++---- src/components/mail/mail.module.css | 17 +++++- tests/ui/app20.localnet.spec.ts | 5 +- 4 files changed, 64 insertions(+), 49 deletions(-) diff --git a/src/components/mail/ConversationList.tsx b/src/components/mail/ConversationList.tsx index ab58f71..285ac97 100644 --- a/src/components/mail/ConversationList.tsx +++ b/src/components/mail/ConversationList.tsx @@ -21,6 +21,7 @@ import { } from "@/lib/escrow"; import { conversationCorrespondent, + correspondentHeadline, type ConversationCorrespondent, } from "./correspondent"; import { @@ -96,6 +97,17 @@ function envelopeLabel(message: LocalMailMessage): string { } } +/* The row already carries a type badge, so the preview line spends itself on + the terms instead of repeating the word underneath it. */ +const UNREADABLE_PAYLOAD = "Payload could not be read"; + +function amount(value: { + token: { symbol: string; decimals: number }; + amount: string; +}): string { + return `${formatBaseUnits(value.amount, value.token.decimals)} ${value.token.symbol}`; +} + function messagePreview(message: LocalMailMessage): string { const payload = message.envelope.type === "unsupported" ? null : message.envelope.payload; @@ -107,45 +119,43 @@ function messagePreview(message: LocalMailMessage): string { case "offer": { const offer = parseOfferPayload(payload); return offer - ? `Offer ${formatBaseUnits(offer.give.amount, offer.give.token.decimals)} ${offer.give.token.symbol}` - : "Deal offer"; + ? `${amount(offer.give)} for ${amount(offer.want)}` + : UNREADABLE_PAYLOAD; } case "accept": { const accept = parseAcceptPayload(payload); return accept - ? `Settlement memo · ${formatBaseUnits(accept.transfer.amount, accept.transfer.token.decimals)} ${accept.transfer.token.symbol}` - : "Settlement memo"; + ? `${amount(accept.transfer)} to settle` + : UNREADABLE_PAYLOAD; } case "decline": - return parseDeclinePayload(payload)?.reason || "Deal declined"; + return parseDeclinePayload(payload)?.reason || "No reason given"; case "receipt": return parseReceiptPayload(payload) - ? "Transfer receipt claim" - : "Receipt"; + ? "Claims a transfer landed" + : UNREADABLE_PAYLOAD; case "payment_request": { const request = parsePaymentRequestPayload(payload); - return request - ? `Invoice · ${formatBaseUnits(request.amount, request.token.decimals)} ${request.token.symbol}` - : "Invoice"; + return request ? `${amount(request)} requested` : UNREADABLE_PAYLOAD; } case "escrow_fund": { const fund = parseEscrowFundPayload(payload); return fund - ? `Escrow deal · ${formatBaseUnits(fund.legA.amount, fund.legA.token.decimals)} ${fund.legA.token.symbol}` - : "Escrow deal"; + ? `${amount(fund.legA)} against ${amount(fund.legB)}` + : UNREADABLE_PAYLOAD; } case "escrow_fill": return parseEscrowFillPayload(payload) - ? "Escrow fill notice" - : "Escrow update"; + ? "Counterparty filled the deal" + : UNREADABLE_PAYLOAD; case "escrow_claim": return parseEscrowClaimPayload(payload) - ? "Escrow claim notice" - : "Escrow update"; + ? "Counterparty claimed the escrow" + : UNREADABLE_PAYLOAD; case "escrow_timeout": return parseEscrowTimeoutPayload(payload) - ? "Escrow timeout notice" - : "Escrow update"; + ? "Escrow timed out and was refunded" + : UNREADABLE_PAYLOAD; case "contact_snapshot": return "Wallet + mailbox recovery phrase required"; case "backup_snapshot": @@ -273,7 +283,7 @@ function ConversationList({ - {correspondent.primary} + {correspondentHeadline(correspondent)} {correspondent.detail ? ( {correspondent.detail} @@ -284,19 +294,18 @@ function ConversationList({ {preview} {kind} - - {message.direction === "outgoing" - ? "POSTED ON-CHAIN" - : unread - ? "● UNREAD" - : message.envelope.type === "unsupported" - ? "UNSUPPORTED" - : "OPENED"} - + {message.direction === "outgoing" ? ( + Sent + ) : null} + {message.direction === "outgoing" ? null : ( + + {unread ? "● UNREAD" : "OPENED"} + + )} diff --git a/src/components/mail/correspondent.ts b/src/components/mail/correspondent.ts index c6a77be..67178f5 100644 --- a/src/components/mail/correspondent.ts +++ b/src/components/mail/correspondent.ts @@ -75,25 +75,17 @@ export function conversationCorrespondent( detail: "Encrypted self-backup · verify before restore", }; } - if (message.envelope.type === "text") { - return { - primary: - message.direction === "outgoing" - ? "Private recipient" - : "Sealed sender", - detail: - message.direction === "outgoing" - ? "Device-local Sent copy · recipient was not stored" - : "Sealed letter · no public sender", - }; - } if (message.direction === "outgoing") { return { primary: "Private recipient", detail: "Device-local Sent copy · recipient was not stored", }; } - return { primary: "Sealed counterparty" }; + /* One name for one fact. "Sealed sender" and "Sealed counterparty" differed + only by envelope type, which says nothing about who sent the record: no + incoming record carries a public sender, whatever it contains. The name + already says that, so it carries no caption restating it. */ + return { primary: "Sealed sender" }; } /** diff --git a/src/components/mail/mail.module.css b/src/components/mail/mail.module.css index 0da059d..91a302f 100644 --- a/src/components/mail/mail.module.css +++ b/src/components/mail/mail.module.css @@ -2145,7 +2145,8 @@ margin-top: auto; } -.typeBadge { +.typeBadge, +.provenanceBadge { padding: 3px 5px; border: 1px solid var(--shell-line); color: var(--shell-muted); @@ -2157,6 +2158,12 @@ text-transform: uppercase; } +/* Where a record came from, next to what it is — not in the read-state slot. */ +.provenanceBadge { + border-style: dashed; + font-family: var(--font-mono-ui), monospace; +} + .unreadIndicator, .readIndicator { margin-left: auto; @@ -2347,6 +2354,14 @@ .threadPanel .threadList { margin-top: 10px; + gap: 22px; +} + +/* Each record is one unit: without a boundary, a message's "sealed on this + device" footer read as a heading for the message below it. */ +.threadPanel .message + .message { + padding-top: 22px; + border-top: 1px solid var(--shell-line); } .threadPanel .message { diff --git a/tests/ui/app20.localnet.spec.ts b/tests/ui/app20.localnet.spec.ts index 9d579a1..0036893 100644 --- a/tests/ui/app20.localnet.spec.ts +++ b/tests/ui/app20.localnet.spec.ts @@ -596,9 +596,8 @@ test("all APP20 localnet journeys", async ({ await expect( page.getByText("1 recipient · posted", { exact: false }), ).toBeVisible(); - await expect(messageRow(page, compositeBody)).toContainText( - "POSTED ON-CHAIN", - ); + // Provenance moved next to the type badge; read state is incoming-only. + await expect(messageRow(page, compositeBody)).toContainText("Sent"); await screenshot(page, "08-composite-in-sent", testInfo); }); From dbaae33a0f9d3ad4b16dc0a8e4e792706759c0fd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 14:04:12 +0000 Subject: [PATCH 07/35] fix(mail): stop the reading pane from being squeezed, and phones from repeating themselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Between 1024px and 1180px the fixed 236px sidebar and 320px rail left the reading pane under 470px, so a letter wrapped at roughly 40 characters while the sidebar sat mostly empty. The sidebar becomes a drawer at 1180px instead, and the JS breakpoint that marks the drawer modal now matches the CSS one — they were 900px and 1023px apart, which left a closed drawer focusable off-screen in between. Back to messages is scoped to the phone layout, where the list is actually off-screen. On phones the top bar said "APP20 Mail" directly under a tab bar whose current tab says MAILBOX, above a rail heading that repeated the folder name — three rows of chrome for one fact, in a viewport that was already 40% chrome before the first message. The bar now names what the pane is showing, with the active type filter beside it, and the rail heading stands down. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB --- src/app/inbox/page.tsx | 14 +++++++++-- src/components/mail/mail.module.css | 37 +++++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/app/inbox/page.tsx b/src/app/inbox/page.tsx index 8eacf96..1573928 100644 --- a/src/app/inbox/page.tsx +++ b/src/app/inbox/page.tsx @@ -400,7 +400,9 @@ export default function InboxPage() { }, [address, chainId]); useEffect(() => { - const query = window.matchMedia("(max-width: 900px)"); + /* Matches the breakpoint where mail.module.css turns the sidebar into a + drawer; if these drift, a closed drawer stays focusable off-screen. */ + const query = window.matchMedia("(max-width: 1179px)"); const update = () => setMobileSidebarMode(query.matches); update(); query.addEventListener("change", update); @@ -3070,7 +3072,15 @@ export default function InboxPage() { > ☰ - APP20 Mail + {/* The app header's current tab already says MAILBOX. This row carries + what the pane is actually showing, so the phone layout does not + spend one of its few rows repeating the module name. */} + + {composerOpen ? "New document" : folderLabel} + {!composerOpen && mailboxFilter !== "all" ? ( + {filterLabel} + ) : null} + + ) : selectedMessage ? ( + ) : null} diff --git a/src/components/mail/DraftList.tsx b/src/components/mail/DraftList.tsx index 26fe622..b8a38b2 100644 --- a/src/components/mail/DraftList.tsx +++ b/src/components/mail/DraftList.tsx @@ -2,6 +2,7 @@ import { memo } from "react"; import type { CompositeDraft } from "@/lib/drafts"; +import { shortenFelt } from "./correspondent"; import styles from "./mail.module.css"; function draftTime(milliseconds: number): { label: string; dateTime: string } { @@ -75,7 +76,7 @@ function DraftList({ {drafts.length}
- {filterLabel} · local only + Local only Drafts are stored in this browser profile. They are never uploaded or included in mailbox scans. @@ -100,7 +101,11 @@ function DraftList({ onClick={() => onSelect(draft.id)} > - {draft.recipient.trim() || "No recipient"} + + + {shortenFelt(draft.recipient) || "No recipient"} + + @@ -114,7 +119,6 @@ function DraftList({ }` : "Letter"} - DRAFT
)} diff --git a/src/components/mail/Onboard.tsx b/src/components/mail/Onboard.tsx index d39ffdd..b2b954c 100644 --- a/src/components/mail/Onboard.tsx +++ b/src/components/mail/Onboard.tsx @@ -411,6 +411,13 @@ export default function Onboard({ helperAddress, onKeyReady }: OnboardProps) { Registration is a normal public Starknet transaction. It links this wallet address to a public mailbox key in the on-chain directory.

+ {address && chainId ? null : ( +

+ Connect a wallet before registering: a mailbox key is bound to the + wallet address that will send from it, which is why the button below + is disabled. +

+ )} {helperAddress ? null : (

Mailbox registration needs a helper configured for this APP20 rail. @@ -455,12 +462,23 @@ export default function Onboard({ helperAddress, onKeyReady }: OnboardProps) { />{" "} Encrypt this mailbox on this browser (optional) - - {wrapExisting - ? "scrypt + AES-GCM wrap. Mail cannot open the mailbox or use its signing key until you unlock this session, and never stores the passphrase. A wallet signature cannot be the wrap key — Ready signatures are not a stable secret." - : "The raw 32-byte mailbox seed is stored in the clear in this browser profile. Anyone with this profile can read your Mail correspondence and create payment requests that display as verified from you."} - + {wrapExisting ? ( + + scrypt + AES-GCM wrap. Mail cannot open the mailbox or use its + signing key until you unlock this session, and never stores the + passphrase. A wallet signature cannot be the wrap key — Ready + signatures are not a stable secret. + + ) : null} + {wrapExisting ? null : ( +

+ Leaving this off stores the raw 32-byte mailbox seed in the clear + in this browser profile. Anyone with the profile can read your + Mail correspondence and create payment requests that display as + verified from you. +

+ )}

Either way, the eight-group backup in step 3 is the only recovery if you clear this profile or forget the passphrase, and APP20 currently diff --git a/src/components/mail/correspondent.ts b/src/components/mail/correspondent.ts index 67178f5..f7a1cd5 100644 --- a/src/components/mail/correspondent.ts +++ b/src/components/mail/correspondent.ts @@ -89,16 +89,23 @@ export function conversationCorrespondent( } /** - * The same identity, shortened for a heading: a 66-character felt is a title - * that wraps to three lines and says nothing at a glance. + * A felt shortened for a row or a heading: at full length it wraps to two or + * three lines and says nothing at a glance that the first and last bytes do + * not. The full value stays available on hover and in the record itself. + */ +export function shortenFelt(value: string): string { + const felt = value.trim(); + if (felt.length <= 18) return felt; + return `${felt.slice(0, 10)}…${felt.slice(-6)}`; +} + +/** + * The same identity, shortened for a heading. */ export function correspondentHeadline( correspondent: ConversationCorrespondent, ): string { const { fullAddress } = correspondent; if (!fullAddress || fullAddress.length <= 18) return correspondent.primary; - return correspondent.primary.replace( - fullAddress, - `${fullAddress.slice(0, 10)}…${fullAddress.slice(-6)}`, - ); + return correspondent.primary.replace(fullAddress, shortenFelt(fullAddress)); } diff --git a/src/components/mail/mail.module.css b/src/components/mail/mail.module.css index e7354d0..330acf8 100644 --- a/src/components/mail/mail.module.css +++ b/src/components/mail/mail.module.css @@ -182,6 +182,15 @@ line-height: 1.6; } +/* Prose keeps a measure wherever it appears in the module. */ +.copy, +.finePrint, +.notice, +.actionWarning, +.restoreWarning { + max-width: var(--measure-reading); +} + .copy { margin-top: 20px; font-size: 14px; @@ -2285,10 +2294,30 @@ display: none; } -.closeCompose { +.closeCompose, +.toolbarReply { margin-left: auto; } +.toolbarReply { + flex: none; + padding: 7px 12px; + border: 1px solid var(--shell-orange); + color: var(--shell-orange); + background: transparent; + font: inherit; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; +} + +.toolbarReply:hover { + color: var(--on-accent); + background: var(--shell-orange); +} + .readingScroll { flex: 1; min-height: 0; From cff4111d8e7dd5718d2c78a9bd0c2e1f650a3525 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 14:11:14 +0000 Subject: [PATCH 09/35] refactor(mail): put a record's own line back to one line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every incoming record in a thread carried an open address-book form — label, book select, input and a submit button — between its meta line and the letter, for an act (naming a sealed sender on this device) that is occasional and deliberate. It collapses behind a disclosure, like the public record below it. The conversation note and the button that continues the conversation shared a stacked grid, so a record spent three lines on chrome before its first word. They share one line, and only the longer notes wrap onto their own. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB --- src/components/mail/Thread.tsx | 38 +++++++++++-------- src/components/mail/mail.module.css | 59 +++++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 19 deletions(-) diff --git a/src/components/mail/Thread.tsx b/src/components/mail/Thread.tsx index 8cf9fa7..5e4c8fb 100644 --- a/src/components/mail/Thread.tsx +++ b/src/components/mail/Thread.tsx @@ -370,23 +370,29 @@ function ConversationControls({ assignment. Supply the counterparty address in the draft.

) : null} + {/* Naming a sealed sender is a deliberate, occasional act. As an open + form it put an address book, a label and a submit button above every + letter in the thread. */} {message.direction !== "outgoing" && onAssign ? ( -
{ - event.preventDefault(); - if (assignInput.trim()) onAssign(message.id, assignInput); - }} - > - - - +
+ Assign a sender on this device +
{ + event.preventDefault(); + if (assignInput.trim()) onAssign(message.id, assignInput); + }} + > + + + +
) : null} {assigned && onProve ? ( ) : null} + {/* Dismissing is not this card's main action: with nothing payable + here it used to stretch across the row and read like one. */} - {PUBLIC_PRICE_RANGES.map((item) => ( +
+ {publicContextEnabled ? ( - ))} + ) : null} +
+ {PUBLIC_PRICE_RANGES.map((item) => ( + + ))} +
@@ -529,10 +532,19 @@ export default function DeskMarketBoard({ pairId }: DeskMarketBoardProps) { : "Loading public candlesticks…"} {priceState.kind === "idle" ? ( - - Opting in contacts CoinGecko directly and discloses your IP - address, user agent, request timing, and selected range. - + <> +

+ Opting in contacts CoinGecko directly and discloses your + IP address, user agent, request timing, and selected + range. +

+ + ) : null} {priceState.kind === "error" ? ( <> diff --git a/src/app/rfq/RfqRecoveryCard.tsx b/src/app/rfq/RfqRecoveryCard.tsx index 433a5d6..6a772f2 100644 --- a/src/app/rfq/RfqRecoveryCard.tsx +++ b/src/app/rfq/RfqRecoveryCard.tsx @@ -1,3 +1,5 @@ +import SelectWallet from "@/app/components/client/WalletHandle/SelectWallet"; +import { useStoreWallet } from "@/app/components/Wallet/walletContext"; import styles from "./rfq.module.css"; import type { WorkspaceLoadState } from "./workspace-load-state"; @@ -54,8 +56,12 @@ export default function RfqRecoveryCard({ busy?: boolean; onRetry?: () => void; }) { + const address = useStoreWallet((state) => state.address); const recovery = RECOVERY[loadState]; if (!recovery) return null; + /* Offline with no wallet at all, the honest control is the wallet itself: + "Review wallet connection" only re-read storage that cannot answer yet. */ + const askForWallet = loadState === "stale/offline" && !address; return (
Reported: {detail}

) : null} - {recovery.retryLabel && onRetry ? ( + {askForWallet ? ( +
+ +
+ ) : recovery.retryLabel && onRetry ? ( diff --git a/src/app/rfq/RfqWorkspace.tsx b/src/app/rfq/RfqWorkspace.tsx index e345bad..e6d78cc 100644 --- a/src/app/rfq/RfqWorkspace.tsx +++ b/src/app/rfq/RfqWorkspace.tsx @@ -1236,11 +1236,16 @@ export default function RfqWorkspace() { {view === "new" ? (

New request

- + {/* On a rail that cannot execute an RFQ at all, asking for the wallet + bound to saved records promises something connecting cannot + deliver. The environment's own notice below is the answer. */} + {executable ? ( + + ) : null}
) : ( -
+

Private RFQ unavailable

{providerIndex === 2 @@ -1286,6 +1294,7 @@ export default function RfqWorkspace() { className={styles.separateOperations} aria-label="Separate operations" > + Elsewhere Shield / unshield funding Public send · unavailable Mail · coordination only diff --git a/src/app/rfq/rfq-authority-ui.test.tsx b/src/app/rfq/rfq-authority-ui.test.tsx index 24ab6be..60eb182 100644 --- a/src/app/rfq/rfq-authority-ui.test.tsx +++ b/src/app/rfq/rfq-authority-ui.test.tsx @@ -303,7 +303,10 @@ describe("storage recovery", () => { const markup = renderToStaticMarkup( undefined} />, ); - expect(markup).toContain("Review wallet connection"); + // With no wallet at all, the card offers the wallet rather than a retry + // that re-reads storage which cannot answer yet. + expect(markup).toContain("Connect a wallet"); + expect(markup).toContain("Saved RFQs are tied to one account"); }); it("stays silent when records loaded normally", () => { diff --git a/src/app/rfq/rfq.module.css b/src/app/rfq/rfq.module.css index af95750..14d3cd5 100644 --- a/src/app/rfq/rfq.module.css +++ b/src/app/rfq/rfq.module.css @@ -560,9 +560,21 @@ } .publicMarketWorkspace { + min-height: 0; grid-template-columns: minmax(0, 1fr); } +/* With no ticket to place beside the board, the reason the ticket is missing + leads the view instead of trailing it at a third of the width. */ +.publicMarketWorkspace .tradeTicket { + width: 100%; + order: 0; +} + +.publicMarketWorkspace .tradeTicket > section { + width: 100%; +} + .marketWorkspace { display: flex; min-width: 0; @@ -643,9 +655,9 @@ .depthWithheld span, .marketSignals span { font-family: var(--font-mono-ui), monospace; - font-size: 8px; + font-size: 9px; font-weight: 700; - letter-spacing: 0.1em; + letter-spacing: 0.09em; text-transform: uppercase; } @@ -672,8 +684,8 @@ .marketStats small { color: var(--shell-muted); - font-size: 8px; - line-height: 1.25; + font-size: 9px; + line-height: 1.3; } .infoTip { @@ -698,7 +710,7 @@ place-items: center; border: 1px solid var(--shell-line); padding: 0; - color: var(--shell-orange); + color: var(--shell-muted); background: var(--shell-bg); cursor: help; font-family: var(--font-mono-ui), monospace; @@ -710,6 +722,7 @@ .infoTipButton:hover, .infoTipButton:focus-visible { border-color: var(--shell-orange); + color: var(--shell-orange); } .infoTipButton:focus-visible { @@ -821,11 +834,37 @@ background: transparent; cursor: pointer; font-family: var(--font-mono-ui), monospace; - font-size: 8px; + font-size: 9px; font-weight: 700; letter-spacing: 0.08em; } +/* The opt-in stands beside the range group instead of inside it. */ +.chartControls { + display: flex; + align-items: center; + gap: 8px; +} + +.publicContextToggle { + min-height: 28px; + border: 1px solid var(--shell-line); + padding: 0 10px; + color: var(--shell-muted); + background: transparent; + cursor: pointer; + font-family: var(--font-mono-ui), monospace; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.publicContextToggle:hover { + border-color: var(--shell-orange); + color: var(--shell-text); +} + .priceRangeTabs button + button { border-left: 1px solid var(--shell-line); } @@ -866,7 +905,7 @@ } .chartFrame { - min-height: 320px; + min-height: 200px; padding: 8px 10px 0; background: var(--shell-bg); } @@ -967,7 +1006,7 @@ .chartStatus { display: grid; - min-height: 300px; + min-height: 180px; place-content: center; justify-items: center; gap: 8px; @@ -988,19 +1027,24 @@ } .chartStatus > button { - min-height: 28px; + min-height: 34px; border: 1px solid var(--shell-orange); - padding: 0 12px; + padding: 0 14px; color: var(--shell-orange); background: transparent; cursor: pointer; font-family: var(--font-mono-ui), monospace; - font-size: 8px; + font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } +.chartStatus > button:hover { + color: var(--on-accent); + background: var(--shell-orange); +} + .chartFooter { display: flex; min-height: 34px; @@ -2493,20 +2537,60 @@ .recoveryCard h3 { margin: 0; - font-family: var(--font-reading), Georgia, serif; + font-family: var(--font-body), system-ui, sans-serif; font-size: 18px; - font-weight: 400; + font-weight: 800; + letter-spacing: -0.02em; } .recoveryCard p { + max-width: var(--measure-reading); margin: 0; font-size: 14px; + line-height: 1.55; } .recoveryCard button { justify-self: start; min-height: 44px; padding: 0 18px; + border: 1px solid var(--shell-line); + color: var(--shell-text); + background: transparent; + font: inherit; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.recoveryCard button:hover:not(:disabled) { + border-color: var(--shell-orange); +} + +.recoveryConnect { + justify-self: start; +} + +.recoveryConnect > button { + width: auto; + margin: 0; + min-height: 44px; + padding: 0 18px; + border: 1px solid var(--shell-orange); + border-radius: 0; + color: var(--on-accent); + background: var(--shell-orange); + font: inherit; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.07em; + text-transform: uppercase; + cursor: pointer; +} + +.recoveryConnect > button:hover { + color: var(--shell-orange); + background: transparent; } .activeCardAmount, @@ -2894,9 +2978,34 @@ opacity: 0.55; } +.unavailableNotice { + display: grid; + gap: 6px; + border: 1px solid var(--shell-line); + padding: 14px; + background: var(--shell-panel); +} + +.unavailableNotice h3 { + margin: 0; + font-family: var(--font-body), system-ui, sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: -0.02em; +} + +.unavailableNotice p { + max-width: var(--measure-reading); + margin: 0; + color: var(--shell-muted); + font-size: 13px; + line-height: 1.55; +} + .separateOperations { display: flex; flex-wrap: wrap; + align-items: center; gap: 8px; margin: 0 16px; border: 1px solid var(--shell-line); @@ -2904,6 +3013,22 @@ background: var(--shell-panel); } +/* This row is navigation, and read as a strip of status chips. */ +.separateOperations > span { + color: var(--shell-muted); + font-family: var(--font-mono-ui), monospace; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.separateOperations a::after { + margin-left: 7px; + color: var(--shell-orange); + content: "→"; +} + .separateOperations a { min-height: 38px; display: inline-flex; From f65d039d5b9ed2f58c16812a28d7ecece43ee532 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 15:33:04 +0000 Subject: [PATCH 16/35] fix(rfq): make the decision surfaces readable and honest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen where a person commits value had two buttons that fell back to the browser's grey default, so "Accept and fund" — the commit action — looked disabled next to an equally weighted decline. Accept is the accent action, decline is a ghost, and the details disclosure uses the desk's +/- instead of a stray triangle. Amounts read 25000 STRK; they now read 25,000 STRK, from one shared helper rather than two copies of the same function in two files. The review's fact labels join the mono label language of the tiles above them, its values and its privacy list keep a measure instead of running the width of the sheet, and the invited-maker panel's directory facts became a hairline grid rather than six unaligned lines. A maker excluded because the whole directory is stale was labelled "Excluded" beside its own positive rationale — "Excluded · Signed a verified quote for this exact request". The card now gives the binding reason, and keeps the maker's own words for the makers that actually refused. The operations dashboard painted an empty cell whenever a section had an odd number of facts, and set its labels in body sans; the last fact spans the row and the labels are mono. On phones the workspace sub-nav went back to 8px. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB --- src/app/rfq/MakerCohortPanel.tsx | 42 +++++++- src/app/rfq/QuoteComparison.tsx | 9 +- src/app/rfq/RfqFinalReview.tsx | 9 +- src/app/rfq/human-units.ts | 13 +++ src/app/rfq/rfq.module.css | 171 ++++++++++++++++++++++++++++++- 5 files changed, 222 insertions(+), 22 deletions(-) create mode 100644 src/app/rfq/human-units.ts diff --git a/src/app/rfq/MakerCohortPanel.tsx b/src/app/rfq/MakerCohortPanel.tsx index adbc5e8..8e6d3ec 100644 --- a/src/app/rfq/MakerCohortPanel.tsx +++ b/src/app/rfq/MakerCohortPanel.tsx @@ -37,6 +37,34 @@ function keyPresentation(maker: BrowserSafeMakerStatus, now: number): string { return "Valid"; } +/** + * Why a maker is excluded. The maker's own rationale explains their answer, + * which is not the binding reason when the panel excludes everyone — pairing + * "Excluded" with "Signed a verified quote for this exact request" reads as a + * contradiction rather than as a stale directory. + */ +function exclusionReason( + maker: BrowserSafeMakerStatus, + context: { + keyEligible: boolean; + freshness: ReturnType | "unavailable"; + quoted: boolean; + quoting: boolean; + }, +): string { + if (!maker.eligible) return maker.rationale; + if (!context.keyEligible) { + return "Maker key is not valid for this request."; + } + if (context.freshness !== "fresh") { + return "Maker directory is not fresh, so no maker is eligible."; + } + if (context.quoting && !context.quoted) { + return "No signed quote for this request."; + } + return maker.rationale; +} + export default function MakerCohortPanel({ makers, directory, @@ -74,14 +102,14 @@ export default function MakerCohortPanel({ ? directoryFreshnessState(directory, now) : "unavailable"; return ( -

+

Invited-maker cohort

Governed makers {summary.governed} · invited {summary.invited} · responded {summary.responded} · refused {summary.refused} · unavailable{" "} {summary.unavailable}.

-
+
Maker-directory epoch
{directory?.epoch ?? "Unavailable"}
@@ -196,7 +224,15 @@ export default function MakerCohortPanel({ : "Eligible" : "Excluded"} {" "} - · {maker.rationale} + ·{" "} + {eligible + ? maker.rationale + : exclusionReason(maker, { + keyEligible, + freshness, + quoted: Boolean(quote), + quoting: quotes.length > 0, + })}
diff --git a/src/app/rfq/QuoteComparison.tsx b/src/app/rfq/QuoteComparison.tsx index 940044f..16f4782 100644 --- a/src/app/rfq/QuoteComparison.tsx +++ b/src/app/rfq/QuoteComparison.tsx @@ -7,16 +7,9 @@ import type { LocalnetQuoteRefusalV3 } from "./localnet-private-intents"; import type { QuoteComparisonV3Row } from "./rfq-v3-selection"; import CopyableId from "./CopyableId"; import RfqCountdown from "./RfqCountdown"; +import { humanUnits } from "./human-units"; import styles from "./rfq.module.css"; -function humanUnits(value: bigint, decimals: number): string { - if (decimals === 0) return value.toString(); - const digits = value.toString().padStart(decimals + 1, "0"); - const whole = digits.slice(0, -decimals) || "0"; - const fraction = decimals ? digits.slice(-decimals).replace(/0+$/, "") : ""; - return fraction ? `${whole}.${fraction}` : whole; -} - function unitPrice( valueE18: bigint, sellDecimals: number, diff --git a/src/app/rfq/RfqFinalReview.tsx b/src/app/rfq/RfqFinalReview.tsx index 0e67ac3..1205bd7 100644 --- a/src/app/rfq/RfqFinalReview.tsx +++ b/src/app/rfq/RfqFinalReview.tsx @@ -9,16 +9,9 @@ import { } from "./rfq-final-review"; import CopyableId from "./CopyableId"; import RfqCountdown from "./RfqCountdown"; +import { humanUnits } from "./human-units"; import styles from "./rfq.module.css"; -function humanUnits(value: bigint, decimals: number): string { - if (decimals === 0) return value.toString(); - const digits = value.toString().padStart(decimals + 1, "0"); - const whole = digits.slice(0, -decimals) || "0"; - const fraction = decimals ? digits.slice(-decimals).replace(/0+$/, "") : ""; - return fraction ? `${whole}.${fraction}` : whole; -} - export type RfqFinalReviewV3DisplayTerms = RfqFinalReviewV3Terms & Readonly<{ sellSymbol: string; diff --git a/src/app/rfq/human-units.ts b/src/app/rfq/human-units.ts new file mode 100644 index 0000000..ac68309 --- /dev/null +++ b/src/app/rfq/human-units.ts @@ -0,0 +1,13 @@ +/** + * Base units rendered for a person: the integer part grouped with literal + * commas, the fraction never grouped. The separator is fixed rather than + * locale-formatted so a decimal point cannot become a group mark elsewhere. + */ +export function humanUnits(value: bigint, decimals: number): string { + const digits = value.toString().padStart(decimals + 1, "0"); + const whole = decimals === 0 ? value.toString() : digits.slice(0, -decimals); + const grouped = (whole || "0").replace(/\B(?=(\d{3})+(?!\d))/g, ","); + if (decimals === 0) return grouped; + const fraction = digits.slice(-decimals).replace(/0+$/, ""); + return fraction ? `${grouped}.${fraction}` : grouped; +} diff --git a/src/app/rfq/rfq.module.css b/src/app/rfq/rfq.module.css index 14d3cd5..ba0f810 100644 --- a/src/app/rfq/rfq.module.css +++ b/src/app/rfq/rfq.module.css @@ -45,6 +45,34 @@ background: var(--shell-bg); } +/* An odd final row left the grid's own rule colour showing as an empty + painted cell beside the last fact. */ +.operationsDashboard dl > div:last-child:nth-child(odd) { + grid-column: 1 / -1; +} + +.operationsDashboard dt { + color: var(--shell-muted); + font-family: var(--font-mono-ui), monospace; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.operationsDashboard dd { + margin: 4px 0 0; + font-size: 13px; + line-height: 1.5; +} + +.operationsDashboard > header p, +.operationsDashboard > p, +.operationsDashboard section > p { + max-width: var(--measure-reading); + line-height: 1.55; +} + .operationsDashboard code, .operationsDashboard td { overflow-wrap: anywhere; @@ -2090,7 +2118,7 @@ } .finalReview dd { - font-family: var(--font-sans-ui), sans-serif; + font-family: var(--font-body), system-ui, sans-serif; font-size: 14px; } @@ -2626,15 +2654,33 @@ .activityRecord summary, .quoteComparison summary, .finalReviewDetails summary { + display: flex; + align-items: center; + justify-content: space-between; cursor: pointer; min-height: 44px; padding: 12px 0; font-family: var(--font-mono-ui), monospace; font-size: 11px; letter-spacing: 0.06em; + list-style: none; text-transform: uppercase; } +.finalReviewDetails summary::-webkit-details-marker { + display: none; +} + +.finalReviewDetails summary::after { + color: var(--shell-orange); + content: "+"; + font-size: 15px; +} + +.finalReviewDetails[open] summary::after { + content: "\2212"; +} + .activityRecord { display: grid; gap: 8px; @@ -2691,6 +2737,57 @@ background: var(--shell-panel); } +.cohortPanel { + display: grid; + gap: 10px; +} + +.cohortPanel > h3 { + margin: 0; + font-family: var(--font-body), system-ui, sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: -0.02em; +} + +.cohortPanel > p { + max-width: var(--measure-reading); + margin: 0; + color: var(--shell-muted); + font-size: 12px; + line-height: 1.5; +} + +.cohortFacts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1px; + margin: 0; + background: var(--shell-line); +} + +.cohortFacts > div { + min-width: 0; + padding: 8px 10px; + background: var(--shell-bg); +} + +.cohortFacts dt { + color: var(--shell-muted); + font-family: var(--font-mono-ui), monospace; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.cohortFacts dd { + margin: 3px 0 0; + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; +} + .makerCohort { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); @@ -2941,6 +3038,50 @@ gap: 10px; } +.finalReviewSummary dt { + color: var(--shell-muted); + font-family: var(--font-mono-ui), monospace; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.09em; + line-height: 1.9; + text-transform: uppercase; +} + +.finalReviewSummary dd { + max-width: var(--measure-reading); + margin: 0; + line-height: 1.5; +} + +/* The privacy list is prose and was running the full width of the sheet. */ +.finalReview h4 { + margin: 6px 0 0; + font-family: var(--font-mono-ui), monospace; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--shell-muted); +} + +.finalReview > ul { + display: grid; + gap: 6px; + max-width: var(--measure-reading); + margin: 0; + padding: 0; + list-style: none; +} + +.finalReview > ul li { + padding-left: 12px; + border-left: 1px solid var(--shell-line); + color: var(--shell-muted); + font-size: 12px; + line-height: 1.5; +} + .finalReviewWarning { margin: 0; color: var(--shell-orange); @@ -2968,8 +3109,31 @@ .finalReviewAccept { border: 1px solid var(--shell-orange); + color: var(--on-accent); + background: var(--shell-orange); + font: inherit; + font-size: 13px; + font-weight: 800; + cursor: pointer; +} + +.finalReviewAccept:hover:not(:disabled) { color: var(--shell-orange); + background: transparent; +} + +.finalReviewDecline { + border: 1px solid var(--shell-line); + color: var(--shell-text); + background: transparent; + font: inherit; + font-size: 13px; font-weight: 700; + cursor: pointer; +} + +.finalReviewDecline:hover:not(:disabled) { + border-color: var(--shell-text); } .finalReviewAccept:disabled, @@ -3393,8 +3557,9 @@ min-height: 40px; flex: 1; justify-content: center; - padding: 0 6px; - font-size: 8px; + padding: 0 4px; + font-size: 9px; + letter-spacing: 0.06em; } .viewHeading { From bcd85b4ccaa7c229d86ee50a96b4bc2af5b14ba1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 15:38:06 +0000 Subject: [PATCH 17/35] fix(rfq): let a record say what the trade is before it says how exact it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An in-flight RFQ led with "25000000000000000000000 base units STRK → 12400000000 base units USDC" — a 23-digit integer as the headline of a trade card. The record now reads 25,000 STRK → 12,400 USDC, with the exact integers kept one rank below in muted mono, so nothing is lost and the line can be read. The action on that card was another browser-default grey button, with the sentence explaining it pinned to the far end of a 1000px row; they sit together, and the button uses the desk's outline treatment with a real disabled state. Every disclosure in the workspace now carries the same +/- marker: sharing a selector with the final review's summary had left three of them with no affordance at all. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB --- src/app/rfq/RfqActiveCard.tsx | 38 +++++++++++++++---- src/app/rfq/RfqActivity.tsx | 21 ++++++++--- src/app/rfq/rfq.module.css | 71 +++++++++++++++++++++++++++++++++-- 3 files changed, 114 insertions(+), 16 deletions(-) diff --git a/src/app/rfq/RfqActiveCard.tsx b/src/app/rfq/RfqActiveCard.tsx index 9d9510e..2ae0121 100644 --- a/src/app/rfq/RfqActiveCard.tsx +++ b/src/app/rfq/RfqActiveCard.tsx @@ -18,8 +18,17 @@ import CopyableId, { } from "./CopyableId"; import RfqPhaseAction from "./RfqPhaseAction"; import { useRfqPresentationClock } from "./ui/rfq-presentation-clock"; +import { humanUnits } from "./human-units"; import styles from "./rfq.module.css"; +function safeBigInt(value: string): bigint | undefined { + try { + return BigInt(value); + } catch { + return undefined; + } +} + const LEGACY_PHASES: readonly RfqLifecycleAttemptPhase[] = [ "funding", "fill", @@ -110,6 +119,10 @@ export default function RfqActiveCard({ : "Actions are disabled until records reload successfully for the current account, chain, and LOCAL provider.", }) : selected; + const receivedRaw = + record.selectedQuote?.buyAmount ?? record.terms?.buyAmount; + const receivedAmount = + receivedRaw === undefined ? undefined : safeBigInt(receivedRaw); return (
{rfqStateLabel(record.state, record.mode)} {record.terms ? ( -

- {record.terms.sellAmount} base units {record.terms.sellSymbol} →{" "} - {record.selectedQuote?.buyAmount ?? - record.terms.buyAmount ?? - "unselected"}{" "} - base units {record.terms.buySymbol} -

+
+ {/* A 23-digit integer is the exact record, not something a person + can read at a glance; it stays, one rank below the human units. */} +

+ {humanUnits( + BigInt(record.terms.sellAmount), + record.terms.sellDecimals, + )}{" "} + {record.terms.sellSymbol} →{" "} + {receivedAmount === undefined + ? "unselected" + : `${humanUnits(receivedAmount, record.terms.buyDecimals)} ${record.terms.buySymbol}`} +

+

+ exactly {record.terms.sellAmount} →{" "} + {receivedAmount?.toString() ?? "unselected"} base units +

+
) : null} {record.mode === "v3" ? (
diff --git a/src/app/rfq/RfqActivity.tsx b/src/app/rfq/RfqActivity.tsx index e22ccd2..aec8f5a 100644 --- a/src/app/rfq/RfqActivity.tsx +++ b/src/app/rfq/RfqActivity.tsx @@ -11,8 +11,20 @@ import { AuthorityStrip } from "./RfqAuthorityStrip"; import RfqRecoveryCard from "./RfqRecoveryCard"; import { useRfqAuthorityPresentation } from "./ui/use-rfq-authority-presentation"; import type { WorkspaceLoadState } from "./workspace-load-state"; +import { humanUnits } from "./human-units"; import styles from "./rfq.module.css"; +/** The received leg in human units, or why there is not one yet. */ +function receivedLabel(record: RfqLifecycleRecord): string { + const raw = record.selectedQuote?.buyAmount ?? record.terms?.buyAmount; + if (!record.terms || raw === undefined) return "unselected"; + try { + return `${humanUnits(BigInt(raw), record.terms.buyDecimals)} ${record.terms.buySymbol}`; + } catch { + return "unselected"; + } +} + function activityBucket(record: RfqLifecycleRecord): string | undefined { if (record.mode !== "v3" || !record.bucket || !record.terms) return undefined; if (record.terms.sellSymbol !== "STRK" && record.terms.sellSymbol !== "USDC") { @@ -52,12 +64,9 @@ function RfqActivityRecord({ {heading} · {rfqStateLabel(record.state, record.mode)} {record.terms ? ( -

- {record.terms.sellAmount} base units {record.terms.sellSymbol} →{" "} - {record.selectedQuote?.buyAmount ?? - record.terms.buyAmount ?? - "unselected"}{" "} - base units {record.terms.buySymbol} +

+ {humanUnits(BigInt(record.terms.sellAmount), record.terms.sellDecimals)}{" "} + {record.terms.sellSymbol} → {receivedLabel(record)}

) : null} {record.mode === "v3" ? ( diff --git a/src/app/rfq/rfq.module.css b/src/app/rfq/rfq.module.css index ba0f810..30f8ebd 100644 --- a/src/app/rfq/rfq.module.css +++ b/src/app/rfq/rfq.module.css @@ -146,8 +146,7 @@ background: var(--shell-panel); } -.activeCard header, -.phaseAction { +.activeCard header { display: flex; flex-wrap: wrap; align-items: center; @@ -155,6 +154,47 @@ gap: 8px; } +.phaseAction { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +/* The action of an in-flight record fell back to the browser's grey button, + and its reason sat at the far end of a 1000px row away from it. */ +.phaseAction button { + min-height: 40px; + border: 1px solid var(--shell-orange); + padding: 0 14px; + color: var(--shell-orange); + background: transparent; + font: inherit; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.phaseAction button:hover:not(:disabled) { + color: var(--on-accent); + background: var(--shell-orange); +} + +.phaseAction button:disabled { + border-color: var(--shell-line); + color: var(--shell-muted); + cursor: not-allowed; +} + +.phaseAction small { + flex: 1 1 240px; + min-width: 0; + max-width: var(--measure-reading); + color: var(--shell-muted); + font-size: 11px; + line-height: 1.45; +} + .activeCard dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -2621,11 +2661,27 @@ background: transparent; } +.recordAmounts { + display: grid; + gap: 3px; +} + +/* What the trade is, at a glance; the exact integer sits one rank below. */ +.recordAmountHuman { + margin: 0; + font-family: var(--font-mono-ui), monospace; + font-size: 17px; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; + overflow-wrap: anywhere; +} + .activeCardAmount, .activityAmount { margin: 0; + color: var(--shell-muted); font-family: var(--font-mono-ui), monospace; - font-size: 12px; + font-size: 10px; overflow-wrap: anywhere; } @@ -2667,16 +2723,25 @@ text-transform: uppercase; } +.activeCardDetails summary::-webkit-details-marker, +.activityRecord summary::-webkit-details-marker, +.quoteComparison summary::-webkit-details-marker, .finalReviewDetails summary::-webkit-details-marker { display: none; } +.activeCardDetails summary::after, +.activityRecord summary::after, +.quoteComparison summary::after, .finalReviewDetails summary::after { color: var(--shell-orange); content: "+"; font-size: 15px; } +.activeCardDetails[open] summary::after, +.activityRecord details[open] summary::after, +.quoteComparison[open] summary::after, .finalReviewDetails[open] summary::after { content: "\2212"; } From df54d484580e8fbd6a04b243a3ac4c8b8897282a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 15:39:24 +0000 Subject: [PATCH 18/35] chore: drop a screenshot scratch script committed by mistake .scratch-shot.mjs is a throwaway Playwright screenshotter used while reviewing the RFQ views; it was swept into the frame commit by a directory-wide add and is not part of the app. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB --- .scratch-shot.mjs | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .scratch-shot.mjs diff --git a/.scratch-shot.mjs b/.scratch-shot.mjs deleted file mode 100644 index 1769d49..0000000 --- a/.scratch-shot.mjs +++ /dev/null @@ -1,11 +0,0 @@ -import { chromium } from "playwright"; -const [,, url, out, w = "1440", h = "900", full = ""] = process.argv; -const browser = await chromium.launch({ executablePath: "/opt/pw-browsers/chromium-1194/chrome-linux/chrome" }); -const page = await browser.newPage({ viewport: { width: Number(w), height: Number(h) } }); -const errors = []; -page.on("pageerror", (e) => errors.push(String(e).slice(0, 140))); -await page.goto(url, { waitUntil: "networkidle" }); -await page.waitForTimeout(1500); -await page.screenshot({ path: out, fullPage: full === "full" }); -console.log("errors:", errors.slice(0, 4).join("\n")); -await browser.close(); From 0d7021e4f1f1a7ac5732474eb7f218ff39647a4e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 15:42:39 +0000 Subject: [PATCH 19/35] fix(contacts): style the masthead the page was already asking for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Counterparties imported .masthead, .mastheadCopy and .eyebrow from the RFQ module and none of them existed, so the page title, its eyebrow and its intro resolved to no class at all: they rendered unstyled and ran from the window edge while the panel beneath them was inset. They get the desk's masthead — a gutter, a display-weight headline, and a measure on the intro — and the fine print reads at the same text column instead of the full window. The empty book said a wallet was required without offering one; it carries the wallet control, like the mail and RFQ gates. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB --- src/app/contacts/page.tsx | 7 +++- src/app/rfq/rfq.module.css | 76 +++++++++++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/app/contacts/page.tsx b/src/app/contacts/page.tsx index 873d0c6..2413107 100644 --- a/src/app/contacts/page.tsx +++ b/src/app/contacts/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { Link } from "@tanstack/react-router"; +import SelectWallet from "@/app/components/client/WalletHandle/SelectWallet"; import { useActiveStarknetSession } from "@/app/active-session"; import { storeDeskHandoff } from "@/lib/desk-handoff"; import { MAIL_RECOVERY_PHRASE_AUTHORITY_NOTICE } from "@/lib/mail-authority-copy"; @@ -243,8 +244,12 @@ function AddressBookPanel({
Wallet required

- Connect a wallet to open this account's encrypted book. + The book is encrypted under the connected account, so it opens + only once a wallet is connected.

+ + +
)}
diff --git a/src/app/rfq/rfq.module.css b/src/app/rfq/rfq.module.css index 30f8ebd..1cd3f4d 100644 --- a/src/app/rfq/rfq.module.css +++ b/src/app/rfq/rfq.module.css @@ -1354,9 +1354,9 @@ .bookField > span { color: var(--shell-muted); font-family: var(--font-mono-ui), monospace; - font-size: 9px; + font-size: 10px; font-weight: 700; - letter-spacing: 0.12em; + letter-spacing: 0.1em; } .bookEmptyBlock { @@ -1372,6 +1372,28 @@ font-size: 13px; } +.bookEmptyBlock .connectAction > button { + margin-top: 4px; + min-height: 40px; + width: auto; + border: 1px solid var(--shell-orange); + border-radius: 0; + padding: 0 16px; + color: var(--on-accent); + background: var(--shell-orange); + font: inherit; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.07em; + text-transform: uppercase; + cursor: pointer; +} + +.bookEmptyBlock .connectAction > button:hover { + color: var(--shell-orange); + background: transparent; +} + .bookEmptyBlock .bookEmpty { max-width: 62ch; } @@ -2478,13 +2500,57 @@ color: var(--shell-live); } +/* Counterparties imported .masthead, .mastheadCopy and .eyebrow from this + module, and none of them existed: the page title, its eyebrow and its + intro rendered unstyled and ran from the window edge. */ +.masthead { + padding: 16px 16px 4px; +} + +.mastheadCopy { + display: grid; + gap: 8px; + max-width: 780px; +} + +.mastheadCopy h1 { + margin: 0; + font-family: var(--font-body), system-ui, sans-serif; + font-size: clamp(26px, 3vw, 38px); + font-weight: 800; + letter-spacing: -0.045em; + line-height: 1.05; + text-wrap: pretty; +} + +.mastheadCopy > span { + display: block; + max-width: var(--measure-copy); + color: var(--shell-muted); + font-size: 14px; + line-height: 1.6; +} + +.eyebrow { + margin: 0; + color: var(--shell-orange); + font-family: var(--font-mono-ui), monospace; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +/* Fine print reads at the page's text column, not at a body measure that + turns 11px into a fourteen-line ribbon. */ .disclosure { + max-width: 780px; margin: 0; - padding: 8px 14px; + padding: 12px 16px 16px; color: var(--shell-muted); background: var(--shell-bg); - font-size: 10px; - line-height: 1.5; + font-size: 11px; + line-height: 1.6; } .privyFallback { From 8f063a22d7fd9839a209a3215584ce6ca34367b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 15:44:44 +0000 Subject: [PATCH 20/35] test: fail the build when a CSS-module class does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An undefined CSS-module class resolves to `undefined`: the element renders with no styling and the literal word "undefined" in its class list, and nothing catches it — not the type checker, not the bundler. Counterparties lost its whole masthead that way, and the Privy vault's recipient field carried a modifier that was never written. The guard scans every component that imports a stylesheet and asserts each styles.x it uses is defined there. The vault's dead reference is removed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB --- src/app/css-module-classes.test.ts | 53 ++++++++++++++++++++++++++++++ src/app/rfq/PrivySepoliaVault.tsx | 2 +- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 src/app/css-module-classes.test.ts diff --git a/src/app/css-module-classes.test.ts b/src/app/css-module-classes.test.ts new file mode 100644 index 0000000..9e600ba --- /dev/null +++ b/src/app/css-module-classes.test.ts @@ -0,0 +1,53 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * A CSS-module class that does not exist resolves to `undefined`: the element + * renders with no styling at all and the word "undefined" in its class list, + * with nothing to notice at build time. Two such references had left a page + * masthead and a form field unstyled before this guard existed. + */ +function componentFiles(): string[] { + return execFileSync("git", ["ls-files", "src/**/*.tsx"], { + encoding: "utf8", + }) + .split("\n") + .filter(Boolean); +} + +function definedClasses(cssPath: string): Set { + const text = readFileSync(cssPath, "utf8"); + const names = new Set(); + for (const match of text.matchAll(/\.([A-Za-z_][\w-]*)/g)) names.add(match[1]); + return names; +} + +function resolveImport(fromFile: string, specifier: string): string { + if (specifier.startsWith("@/")) return resolve("src", specifier.slice(2)); + return resolve(dirname(fromFile), specifier); +} + +describe("CSS module references", () => { + it("only uses classes its stylesheet defines", () => { + const undefinedReferences: string[] = []; + for (const file of componentFiles()) { + const source = readFileSync(file, "utf8"); + const importMatch = source.match( + /import\s+styles\s+from\s+"([^"]+\.css)"/, + ); + if (!importMatch) continue; + const defined = definedClasses(resolveImport(file, importMatch[1])); + const used = new Set( + [...source.matchAll(/styles\.([A-Za-z_][\w]*)/g)].map( + (match) => match[1], + ), + ); + for (const name of used) { + if (!defined.has(name)) undefinedReferences.push(`${file}: ${name}`); + } + } + expect(undefinedReferences).toEqual([]); + }); +}); diff --git a/src/app/rfq/PrivySepoliaVault.tsx b/src/app/rfq/PrivySepoliaVault.tsx index 2c93a9c..468b8c3 100644 --- a/src/app/rfq/PrivySepoliaVault.tsx +++ b/src/app/rfq/PrivySepoliaVault.tsx @@ -882,7 +882,7 @@ function PrivySepoliaVaultContent() { /> Date: Fri, 4 Sep 2026 15:47:22 +0000 Subject: [PATCH 21/35] style(funding): bring the readiness gate into the desk's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The funding gate was the last panel written outside the design system: its eyebrow was a plain paragraph, its four readiness facts were body-sans label and value pairs floating in a gap-12 grid with no rules, its section headings were browser h3s, and every sentence ran the full width of a 1280px window. It now reads like the rest of the desk — a mono eyebrow, a display-weight headline, a hairline fact grid with mono labels, mono section labels, and a measure on the prose — without changing a single statement it makes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB --- src/app/funding/FundingReadinessPanel.tsx | 2 +- src/app/globals.css | 59 +++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/app/funding/FundingReadinessPanel.tsx b/src/app/funding/FundingReadinessPanel.tsx index c976b6f..e3d9da0 100644 --- a/src/app/funding/FundingReadinessPanel.tsx +++ b/src/app/funding/FundingReadinessPanel.tsx @@ -242,7 +242,7 @@ export function FundingReadinessPanelView({ style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", - gap: 12, + gap: 1, }} >
diff --git a/src/app/globals.css b/src/app/globals.css index 75d1a65..6de6986 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -483,6 +483,65 @@ a { overflow-wrap: anywhere; } +/* The funding gate was the one panel still outside the desk's language: a + plain paragraph for its eyebrow, body-sans labels on its facts, and every + sentence running the full width of the window. */ +.funding-readiness-panel > p:first-child { + color: var(--shell-orange); + font-family: var(--font-mono-ui), monospace; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.funding-readiness-panel h2 { + margin: 6px 0 0; + font-size: 20px; + font-weight: 800; + letter-spacing: -0.03em; +} + +.funding-readiness-panel h3 { + margin: 18px 0 6px; + font-family: var(--font-mono-ui), monospace; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--shell-muted); +} + +.funding-readiness-panel p, +.funding-readiness-panel li { + max-width: var(--measure-reading); + line-height: 1.55; +} + +.funding-readiness-panel dl { + border: 1px solid var(--shell-line); + background: var(--shell-line); +} + +.funding-readiness-panel dl > div { + padding: 9px 11px; + background: var(--shell-bg); +} + +.funding-readiness-panel dt { + color: var(--shell-muted); + font-family: var(--font-mono-ui), monospace; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.funding-readiness-panel dd { + margin: 4px 0 0; + font-size: 13px; +} + .panel-heading { display: grid; gap: 5px; From fdf186502d95e298f68e1742ddcebc84e3d29e58 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 16:21:50 +0000 Subject: [PATCH 22/35] feat(mail): keep opened state across reloads, and let a mailbox be searched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read state lived in React state alone, so a refresh marked an entire mailbox unread again and the rail's own note admitted it in small print. It now persists per chain and account in this browser, bounded so a long-lived mailbox cannot grow it without limit, cleared by Forget this device along with every other local record, and written at the moment a record is opened rather than from an effect that could race an account switch and file one account's history under another's key. The mailbox also had no search at all: with sealed senders, felt addresses and fourteen record types, fifty records were unnavigable. The rail searches what this device has already decrypted — correspondent, preview, record type, index and device-local recipients — and says so, since a query can only reach the plaintext this browser holds. Nothing about a search leaves the device. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB --- src/app/inbox/page.tsx | 31 +++++- src/components/mail/ConversationList.test.ts | 30 ++++++ src/components/mail/ConversationList.tsx | 104 +++++++++++++++---- src/components/mail/mail.module.css | 29 ++++++ src/lib/local-mailbox-storage.ts | 2 + src/lib/mail-read-state.test.ts | 64 ++++++++++++ src/lib/mail-read-state.ts | 57 ++++++++++ 7 files changed, 293 insertions(+), 24 deletions(-) create mode 100644 src/lib/mail-read-state.test.ts create mode 100644 src/lib/mail-read-state.ts diff --git a/src/app/inbox/page.tsx b/src/app/inbox/page.tsx index 063a6f1..345ba25 100644 --- a/src/app/inbox/page.tsx +++ b/src/app/inbox/page.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { hash, validateAndParseAddress } from "starknet"; import { useFrontendProvider } from "@/app/components/client/provider/providerContext"; import { useStoreWallet } from "@/app/components/Wallet/walletContext"; +import { loadReadMessageIds, saveReadMessageIds } from "@/lib/mail-read-state"; import SelectWallet from "@/app/components/client/WalletHandle/SelectWallet"; import Compose, { type SentEnvelope } from "@/components/mail/Compose"; import ConversationList from "@/components/mail/ConversationList"; @@ -292,6 +293,19 @@ export default function InboxPage() { const [readMessageIds, setReadMessageIds] = useState>( () => new Set(), ); + const [mailboxSearch, setMailboxSearch] = useState(""); + + /* Read state belongs to this device and this account: it is loaded when the + account is known and written back as records are opened, so a reload no + longer marks a whole mailbox unread. */ + useEffect(() => { + setReadMessageIds( + chainId && address + ? loadReadMessageIds(window.localStorage, chainId, address) + : new Set(), + ); + }, [address, chainId]); + const [mailFolder, setMailFolder] = useState("inbox"); const [mailboxFilter, setMailboxFilter] = useState("all"); const [drafts, setDrafts] = useState([]); @@ -544,7 +558,6 @@ export default function InboxPage() { setScanProgress({ pages: 0, events: 0, maxPages: MAIL_SCAN_MAX_PAGES }); setSelectedMessageId(null); activatedMessageIdRef.current = null; - setReadMessageIds(new Set()); setComposerOpen(false); setMobileDetailOpen(false); setSidebarOpen(false); @@ -823,11 +836,21 @@ export default function InboxPage() { if (current.has(messageId)) return current; const next = new Set(current); next.add(messageId); + if (chainId && address) { + saveReadMessageIds(window.localStorage, chainId, address, next); + } return next; }); }); return () => window.cancelAnimationFrame(frame); - }, [composerOpen, messageActivation, mobileDetailOpen, selectedMessageId]); + }, [ + address, + chainId, + composerOpen, + messageActivation, + mobileDetailOpen, + selectedMessageId, + ]); function setActionState(key: string, state: ThreadActionState) { setActionStates((current) => ({ ...current, [key]: state })); @@ -3056,6 +3079,7 @@ export default function InboxPage() { } function selectFolder(nextFolder: MailFolder) { + setMailboxSearch(""); setMailFolder(nextFolder); setSidebarOpen(false); setMobileDetailOpen(false); @@ -3069,6 +3093,7 @@ export default function InboxPage() { } function selectTypeFilter(nextFilter: MailboxFilter) { + setMailboxSearch(""); setMailboxFilter(nextFilter); setSidebarOpen(false); setMobileDetailOpen(false); @@ -3354,6 +3379,8 @@ export default function InboxPage() { selfAddress={address} folderLabel={folderLabel} filterLabel={filterLabel} + search={mailboxSearch} + onSearchChange={setMailboxSearch} onSelect={selectMessage} /> )} diff --git a/src/components/mail/ConversationList.test.ts b/src/components/mail/ConversationList.test.ts index c3786fc..7deda3e 100644 --- a/src/components/mail/ConversationList.test.ts +++ b/src/components/mail/ConversationList.test.ts @@ -8,6 +8,7 @@ import type { LocalMailMessage } from "./Thread"; import ConversationList, { conversationCorrespondent, mailboxMatchesFilter, + messageMatchesSearch, } from "./ConversationList"; const strk = { symbol: "STRK", address: addrSTRK, decimals: 18 }; @@ -171,3 +172,32 @@ describe("conversation list accessibility", () => { expect(markup).not.toContain("Unsupported decrypted record"); }); }); + +describe("device-local search", () => { + const message = compositeMessage("incoming"); + const correspondent = conversationCorrespondent(message, [], "0xb0b"); + + it("matches an empty query so an unsearched rail shows everything", () => { + expect(messageMatchesSearch(message, correspondent, " ")).toBe(true); + }); + + it("matches decrypted body text, the record type and the index", () => { + expect(messageMatchesSearch(message, correspondent, "settlement")).toBe( + true, + ); + expect(messageMatchesSearch(message, correspondent, "document")).toBe(true); + expect(messageMatchesSearch(message, correspondent, message.index)).toBe( + true, + ); + }); + + it("matches the correspondent, including a claimed address", () => { + expect(messageMatchesSearch(message, correspondent, "0xa11ce")).toBe(true); + }); + + it("does not match text that is absent from the decrypted record", () => { + expect(messageMatchesSearch(message, correspondent, "zzz-not-here")).toBe( + false, + ); + }); +}); diff --git a/src/components/mail/ConversationList.tsx b/src/components/mail/ConversationList.tsx index 285ac97..39689c8 100644 --- a/src/components/mail/ConversationList.tsx +++ b/src/components/mail/ConversationList.tsx @@ -224,9 +224,38 @@ type ConversationListProps = { selfAddress: string; folderLabel: string; filterLabel: string; + search?: string; + onSearchChange?: (value: string) => void; onSelect: (messageId: string) => void; }; +/** + * Searching happens here, over what this device already decrypted: the + * correspondent, the preview, the record type and the record index. Nothing + * about a query leaves the browser, and sealed rows stay searchable by the + * plaintext only this device holds. + */ +export function messageMatchesSearch( + message: LocalMailMessage, + correspondent: ConversationCorrespondent, + query: string, +): boolean { + const needle = query.trim().toLowerCase(); + if (!needle) return true; + const haystack = [ + correspondent.primary, + correspondent.detail ?? "", + correspondent.fullAddress ?? "", + envelopeLabel(message), + messagePreview(message), + message.index, + ...(message.recipients ?? []), + ] + .join(" ") + .toLowerCase(); + return haystack.includes(needle); +} + function ConversationList({ messages, selectedMessageId, @@ -235,8 +264,19 @@ function ConversationList({ selfAddress, folderLabel, filterLabel, + search = "", + onSearchChange, onSelect, }: ConversationListProps) { + const rows = messages.map((message) => ({ + message, + correspondent: conversationCorrespondent(message, aliases, selfAddress), + })); + const visible = search.trim() + ? rows.filter((row) => + messageMatchesSearch(row.message, row.correspondent, search), + ) + : rows; return (
@@ -244,25 +284,33 @@ function ConversationList({

{filterLabel.toUpperCase()}

{folderLabel}

- {messages.length} + {visible.length} + {onSearchChange ? ( +
+ onSearchChange(event.target.value)} + /> +
+ ) : null} +
Local browser index—not encrypted at rest - “Unread/opened” is session-only. Sealed rows carry no public sender. + Opened state and search stay on this device. Sealed rows carry no + public sender.
- {messages.length ? ( + {visible.length ? (
    - {messages.map((message) => { + {visible.map(({ message, correspondent }) => { const selected = selectedMessageId === message.id; - const correspondent = conversationCorrespondent( - message, - aliases, - selfAddress, - ); const unread = message.direction !== "outgoing" && !readMessageIds.has(message.id); @@ -315,20 +363,32 @@ function ConversationList({ ) : (
    - - {filterLabel === "All" || filterLabel === "All types" - ? "Nothing here yet" - : `No ${filterLabel.toLowerCase()} yet`} - - - {folderLabel === "Sent" - ? "Sent copies live only on this device. Write a message to create one." - : folderLabel === "Inbox" - ? "Check for new mail, or write a message." - : "Choose another mailbox, or write a message."} + {search.trim() ? "⌕" : "✉"} + {search.trim() ? ( + <> + No opened record matches + + Search reads only what this device has already decrypted. Check + for new mail to widen it. + + + ) : ( + <> + + {filterLabel === "All" || filterLabel === "All types" + ? "Nothing here yet" + : `No ${filterLabel.toLowerCase()} yet`} + + + {folderLabel === "Sent" + ? "Sent copies live only on this device. Write a message to create one." + : folderLabel === "Inbox" + ? "Check for new mail, or write a message." + : "Choose another mailbox, or write a message."} + + + )}
    )}
diff --git a/src/components/mail/mail.module.css b/src/components/mail/mail.module.css index 4405c7b..0fb31be 100644 --- a/src/components/mail/mail.module.css +++ b/src/components/mail/mail.module.css @@ -2086,6 +2086,35 @@ font-weight: 700; } +/* Search is the rail's own control: it filters what this device decrypted. */ +.railSearch { + flex: none; + padding: 8px 12px; + border-bottom: 1px solid var(--shell-line); +} + +.railSearch input { + width: 100%; + min-height: 32px; + border: 1px solid var(--shell-line); + border-radius: 0; + padding: 0 9px; + color: var(--shell-text); + background: var(--shell-bg); + font-family: var(--font-mono-ui), monospace; + font-size: 11px; +} + +.railSearch input::placeholder { + color: var(--shell-muted); + letter-spacing: 0.04em; +} + +.railSearch input:focus-visible { + border-color: var(--shell-orange); + outline: none; +} + .railPrivacyNote { display: flex; flex-direction: column; diff --git a/src/lib/local-mailbox-storage.ts b/src/lib/local-mailbox-storage.ts index 9e33bbf..d9ea0c2 100644 --- a/src/lib/local-mailbox-storage.ts +++ b/src/lib/local-mailbox-storage.ts @@ -1,6 +1,7 @@ import { ALIAS_STORAGE_PREFIX } from "./aliases"; import { DRAFT_STORAGE_PREFIX } from "./drafts"; import { ESCROW_STORAGE_PREFIX } from "./escrow"; +import { MAIL_READ_STATE_STORAGE_PREFIX } from "./mail-read-state"; import { MAIL_SCAN_CURSOR_PREFIX } from "./mail-scan"; import { OTC_STORAGE_PREFIX } from "./otc"; import { MAIL_ASSIGNMENT_STORAGE_PREFIX } from "./mail-assignments"; @@ -17,6 +18,7 @@ export const LOCAL_MAILBOX_STORAGE_PREFIXES = [ ESCROW_STORAGE_PREFIX, MAIL_SCAN_CURSOR_PREFIX, MAIL_ASSIGNMENT_STORAGE_PREFIX, + MAIL_READ_STATE_STORAGE_PREFIX, ] as const; type MutableStorage = Pick; diff --git a/src/lib/mail-read-state.test.ts b/src/lib/mail-read-state.test.ts new file mode 100644 index 0000000..b43c827 --- /dev/null +++ b/src/lib/mail-read-state.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_TRACKED_READ_IDS, + loadReadMessageIds, + saveReadMessageIds, +} from "./mail-read-state"; + +function memoryStorage(seed: Record = {}) { + const map = new Map(Object.entries(seed)); + return { + getItem: (name: string) => map.get(name) ?? null, + setItem: (name: string, value: string) => void map.set(name, value), + map, + }; +} + +describe("mail read state", () => { + it("round-trips opened records for one chain and account", () => { + const storage = memoryStorage(); + saveReadMessageIds(storage, "0x1", "0xabc", new Set(["a", "b"])); + expect([...loadReadMessageIds(storage, "0x1", "0xabc")]).toEqual([ + "a", + "b", + ]); + }); + + it("keeps accounts and chains apart", () => { + const storage = memoryStorage(); + saveReadMessageIds(storage, "0x1", "0xabc", new Set(["a"])); + expect(loadReadMessageIds(storage, "0x1", "0xdef").size).toBe(0); + expect(loadReadMessageIds(storage, "0x2", "0xabc").size).toBe(0); + }); + + it("ignores malformed or absent entries instead of throwing", () => { + expect( + loadReadMessageIds(memoryStorage({ x: "y" }), "0x1", "0xabc").size, + ).toBe(0); + const broken = memoryStorage({ + "app20/mailread/v1/0x1/0xabc": "{not json", + }); + expect(loadReadMessageIds(broken, "0x1", "0xabc").size).toBe(0); + const wrongShape = memoryStorage({ + "app20/mailread/v1/0x1/0xabc": '{"a":1}', + }); + expect(loadReadMessageIds(wrongShape, "0x1", "0xabc").size).toBe(0); + }); + + it("writes nothing without a chain and account", () => { + const storage = memoryStorage(); + saveReadMessageIds(storage, "", "0xabc", new Set(["a"])); + expect(storage.map.size).toBe(0); + }); + + it("bounds how much history one mailbox can store", () => { + const storage = memoryStorage(); + const ids = new Set( + Array.from({ length: MAX_TRACKED_READ_IDS + 25 }, (_, i) => `id-${i}`), + ); + saveReadMessageIds(storage, "0x1", "0xabc", ids); + const loaded = loadReadMessageIds(storage, "0x1", "0xabc"); + expect(loaded.size).toBe(MAX_TRACKED_READ_IDS); + expect(loaded.has(`id-${MAX_TRACKED_READ_IDS + 24}`)).toBe(true); + }); +}); diff --git a/src/lib/mail-read-state.ts b/src/lib/mail-read-state.ts new file mode 100644 index 0000000..922bba3 --- /dev/null +++ b/src/lib/mail-read-state.ts @@ -0,0 +1,57 @@ +/** + * Which decrypted records this device has already opened. + * + * Read state used to live in React state alone, so a reload marked a whole + * mailbox unread again. It is device-local by design: nothing about what you + * have read is ever posted, and the set is scoped to one chain and account so + * switching either never leaks the other's history. + */ +export const MAIL_READ_STATE_STORAGE_PREFIX = "app20/mailread/v1"; + +type StorageLike = Pick; + +/** Bounded so a long-lived mailbox cannot grow the entry without limit. */ +export const MAX_TRACKED_READ_IDS = 5_000; + +function key(chainId: string, address: string): string { + return `${MAIL_READ_STATE_STORAGE_PREFIX}/${encodeURIComponent(chainId)}/${encodeURIComponent(address)}`; +} + +export function loadReadMessageIds( + storage: StorageLike, + chainId: string, + address: string, +): Set { + if (!chainId || !address) return new Set(); + let raw: string | null = null; + try { + raw = storage.getItem(key(chainId, address)); + } catch { + return new Set(); + } + if (!raw) return new Set(); + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set( + parsed.filter((item): item is string => typeof item === "string"), + ); + } catch { + return new Set(); + } +} + +export function saveReadMessageIds( + storage: StorageLike, + chainId: string, + address: string, + ids: ReadonlySet, +): void { + if (!chainId || !address) return; + const bounded = [...ids].slice(-MAX_TRACKED_READ_IDS); + try { + storage.setItem(key(chainId, address), JSON.stringify(bounded)); + } catch { + // A profile that refuses storage keeps session-only read state. + } +} From 02964a8fcac125fb5dadd375f0ecfdef1b558fc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 16:26:34 +0000 Subject: [PATCH 23/35] refactor(rfq): put the ticket first, and stop splitting one set of records across two tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public market board led the New request view: a CoinGecko chart and eight metrics, above the ticket, in a panel whose own labels read "Not an RFQ quote" and "NON-EXECUTABLE", behind an opt-in that discloses the visitor's IP. It is reference, so it collapses into a disclosure under the ticket and opens when someone asks for it. The view is now what a person came to fill in, and the page it renders is half the height it was. Active and Activity were two tabs over the same records, differing by a filter, which left "which one am I in?" as a question with no good answer. They are one Records view with an explicit scope — in flight first, because a record you can still act on is why you opened it — and #active and #activity still resolve there so existing bookmarks keep working. Operations is a status page for one endpoint, mostly reading Unavailable; it leaves the workspace tab bar for the row of separate operations. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB --- src/app/rfq/RfqWorkspace.tsx | 158 +++++++++++++++----------- src/app/rfq/rfq-workspace-ia.test.tsx | 14 ++- src/app/rfq/rfq.module.css | 111 +++++++++++++++++- 3 files changed, 211 insertions(+), 72 deletions(-) diff --git a/src/app/rfq/RfqWorkspace.tsx b/src/app/rfq/RfqWorkspace.tsx index e6d78cc..d55e385 100644 --- a/src/app/rfq/RfqWorkspace.tsx +++ b/src/app/rfq/RfqWorkspace.tsx @@ -110,7 +110,8 @@ import RfqRecoveryCard from "./RfqRecoveryCard"; import { refreshLiveRfqAuthority } from "./rfq-authority"; import type { WorkspaceLoadState } from "./workspace-load-state"; -type View = "new" | "active" | "activity" | "compatibility"; +type View = "new" | "records" | "compatibility"; +type RecordScope = "in-flight" | "all"; export function rfqWorkspaceScopeKey( providerIndex: number, @@ -133,10 +134,10 @@ export function workspaceScopeIsReady( ): boolean { return Boolean( currentScope && - loadedScope === currentScope && - (loadState === "ready" || - loadState === "quarantined" || - loadState === "local-deal-read-failed"), + loadedScope === currentScope && + (loadState === "ready" || + loadState === "quarantined" || + loadState === "local-deal-read-failed"), ); } @@ -151,20 +152,20 @@ function deriveWorkspaceContextReady(input: { }): boolean { return Boolean( input.providerIndex === LOCALNET_PROVIDER_INDEX && - input.address && - input.chain && - workspaceScopeIsReady( - input.loadedScope, - input.currentScope, - input.loadState, - ) && - input.records.every((record) => - recoveryContextMatches(record, { - account: input.address!, - chainId: input.chain!, - providerIndex: input.providerIndex, - }), - ), + input.address && + input.chain && + workspaceScopeIsReady( + input.loadedScope, + input.currentScope, + input.loadState, + ) && + input.records.every((record) => + recoveryContextMatches(record, { + account: input.address!, + chainId: input.chain!, + providerIndex: input.providerIndex, + }), + ), ); } @@ -201,8 +202,11 @@ export function RfqWorkspaceActiveBoundary(props: { function viewFromHash(hash: string): View { const value = hash.replace(/^#/, ""); - if (value === "active") return "active"; - if (value === "activity") return "activity"; + /* #active and #activity were two tabs over one set of records; existing + bookmarks land on the single Records view. */ + if (value === "records" || value === "active" || value === "activity") { + return "records"; + } if (value === "intents") return "compatibility"; return "new"; } @@ -276,6 +280,9 @@ export default function RfqWorkspace() { const address = useStoreWallet((state) => state.address); const chain = useStoreWallet((state) => state.chain); const view = viewFromHash(hash); + /* In-flight first, because a record you can still act on is why you opened + this view; the whole history is one control away. */ + const [recordScope, setRecordScope] = useState("in-flight"); const [pairId, setPairId] = useState(requestedPair); const [records, setRecords] = useState([]); const [loadState, setLoadState] = useState("loading"); @@ -1161,21 +1168,12 @@ export default function RfqWorkspace() { - Active + Records - - Activity - - Operations {recordError ?

{recordError}

: null} {view === "compatibility" ? ( @@ -1186,23 +1184,55 @@ export default function RfqWorkspace() { Open dry cross-chain review
) : null} - {view === "active" ? ( -
- void runRecordAction(record, action)} - onRemove={(record) => void removeRecord(record)} - onClearAll={() => void clearAllRecords()} - onRetryLoad={retryWorkspaceLoad} - /> + {view === "records" ? ( +
+ + {recordScope === "in-flight" ? ( + + void runRecordAction(record, action) + } + onRemove={(record) => void removeRecord(record)} + onClearAll={() => void clearAllRecords()} + onRetryLoad={retryWorkspaceLoad} + /> + ) : ( + <> + void removeRecord(record)} + onClearAll={() => void clearAllRecords()} + onRetryLoad={retryWorkspaceLoad} + /> + + + )} {resumeReviewRecord ? ( ) : null} - {view === "activity" ? ( -
- void removeRecord(record)} - onClearAll={() => void clearAllRecords()} - onRetryLoad={retryWorkspaceLoad} - /> - -
- ) : null} {view === "new" ? (

New request

@@ -1283,18 +1300,31 @@ export default function RfqWorkspace() {
)} +
+ {/* Public context is a reference, not a quote: the board's own labels + say non-executable, it needs an opt-in that discloses your IP, and + it used to occupy the top half of the page above the ticket. It + opens when someone asks for it. */} +
+ + Public market context + + NON-EXECUTABLE + +
-
+ -
- + Pay + +
diff --git a/src/app/globals.css b/src/app/globals.css index 6de6986..c9e1733 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -420,8 +420,7 @@ a { align-items: center; gap: 6px; } -.app-tabs a, -.request-link { +.app-tabs a { display: inline-flex; min-height: 44px; align-items: center; @@ -434,24 +433,18 @@ a { letter-spacing: 0.08em; text-transform: uppercase; } -.app-tabs a[aria-current="page"], -.request-link[aria-current="page"] { +.app-tabs a[aria-current="page"] { border-color: var(--shell-line); color: var(--shell-text); box-shadow: inset 0 -2px var(--shell-orange); } -.app-tabs a:hover, -.request-link:hover { +.app-tabs a:hover { color: var(--shell-text); border-color: var(--shell-orange); } .app-utilities { justify-content: flex-end; } -.request-link { - border-color: var(--shell-orange); - color: var(--shell-text); -} .network-status, .wallet-status { display: inline-flex; @@ -908,9 +901,6 @@ a { font-size: clamp(8px, 2.25vw, 9px); letter-spacing: 0.03em; } - .app-utilities .request-link { - padding: 0 5px; - } /* Wrap rather than clip: the Disconnect action stays reachable at 320px. */ .app-utilities { min-width: 0; diff --git a/src/app/inbox/page.tsx b/src/app/inbox/page.tsx index d90a2c4..ca76762 100644 --- a/src/app/inbox/page.tsx +++ b/src/app/inbox/page.tsx @@ -3083,6 +3083,11 @@ export default function InboxPage() { } const walletGateShown = storageNotice?.action === "connect-wallet"; + /* What is actually blocking this mailbox, answered once. The welcome sheet, + the rail's empty state and the scan panel used to give three different + instructions for the same state. */ + const mailboxGate: "wallet" | "key" | null = + !address || !chainId ? "wallet" : keypair ? null : "key"; function replyToOpenConversation() { if (!selectedMessage) return; @@ -3265,11 +3270,13 @@ export default function InboxPage() { events={scanProgress.events} phase={scanMessage} /> - {keypair ? null : ( + {mailboxGate ? (

- Set up a mailbox key before checking for mail. + {mailboxGate === "wallet" + ? "Connect a wallet before checking for mail." + : "Set up a mailbox key before checking for mail."}

- )} + ) : null} {scanCursorDescription ? (

{scanCursorDescription}

) : null} @@ -3402,6 +3409,7 @@ export default function InboxPage() { filterLabel={filterLabel} search={mailboxSearch} onSearchChange={setMailboxSearch} + gate={mailboxGate} onSelect={selectMessage} /> )} diff --git a/src/app/rfq/RfqWorkspace.tsx b/src/app/rfq/RfqWorkspace.tsx index d55e385..ed2784d 100644 --- a/src/app/rfq/RfqWorkspace.tsx +++ b/src/app/rfq/RfqWorkspace.tsx @@ -1294,8 +1294,8 @@ export default function RfqWorkspace() {

Private RFQ unavailable

{providerIndex === 2 - ? "Sepolia RFQ is disabled. Production contracts, governed maker directory, custody, chain verifier, operators, funding, audit, and rollout evidence are unavailable." - : "Mainnet RFQ is disabled. No maker request was sent and there is no automatic public fallback."} + ? "Production contracts, a governed maker directory, custody, the chain verifier, operators, funding, audit and rollout evidence are not in place on this rail, so no maker request can be sent." + : "No maker request was sent, and there is no automatic public fallback."}

)} @@ -1326,7 +1326,6 @@ export default function RfqWorkspace() { Elsewhere RFQ operations status Shield / unshield funding - Public send · unavailable Mail · coordination only Cross-chain dry review Privy recovery diff --git a/src/components/mail/ConversationList.tsx b/src/components/mail/ConversationList.tsx index 39689c8..b5a8ee2 100644 --- a/src/components/mail/ConversationList.tsx +++ b/src/components/mail/ConversationList.tsx @@ -226,6 +226,8 @@ type ConversationListProps = { filterLabel: string; search?: string; onSearchChange?: (value: string) => void; + /** What is blocking this mailbox, so the rail answers what the page answers. */ + gate?: "wallet" | "key" | null; onSelect: (messageId: string) => void; }; @@ -266,6 +268,7 @@ function ConversationList({ filterLabel, search = "", onSearchChange, + gate = null, onSelect, }: ConversationListProps) { const rows = messages.map((message) => ({ @@ -287,7 +290,7 @@ function ConversationList({ {visible.length} - {onSearchChange ? ( + {onSearchChange && messages.length ? (
) : null} -
- Local browser index—not encrypted at rest - - Opened state and search stay on this device. Sealed rows carry no - public sender. - -
+ {messages.length ? ( +
+ Local browser index—not encrypted at rest + + Opened state and search stay on this device. Sealed rows carry no + public sender. + +
+ ) : null} {visible.length ? (
    @@ -373,6 +378,17 @@ function ConversationList({ for new mail to widen it. + ) : gate ? ( + <> + + {gate === "wallet" ? "Wallet required" : "Mailbox key required"} + + + {gate === "wallet" + ? "This mailbox is keyed to a wallet. Connect one to open it." + : "Set up a mailbox key to open and decrypt records."} + + ) : ( <> diff --git a/src/components/mail/Thread.tsx b/src/components/mail/Thread.tsx index c6af871..afcf2a2 100644 --- a/src/components/mail/Thread.tsx +++ b/src/components/mail/Thread.tsx @@ -43,10 +43,7 @@ import EscrowCard from "./EscrowCard"; import InvoiceCard from "./InvoiceCard"; import OfferCard from "./OfferCard"; import ReceiptCard from "./ReceiptCard"; -import { - replyAddressForConversation, - replyAddressForMessage, -} from "@/lib/mail-correspondents"; +import { replyAddressForConversation } from "@/lib/mail-correspondents"; import type { MailAssignment } from "@/lib/mail-assignments"; import { conversationFieldsFromPayload, @@ -314,85 +311,83 @@ export function ChainRecordPanel({ message }: { message: LocalMailMessage }) { ); } -function ConversationControls({ - message, +/** + * What belongs to the conversation rather than to one envelope: naming its + * sender, and the reason a reply cannot prefill a recipient. Both used to + * render once per record, so a five-record thread carried five naming forms. + */ +function ConversationNaming({ + messages, selfAddress, - proof, - conversationAddress, - onReply, + responseAddress, + canReply, onAssign, +}: { + messages: LocalMailMessage[]; + selfAddress: string; + responseAddress?: string; + canReply: boolean; + onAssign?: ThreadProps["onAssign"]; +}) { + const target = messages.find((message) => message.direction !== "outgoing"); + const assigned = target?.assignedAddress; + const [assignInput, setAssignInput] = useState(assigned ?? ""); + if (!target || !onAssign) return null; + return ( +
    + {canReply && !responseAddress ? ( +

    + A reply cannot prefill a recipient: the sender is sealed and this + conversation has no device-local recipient or name yet. Name the + sender below, or supply the address in the draft. +

    + ) : null} +
    + Name this sender on this device +
    { + event.preventDefault(); + if (assignInput.trim()) onAssign(target.id, assignInput); + }} + > + + + +
    +
    + ); +} + +/** What belongs to one record: how it identifies itself, and its own claim. */ +function RecordControls({ + message, + proof, onProve, }: { message: LocalMailMessage; - selfAddress: string; proof?: SenderProof; - conversationAddress?: string; - onReply?: ThreadProps["onReply"]; - onAssign?: ThreadProps["onAssign"]; onProve?: ThreadProps["onProve"]; }) { const fields = conversationFieldsFromPayload( message.envelope.type, message.envelope.type === "unsupported" ? null : message.envelope.payload, ); - const conversationId = conversationKeyForMessage(message); - const claimed = replyAddressForMessage(message, selfAddress); const assigned = message.assignedAddress; - const responseAddress = claimed ?? assigned ?? conversationAddress; - const canContinue = Boolean(onReply); - const [assignInput, setAssignInput] = useState(assigned ?? ""); + if (!proof && !fields.conversationId && !(assigned && onProve)) return null; return (
    {proof ?

    {senderProofLabel(proof)}

    : null} {fields.conversationId ? ( Conversation tag {fields.conversationId.slice(0, 18)}… ) : null} - {canContinue ? ( - - ) : null} - {canContinue && !responseAddress ? ( -

    - Recipient cannot be prefilled because the sender is sealed and this - conversation has no single device-local recipient or local sender - assignment. Supply the counterparty address in the draft. -

    - ) : null} - {/* Naming a sealed sender is a deliberate, occasional act. As an open - form it put an address book, a label and a submit button above every - letter in the thread. */} - {message.direction !== "outgoing" && onAssign ? ( -
    - Name this sender on this device -
    { - event.preventDefault(); - if (assignInput.trim()) onAssign(message.id, assignInput); - }} - > - - - -
    - ) : null} {assigned && onProve ? (
    -
+ + {messages.length ? (
    {messages.map((message) => { @@ -841,13 +841,9 @@ export default function Thread({ : "This unverified legacy request was imported from a URL fragment for local review. It is not a MessagePosted event, cannot authenticate the requester, and opening or importing it did not submit a payment."}

    ) : null} - {renderEnvelope(message)} diff --git a/src/components/mail/mail.module.css b/src/components/mail/mail.module.css index 0fb31be..1c8b345 100644 --- a/src/components/mail/mail.module.css +++ b/src/components/mail/mail.module.css @@ -3451,6 +3451,21 @@ flex-basis: 100%; } +/* Conversation-level actions sit once, under the head they act on. */ +.conversationActions { + display: grid; + gap: 8px; + margin: 10px 0 4px; +} + +.conversationActionsNote { + max-width: var(--measure-reading); + margin: 0; + color: var(--shell-muted); + font-size: 12px; + line-height: 1.5; +} + .assignDisclosure { border: 1px solid var(--shell-line); background: var(--shell-bg); From 13f9e8ccf9239da2370f39d1d2c6c7bab8d55e58 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 18:57:04 +0000 Subject: [PATCH 27/35] test(ui): follow the merged Records view, and open the recovery disclosure before using it The UI specs still navigated Active/Activity tabs, looked for a "Public send" link and expected the market board open. They now use New/Records, the In flight/All scope buttons and the collapsed "Public market context" board. The mailbox recovery panel is a closed disclosure, so its backup buttons are not visible until opened; the two localnet specs open it first. The ticket also left-aligns under the view heading at 560px instead of floating centred in an empty pane. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB --- src/app/rfq/rfq.module.css | 6 ++-- tests/ui/app20.localnet.spec.ts | 13 +++++++ tests/ui/private-intents.localnet.spec.ts | 16 ++++++++- tests/ui/rfq-accessibility.spec.ts | 44 +++++++++-------------- tests/ui/rfq-m11-acceptance.spec.ts | 44 ++++++++--------------- tests/ui/rfq-scope-invalidation.spec.ts | 5 +-- tests/ui/swap-routes.localnet.spec.ts | 2 ++ 7 files changed, 69 insertions(+), 61 deletions(-) diff --git a/src/app/rfq/rfq.module.css b/src/app/rfq/rfq.module.css index b89ce15..a64d028 100644 --- a/src/app/rfq/rfq.module.css +++ b/src/app/rfq/rfq.module.css @@ -1591,10 +1591,12 @@ color: var(--shell-text); } +/* The ticket is the view now, not a side rail: it sits under the view heading + at a form's width instead of floating centred in a 1400px pane. */ .privateIntentDesk { width: 100%; - max-width: 420px; - margin: 0 auto; + max-width: 560px; + margin: 0; overflow: hidden; border: 1px solid var(--shell-line); background: var(--shell-bg); diff --git a/tests/ui/app20.localnet.spec.ts b/tests/ui/app20.localnet.spec.ts index 0036893..b51517a 100644 --- a/tests/ui/app20.localnet.spec.ts +++ b/tests/ui/app20.localnet.spec.ts @@ -131,6 +131,18 @@ async function restoreRegisteredKey(page: Page, backup: string) { } } +/** The recovery panel is a closed disclosure in the mailbox rail. */ +async function openMailboxRecovery(page: Page) { + await page + .locator("details", { + has: page.getByText("Encrypted mailbox recovery", { exact: true }), + }) + .first() + .evaluate((element) => { + (element as HTMLDetailsElement).open = true; + }); +} + async function loadExistingKey(page: Page) { const button = page.getByRole("button", { name: "Load device key & register", @@ -410,6 +422,7 @@ test("all APP20 localnet journeys", async ({ await page.getByRole("button", { name: "Add", exact: true }).click(); await page.getByRole("link", { name: "Mailbox", exact: true }).click(); await loadExistingKey(page); + await openMailboxRecovery(page); const backupContacts = page.getByRole("button", { name: "Back up contacts to Mailbox", diff --git a/tests/ui/private-intents.localnet.spec.ts b/tests/ui/private-intents.localnet.spec.ts index 50f1b6c..2a08936 100644 --- a/tests/ui/private-intents.localnet.spec.ts +++ b/tests/ui/private-intents.localnet.spec.ts @@ -81,6 +81,7 @@ async function ensureMailboxKey(page: Page, identity: LocalnetIdentityId) { .click(); } await expect(scanButton).toBeEnabled({ timeout: 60_000 }); + await openMailboxRecovery(page); await expect( page.getByRole("button", { name: "Back up RFQ history" }), ).toBeEnabled({ timeout: 60_000 }); @@ -97,6 +98,18 @@ async function ensureMailboxKey(page: Page, identity: LocalnetIdentityId) { } } +/** The recovery panel is a closed disclosure in the mailbox rail. */ +async function openMailboxRecovery(page: Page) { + await page + .locator("details", { + has: page.getByText("Encrypted mailbox recovery", { exact: true }), + }) + .first() + .evaluate((element) => { + (element as HTMLDetailsElement).open = true; + }); +} + async function scanRecent(page: Page) { const button = page.getByRole("button", { name: "Check for new mail" }); await expect(button).toBeEnabled({ timeout: 120_000 }); @@ -454,6 +467,7 @@ test("v3 keeps floors sealed, expires locks, and atomically settles single and s ipfsWrites.push(outgoing.url()); } }); + await openMailboxRecovery(page); const backUpRfqHistory = page.getByRole("button", { name: "Back up RFQ history", }); @@ -976,7 +990,7 @@ test("modeled localnet authority survives TTL, two tabs, disagreement, reorg, an /stale lifecycle snapshot|exact predecessor/i, ); - await secondPage.getByRole("link", { name: "Active", exact: true }).click(); + await secondPage.getByRole("link", { name: "Records", exact: true }).click(); await expect( secondPage.getByText( /Restoring and reconciling never automatically resubmits fund, fill, claim, or refund/, diff --git a/tests/ui/rfq-accessibility.spec.ts b/tests/ui/rfq-accessibility.spec.ts index 4c012e7..abf4005 100644 --- a/tests/ui/rfq-accessibility.spec.ts +++ b/tests/ui/rfq-accessibility.spec.ts @@ -122,7 +122,7 @@ async function expectBefore(earlier: Locator, later: Locator) { (element, candidate) => Boolean( element.compareDocumentPosition(candidate as Node) & - Node.DOCUMENT_POSITION_FOLLOWING, + Node.DOCUMENT_POSITION_FOLLOWING, ), await later.elementHandle(), ), @@ -288,30 +288,25 @@ test("RFQ hash navigation is keyboard reachable and lands focus in labelled regi }) => { await page.goto("/rfq#new"); const newLink = page.getByRole("link", { name: "New", exact: true }); - const activeLink = page.getByRole("link", { name: "Active", exact: true }); - const activityLink = page.getByRole("link", { - name: "Activity", - exact: true, - }); + const recordsLink = page.getByRole("link", { name: "Records", exact: true }); await tabTo(page, newLink); await expectVisibleFocus(newLink); await page.keyboard.press("Tab"); - await expectVisibleFocus(activeLink); - await page.keyboard.press("Tab"); - await expectVisibleFocus(activityLink); + await expectVisibleFocus(recordsLink); - await page.keyboard.press("Shift+Tab"); await page.keyboard.press("Enter"); - await expect(page).toHaveURL(/\/rfq#active$/); - await expectVisibleFocus(page.getByRole("region", { name: "Active RFQs" })); + await expect(page).toHaveURL(/\/rfq#records$/); + await expectVisibleFocus(page.getByRole("region", { name: "RFQ records" })); - await page.keyboard.press("Shift+Tab"); - await page.keyboard.press("Shift+Tab"); - await expectVisibleFocus(activityLink); + // The scope control inside the view is keyboard reachable and exclusive. + const inFlight = page.getByRole("button", { name: /^In flight/ }); + const all = page.getByRole("button", { name: /^All/ }); + await expect(inFlight).toHaveAttribute("aria-pressed", "true"); + await all.focus(); await page.keyboard.press("Enter"); - await expect(page).toHaveURL(/\/rfq#activity$/); - await expectVisibleFocus(page.getByRole("region", { name: "RFQ activity" })); + await expect(all).toHaveAttribute("aria-pressed", "true"); + await expect(inFlight).toHaveAttribute("aria-pressed", "false"); }); test("RFQ screen-reader shape, heading order, zoom reflow, and responsive hierarchy remain usable", async ({ @@ -421,11 +416,12 @@ test("RFQ screen-reader shape, heading order, zoom reflow, and responsive hierar const comparison = ticket.getByRole("region", { name: /Compare all makers/, }); - const publicMarket = page.getByRole("region", { - name: "Public market context", + // The public board collapses under the ticket; its summary is the visible + // landmark. Public send is no longer linked from a rail where it cannot run. + const publicMarket = page.locator("summary", { + hasText: "Public market context", }); const funding = page.getByRole("link", { name: "Shield / unshield funding" }); - const send = page.getByRole("link", { name: "Public send" }); const crossChain = page.getByRole("link", { name: "Cross-chain dry review" }); const recovery = page.getByRole("link", { name: "Privy recovery" }); @@ -433,13 +429,7 @@ test("RFQ screen-reader shape, heading order, zoom reflow, and responsive hierar await page.setViewportSize({ width, height: 900 }); await expectNoHorizontalOverflow(page); for (const primary of [environment, ticket, comparison, review]) { - for (const secondary of [ - publicMarket, - funding, - send, - crossChain, - recovery, - ]) { + for (const secondary of [publicMarket, funding, crossChain, recovery]) { await expectBefore(primary, secondary); } } diff --git a/tests/ui/rfq-m11-acceptance.spec.ts b/tests/ui/rfq-m11-acceptance.spec.ts index 08278e5..d49848d 100644 --- a/tests/ui/rfq-m11-acceptance.spec.ts +++ b/tests/ui/rfq-m11-acceptance.spec.ts @@ -17,38 +17,24 @@ test("M11 keeps /rfq canonical, preserves legacy hashes, and restores keyboard-s page.getByRole("status", { name: "RFQ environment" }), ).toContainText("No automatic public fallback"); - const activeLink = page.getByRole("link", { name: "Active", exact: true }); - await activeLink.focus(); + const recordsLink = page.getByRole("link", { name: "Records", exact: true }); + await recordsLink.focus(); await page.keyboard.press("Enter"); - await expect(page).toHaveURL(/\/rfq#active$/); - await expect(page.getByRole("region", { name: "Active RFQs" })).toBeFocused(); + await expect(page).toHaveURL(/\/rfq#records$/); + await expect(page.getByRole("region", { name: "RFQ records" })).toBeFocused(); await page.reload(); - await expect(page).toHaveURL(/\/rfq#active$/); - await expect(page.getByRole("region", { name: "Active RFQs" })).toBeVisible(); + await expect(page).toHaveURL(/\/rfq#records$/); + await expect(page.getByRole("region", { name: "RFQ records" })).toBeVisible(); - const activityLink = page.getByRole("link", { - name: "Activity", - exact: true, - }); - await activityLink.focus(); - await page.keyboard.press("Enter"); - await expect(page).toHaveURL(/\/rfq#activity$/); - await expect( - page.getByRole("region", { name: "RFQ activity" }), - ).toBeFocused(); - - await page.reload(); - await expect(page).toHaveURL(/\/rfq#activity$/); - await expect( - page.getByRole("region", { name: "RFQ activity" }), - ).toBeVisible(); - - await page.goto("/vault#activity"); - await expect(page).toHaveURL(/\/rfq#activity$/); - await expect( - page.getByRole("region", { name: "RFQ activity" }), - ).toBeVisible(); + // The two tabs this view replaced remain valid bookmarks. + for (const legacy of ["/rfq#active", "/rfq#activity", "/vault#activity"]) { + await page.goto(legacy); + await expect(page).toHaveURL(/\/rfq#(active|activity)$/); + await expect( + page.getByRole("region", { name: "RFQ records" }), + ).toBeVisible(); + } }); test("M11 prioritizes the local RFQ ticket on mobile and keeps proposal migration non-executable", async ({ @@ -59,7 +45,7 @@ test("M11 prioritizes the local RFQ ticket on mobile and keeps proposal migratio const environment = page.getByRole("status", { name: "RFQ environment" }); const ticket = page.locator('aside[aria-label="Private RFQ ticket"]'); - const market = page.getByRole("region", { name: "Public market context" }); + const market = page.locator("summary", { hasText: "Public market context" }); await expect(environment).toContainText("LOCALNET DEMO"); await expect(ticket).toBeVisible(); await expect(market).toBeVisible(); diff --git a/tests/ui/rfq-scope-invalidation.spec.ts b/tests/ui/rfq-scope-invalidation.spec.ts index 0559f7e..802727d 100644 --- a/tests/ui/rfq-scope-invalidation.spec.ts +++ b/tests/ui/rfq-scope-invalidation.spec.ts @@ -119,8 +119,9 @@ test("an Alice quote request is discarded after switching to Bob", async ({ page.getByRole("alert").filter({ hasText: DISCARD_MESSAGE }), ).toBeVisible(); await expect(page.getByLabel("Selected private maker quote")).toHaveCount(0); - await page.getByRole("link", { name: "Activity", exact: true }).click(); - await expect(page.getByLabel("RFQ activity").locator("article")).toHaveCount( + await page.getByRole("link", { name: "Records", exact: true }).click(); + await page.getByRole("button", { name: /^All/ }).click(); + await expect(page.getByLabel("RFQ records").locator("article")).toHaveCount( 0, ); await expectNoPersistedCompletion(page, config); diff --git a/tests/ui/swap-routes.localnet.spec.ts b/tests/ui/swap-routes.localnet.spec.ts index 775a42c..2e420cf 100644 --- a/tests/ui/swap-routes.localnet.spec.ts +++ b/tests/ui/swap-routes.localnet.spec.ts @@ -49,6 +49,8 @@ test("keeps RFQ canonical, public context opt-in, swap non-executable, and marke await expect( page.getByText("LOCALNET DEMO", { exact: true }), ).toBeVisible(); + // Public context is collapsed under the ticket until someone asks for it. + await page.getByText("Public market context", { exact: true }).click(); await expect( page.getByRole("heading", { name: "Not an RFQ quote" }), ).toBeVisible(); From aea41e9df976d07ed84acc6f2e01773131e332cb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 19:19:53 +0000 Subject: [PATCH 28/35] fix(rfq): let the briefing sit in the ticket, and make the ticket read as two plain steps The one-time privacy briefing opened as a modal the moment the ticket mounted, so anyone landing on /rfq could not reach the wallet picker or the navigation until they acknowledged it. It now renders in the flow at the top of the ticket, waits for a connected wallet before its first showing, and leaves the page usable; the ticket's own actions stay disabled until it is acknowledged, which is the gate that matters. The ticket also drops the three "nothing has happened yet" chips in its idle state, names its two steps plainly ("Review what makers will see", "Request quotes"), gives the invitation consent a proper checkbox row, and leads each maker card with the verdict, keeping key and capacity facts behind a disclosure. Records: the empty state links straight to a new request, and the storage caveats fold into one "What this browser stores" disclosure. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB --- src/app/rfq/LocalnetPrivateIntentDesk.tsx | 35 ++++-- src/app/rfq/MakerCohortPanel.tsx | 95 ++++++++------- src/app/rfq/RfqActiveList.tsx | 19 +-- src/app/rfq/RfqActivity.tsx | 10 +- src/app/rfq/rfq-authority-ui.test.tsx | 2 +- src/app/rfq/rfq.module.css | 141 ++++++++++++++++++++-- tests/ui/private-intents.localnet.spec.ts | 9 +- tests/ui/rfq-accessibility.spec.ts | 6 +- tests/ui/rfq-scope-invalidation.spec.ts | 12 +- 9 files changed, 232 insertions(+), 97 deletions(-) diff --git a/src/app/rfq/LocalnetPrivateIntentDesk.tsx b/src/app/rfq/LocalnetPrivateIntentDesk.tsx index 92370a3..ca20efa 100644 --- a/src/app/rfq/LocalnetPrivateIntentDesk.tsx +++ b/src/app/rfq/LocalnetPrivateIntentDesk.tsx @@ -471,7 +471,7 @@ export default function LocalnetPrivateIntentDesk({ ]); const privacyReady = Boolean( privacyPreflight && - canProceedFromPrivacyPreflight(privacyPreflight, privacyBriefingAccepted), + canProceedFromPrivacyPreflight(privacyPreflight, privacyBriefingAccepted), ); useEffect(() => { @@ -501,15 +501,22 @@ export default function LocalnetPrivateIntentDesk({ return () => window.removeEventListener("storage", handleStorage); }, []); + /* The briefing opens in the flow of the ticket, not as a modal: a modal made + the whole page inert, wallet picker and navigation included, for anyone + who had only passed through the route. The ticket's own actions stay + disabled until it is acknowledged, which is the gate that matters. The + first showing also waits for a connected wallet, since nothing can be + sent before one; a later review of an accepted briefing opens on request. */ + const briefingCanOpen = connected || privacyBriefingAccepted; useEffect(() => { const dialog = privacyBriefingDialogRef.current; if (!dialog) return; - if (showPrivacyBriefing && !dialog.open) { - dialog.showModal(); + if (showPrivacyBriefing && briefingCanOpen && !dialog.open) { + dialog.show(); } else if (!showPrivacyBriefing && dialog.open) { dialog.close(); } - }, [showPrivacyBriefing]); + }, [showPrivacyBriefing, briefingCanOpen]); useEffect(() => { const tick = () => setPreflightNow(Math.floor(Date.now() / 1_000)); @@ -1538,7 +1545,6 @@ export default function LocalnetPrivateIntentDesk({ {swapOnly ? null : ( <> - {venue === "idle" ? (
    Invited maker inventory · no public route @@ -1547,7 +1553,10 @@ export default function LocalnetPrivateIntentDesk({
    ) : ( -

    {deskVenueCopy(venue)}

    + <> + +

    {deskVenueCopy(venue)}

    + )} )} @@ -1821,7 +1830,7 @@ export default function LocalnetPrivateIntentDesk({ (Boolean(invoice) && !invoiceEstimateReady) } > - Prepare size-blind cohort review + Review what makers will see {invitationReview && operations.status ? ( @@ -1873,7 +1882,7 @@ export default function LocalnetPrivateIntentDesk({ -