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 ? (
- {
- if (
- window.confirm(
- "Delete this device-private draft? It has never been uploaded and cannot be restored.",
- )
- ) {
- onDeleteDraft(draft.id);
- }
- }}
- >
- Delete draft…
-
+ {/* Discarding the draft is not a second way to send it: it leaves the
+ primary action's block and sits at its own weight. */}
+
+ {
+ if (
+ window.confirm(
+ "Delete this device-private draft? It has never been uploaded and cannot be restored.",
+ )
+ ) {
+ onDeleteDraft(draft.id);
+ }
+ }}
+ >
+ Delete draft…
+
+
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}
+
Date: Fri, 4 Sep 2026 14:08:49 +0000
Subject: [PATCH 08/35] feat(mail): keep reply in reach, and make first-run
state its own case
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replying lived only inside a thread record, so on a long correspondence it
scrolled out of reach. The reading toolbar carries it while a record is open,
and it stops spending its title line on the folder name that the rail already
shows two panes to the left.
Drafts: the recipient felt was the row's primary identity at full length, the
same wrap that broke the message rail's rhythm, and every row wore a DRAFT
chip inside a pane titled Drafts, under a note that named the active type
filter for no reason.
Key setup: the button that registers a mailbox is disabled without a wallet
and said nothing about why. The consequence of declining device encryption —
the raw seed sits in the clear, and anyone with the profile can send payment
requests that display as verified from you — was 11px fine print under an
optional-looking checkbox; it takes the risk frame the design system has for
exactly this. Prose across the module keeps a measure instead of running the
full pane.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB
---
src/app/inbox/page.tsx | 33 +++++++++++++++++++++++++---
src/components/mail/DraftList.tsx | 12 ++++++----
src/components/mail/Onboard.tsx | 28 ++++++++++++++++++-----
src/components/mail/correspondent.ts | 19 +++++++++++-----
src/components/mail/mail.module.css | 31 +++++++++++++++++++++++++-
5 files changed, 104 insertions(+), 19 deletions(-)
diff --git a/src/app/inbox/page.tsx b/src/app/inbox/page.tsx
index 1573928..063a6f1 100644
--- a/src/app/inbox/page.tsx
+++ b/src/app/inbox/page.tsx
@@ -108,7 +108,10 @@ import {
type ParsedMailEvent,
} from "@/lib/mail-scan";
import { verifyContactSnapshot } from "@/lib/contact-backup";
-import { describeMailScanCursor } from "@/lib/mail-correspondents";
+import {
+ describeMailScanCursor,
+ replyAddressForConversation,
+} from "@/lib/mail-correspondents";
import { authorizeStrk20ValueAction } from "@/lib/mainnet-safety";
import { clearLocalMailboxStorage } from "@/lib/local-mailbox-storage";
import {
@@ -179,7 +182,10 @@ import {
saveMailAssignment,
type MailAssignment,
} from "@/lib/mail-assignments";
-import { assembleConversation } from "@/lib/mail-thread";
+import {
+ assembleConversation,
+ conversationKeyForMessage,
+} from "@/lib/mail-thread";
import { evaluateSenderProof, type SenderProof } from "@/lib/sender-proof";
import { assertWalletOperationPolicy } from "@/lib/wallet-policy";
import * as constants from "@/utils/constants";
@@ -3031,6 +3037,16 @@ export default function InboxPage() {
const walletGateShown = storageNotice?.action === "connect-wallet";
+ function replyToOpenConversation() {
+ if (!selectedMessage) return;
+ openComposerForRecipient({
+ address:
+ replyAddressForConversation(conversationMessages, address) ?? undefined,
+ conversationId: conversationKeyForMessage(selectedMessage),
+ inReplyTo: selectedMessage.documentId ?? "",
+ });
+ }
+
function closeDetail() {
if (composerOpen && activeDraft && isBlankDraft(activeDraft)) {
removeDraft(activeDraft.id, false);
@@ -3364,11 +3380,14 @@ export default function InboxPage() {
? "LOCAL PLAINTEXT / CARBON COPY"
: "APP20 / MAILBOX / ENCRYPTED CORRESPONDENCE"}
+ {/* An open thread names itself in its own head, and the folder is
+ named in the rail beside this; the toolbar spends its line on
+ the one thing neither of them offers. */}
{composerOpen
? "New document"
: selectedMessage
- ? folderLabel
+ ? "Opened on this device"
: "Private correspondence desk"}
@@ -3380,6 +3399,14 @@ export default function InboxPage() {
>
Close
+ ) : selectedMessage ? (
+
+ Reply
+
) : 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"}
- DRAFTNo {filterLabel.toLowerCase()} drafts
- Click New to start a device-private document.
+ Compose to start a device-private document.
)}
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 ? (
-
+
+ Assign a sender on this device
+
+
) : null}
{assigned && onProve ? (
onProve(message.id, assigned)}>
diff --git a/src/components/mail/mail.module.css b/src/components/mail/mail.module.css
index 330acf8..29fa8d4 100644
--- a/src/components/mail/mail.module.css
+++ b/src/components/mail/mail.module.css
@@ -3383,11 +3383,64 @@
line-height: 1.45;
}
+/* The record's own line: what conversation it belongs to, and the one action
+ that continues it. Everything longer wraps onto its own row. */
.replyRow {
- display: grid;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
min-width: 0;
- gap: 6px;
- margin: 10px 0 12px;
+ gap: 8px 12px;
+ margin: 8px 0 12px;
+}
+
+.replyRow > p,
+.replyRow > details,
+.replyRow > form {
+ flex-basis: 100%;
+}
+
+.assignDisclosure {
+ border: 1px solid var(--shell-line);
+ background: var(--shell-bg);
+}
+
+.assignDisclosure > summary {
+ display: flex;
+ min-height: 34px;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 11px;
+ color: var(--shell-muted);
+ font-family: var(--font-mono-ui), monospace;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ list-style: none;
+ text-transform: uppercase;
+ cursor: pointer;
+}
+
+.assignDisclosure > summary::-webkit-details-marker {
+ display: none;
+}
+
+.assignDisclosure > summary::after {
+ color: var(--shell-orange);
+ content: "+";
+ font-size: 15px;
+}
+
+.assignDisclosure[open] > summary {
+ border-bottom: 1px solid var(--shell-line);
+}
+
+.assignDisclosure[open] > summary::after {
+ content: "\2212";
+}
+
+.assignDisclosure > form {
+ padding: 11px;
}
.replyRow button {
From 629cc2fc9fb2ed4814ad743d163d130a1df8f737 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 4 Sep 2026 14:17:15 +0000
Subject: [PATCH 10/35] feat(mail): group amounts, and rank a card's warnings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every amount in the product printed ungrouped: a desk reads 25,000 STRK at a
glance and has to count the digits of 25000. formatBaseUnits groups the
integer part with literal commas — never the fraction, and never through a
locale format that could turn a decimal point into a group mark.
Inside a deal card, the protocol truth ("value moves now, this is not an
atomic swap") and the procedural note ("accepting takes two approvals") wore
the same frame, so neither ranked. The consequence keeps the frame; the note
takes a rule, which also stops the card from nesting a box in a box in a box.
A receipt printed its transaction hash twice at full length — once inside the
sentence, where it broke the line, and once as the copyable record below — and
ended with the raw protocol constant "one_sided_v1" under the word "warning".
The sentence carries a shortened hash and says what the constant means.
Prose in the module keeps a measure; the sheet type and meta line keep the
mono label face that the desk is built on.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB
---
src/components/mail/ReceiptCard.tsx | 7 +++++--
src/components/mail/mail.module.css | 30 ++++++++++++++++++++++-------
src/lib/otc.test.ts | 7 +++++++
src/lib/otc.ts | 18 ++++++++++++-----
4 files changed, 48 insertions(+), 14 deletions(-)
diff --git a/src/components/mail/ReceiptCard.tsx b/src/components/mail/ReceiptCard.tsx
index ce0ea43..3823a2b 100644
--- a/src/components/mail/ReceiptCard.tsx
+++ b/src/components/mail/ReceiptCard.tsx
@@ -1,4 +1,5 @@
import { formatBaseUnits, type ReceiptPayload } from "@/lib/otc";
+import { shortenFelt } from "./correspondent";
import styles from "./mail.module.css";
type ReceiptCardProps = {
@@ -45,7 +46,8 @@ export default function ReceiptCard({
{locallySubmitted ? "This device submitted" : "A counterparty claims"}{" "}
the {amount} {receipt.transfer.token.symbol}{" "}
- {standalonePayment ? "payment" : "leg"} in transaction {receipt.txHash}.
+ {standalonePayment ? "payment" : "leg"} in transaction{" "}
+ {shortenFelt(receipt.txHash)}.
{locallySubmitted
@@ -61,7 +63,8 @@ export default function ReceiptCard({
{standalonePayment ? null : (
- Counterparty claim warning: {receipt.warning}
+ Counterparty claim · one-sided settlement — the two
+ legs are not swapped in one transaction.
)}
{receipt.txHash}
diff --git a/src/components/mail/mail.module.css b/src/components/mail/mail.module.css
index 29fa8d4..cd42bd3 100644
--- a/src/components/mail/mail.module.css
+++ b/src/components/mail/mail.module.css
@@ -182,12 +182,14 @@
line-height: 1.6;
}
-/* Prose keeps a measure wherever it appears in the module. */
+/* Prose keeps a measure wherever it appears in the module. Framed and ruled
+ blocks are structure and span their card, so they are not capped here. */
.copy,
.finePrint,
.notice,
-.actionWarning,
-.restoreWarning {
+.authWarning,
+.offerNote,
+.receiptWarning {
max-width: var(--measure-reading);
}
@@ -732,8 +734,7 @@
}
.sheetType,
-.sheetMeta,
-.receiptWarning {
+.sheetMeta {
font-family: var(--font-mono-ui), monospace;
font-size: 11px;
font-weight: 700;
@@ -741,6 +742,18 @@
text-transform: uppercase;
}
+/* A full sentence is prose, not a label: it leaves the mono label face that
+ the sheet type and meta line keep. */
+.receiptWarning {
+ color: var(--shell-muted);
+ font-size: 12px;
+ line-height: 1.55;
+}
+
+.receiptWarning strong {
+ color: var(--shell-text);
+}
+
.sheetType,
.sheetMeta {
color: var(--shell-muted);
@@ -832,9 +845,12 @@
background: var(--shell-panel);
}
+/* One framed block per card. A hazard note is ranked below the protocol truth
+ in .riskCopy, so it takes a rule rather than a second box inside the box. */
.actionWarning {
- padding: 10px 12px;
- border: 1px solid var(--shell-text);
+ padding: 8px 12px;
+ border: 0;
+ border-left: var(--border-record-width) solid var(--shell-text);
background: var(--shell-panel);
}
diff --git a/src/lib/otc.test.ts b/src/lib/otc.test.ts
index 74694bc..d2561e3 100644
--- a/src/lib/otc.test.ts
+++ b/src/lib/otc.test.ts
@@ -98,6 +98,13 @@ describe("OTC payloads", () => {
expect(parseDecimalToBaseUnits("0.01", 18)).toBe("10000000000000000");
expect(parseDecimalToBaseUnits("2.5", 6)).toBe("2500000");
expect(formatBaseUnits("2500000", 6)).toBe("2.5");
+ // Grouping is display-only and never touches the fraction.
+ expect(formatBaseUnits((25_000n * 10n ** 18n).toString(), 18)).toBe(
+ "25,000",
+ );
+ expect(formatBaseUnits("1234567890123", 6)).toBe("1,234,567.890123");
+ expect(formatBaseUnits("999", 0)).toBe("999");
+ expect(formatBaseUnits("1000", 0)).toBe("1,000");
expect(() => parseDecimalToBaseUnits("0.0000001", 6)).toThrow(/6 decimal/i);
expect(isPositiveBaseUnitAmount(((1n << 256n) - 1n).toString())).toBe(true);
expect(isPositiveBaseUnitAmount((1n << 256n).toString())).toBe(false);
diff --git a/src/lib/otc.ts b/src/lib/otc.ts
index 54a4347..cb6fcc8 100644
--- a/src/lib/otc.ts
+++ b/src/lib/otc.ts
@@ -421,16 +421,24 @@ export function parseDecimalToBaseUnits(
return units.toString();
}
+/**
+ * Thousands separators on the integer part only. A desk reads 25,000 STRK at a
+ * glance and has to count the digits of 25000; the fraction is never grouped,
+ * and the separator is a literal comma rather than a locale format so a decimal
+ * point cannot become a group mark on another machine.
+ */
+function groupThousands(whole: string): string {
+ return whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
+}
+
export function formatBaseUnits(amount: string, decimals: number): string {
if (!BASE_UNITS_PATTERN.test(amount)) return amount;
if (!Number.isInteger(decimals) || decimals <= 0)
- return BigInt(amount).toString();
+ return groupThousands(BigInt(amount).toString());
const padded = amount.padStart(decimals + 1, "0");
- const whole = padded.slice(0, -decimals);
+ const whole = groupThousands(BigInt(padded.slice(0, -decimals)).toString());
const fraction = padded.slice(-decimals).replace(/0+$/, "");
- return fraction
- ? `${BigInt(whole).toString()}.${fraction}`
- : BigInt(whole).toString();
+ return fraction ? `${whole}.${fraction}` : whole;
}
export function offerIsExpired(
From c01194708191016bfc49bb72316513dd1292a6bd Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 4 Sep 2026 14:19:14 +0000
Subject: [PATCH 11/35] fix(mail): stop Ignore from reading as an invoice's
main action
The dismissal shared the stretch treatment of the pay actions, so on an
invoice with nothing payable from this rail it took the whole action row and
looked like the card's primary button. It keeps its size and sits at the end
of the row.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB
---
src/components/mail/InvoiceCard.tsx | 4 +++-
src/components/mail/mail.module.css | 6 ++++++
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/src/components/mail/InvoiceCard.tsx b/src/components/mail/InvoiceCard.tsx
index e00b33f..d8e28bc 100644
--- a/src/components/mail/InvoiceCard.tsx
+++ b/src/components/mail/InvoiceCard.tsx
@@ -453,8 +453,10 @@ export default function InvoiceCard({
Pay privately with STRK
) : null}
+ {/* Dismissing is not this card's main action: with nothing payable
+ here it used to stretch across the row and read like one. */}
setIgnored(true)}
disabled={busy}
diff --git a/src/components/mail/mail.module.css b/src/components/mail/mail.module.css
index cd42bd3..1ad52ca 100644
--- a/src/components/mail/mail.module.css
+++ b/src/components/mail/mail.module.css
@@ -876,6 +876,12 @@
margin-top: 0;
}
+.sheetActions .ignoreAction {
+ flex: 0 0 auto;
+ margin-left: auto;
+ padding-inline: 18px;
+}
+
.chainDisclosure {
margin-top: 14px;
border: 1px solid var(--shell-public, #8cafb7);
From d722be37fc88a80e2aa3a7a3c95a44fcd0048ab6 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 4 Sep 2026 14:23:27 +0000
Subject: [PATCH 12/35] style(shell): make connecting the header's strongest
control
Connecting a wallet gates every module, and it was the weakest affordance on
the page: a rounded legacy pill that resolved to bare orange text beside a
bordered Pay link and a solid accent network chip. It becomes the desk's
square accent button, and a connected wallet turns into a quiet bordered
status chip, because a connected wallet is state rather than an action.
The active network then sat as a second solid accent block flush against it,
so the two read as one control. Which network is current is state too, and
the header already marks current with the tab's inset accent rule; the chip
uses that instead. On phones both wallet controls keep the accent and lose
the width they do not need.
Checked for horizontal overflow at 320, 360, 390, 768, 1024, 1180 and 1440.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB
---
src/app/components/session.module.css | 8 +++-
src/app/uni.module.css | 64 +++++++++++++++++++--------
2 files changed, 51 insertions(+), 21 deletions(-)
diff --git a/src/app/components/session.module.css b/src/app/components/session.module.css
index 1c0204a..19d88f9 100644
--- a/src/app/components/session.module.css
+++ b/src/app/components/session.module.css
@@ -86,9 +86,13 @@
opacity: 0.6;
}
+/* Which network is current is state, and the header already has one way of
+ marking current: the tab's inset accent rule. A solid accent block here also
+ sat flush against the connect button and read as one control. */
.networkToggle .networkActive {
- color: var(--shell-bg);
- background: var(--shell-orange);
+ color: var(--shell-text);
+ background: var(--shell-inset);
+ box-shadow: inset 0 -2px var(--shell-orange);
}
.networkToggle span[role="status"] {
diff --git a/src/app/uni.module.css b/src/app/uni.module.css
index 5f35190..cb64056 100644
--- a/src/app/uni.module.css
+++ b/src/app/uni.module.css
@@ -466,36 +466,60 @@
/* Connect controls */
.connectPill {
- font-family: var(--font-body), sans-serif;
- font-weight: 600;
- font-size: 14px;
- color: var(--pink-text);
- background: var(--pink-soft);
- border: none;
- border-radius: 999px;
- padding: 9px 18px;
+ display: inline-flex;
+ align-items: center;
+ min-height: 44px;
+ font-family: var(--font-mono-ui), monospace;
+ font-weight: 800;
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--on-accent);
+ background: var(--shell-orange, var(--pink));
+ border: 1px solid var(--shell-orange, var(--pink));
+ border-radius: 0;
+ padding: 0 16px;
cursor: pointer;
- transition: background 140ms;
+ transition:
+ background 140ms,
+ color 140ms;
}
.connectPill:hover {
- background: var(--pink-soft-2);
+ color: var(--shell-orange, var(--pink));
+ background: transparent;
}
.connectPill:disabled {
opacity: 0.6;
cursor: wait;
}
+
+/* Phones: keep the solid accent, lose the width it does not need. */
+@media (max-width: 560px) {
+ .connectPill {
+ min-height: 38px;
+ padding: 0 10px;
+ font-size: 10px;
+ letter-spacing: 0.05em;
+ }
+ .addrPill {
+ min-height: 38px;
+ font-size: 11px;
+ padding: 0 6px 0 10px;
+ }
+}
.addrPill {
display: inline-flex;
align-items: center;
gap: 9px;
+ min-height: 44px;
font-family: var(--font-mono-ui), monospace;
- font-size: 13px;
+ font-size: 12px;
font-weight: 700;
color: var(--ink);
- background: var(--surface-raised);
+ background: transparent;
border: 1px solid var(--line);
- border-radius: 999px;
- padding: 7px 7px 7px 13px;
+ border-radius: 0;
+ padding: 0 9px 0 13px;
cursor: pointer;
}
.addrDot {
@@ -505,13 +529,15 @@
background: var(--green);
}
.addrDisconnect {
- font-family: var(--font-body), sans-serif;
- font-size: 12px;
- font-weight: 600;
+ font-family: var(--font-mono-ui), monospace;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
color: var(--muted);
background: var(--inset);
- border-radius: 999px;
- padding: 4px 10px;
+ border-radius: 0;
+ padding: 5px 8px;
}
.errorText {
From d0b1dc7db68fa626d1536f452fb43a44b524a5a7 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 4 Sep 2026 14:26:15 +0000
Subject: [PATCH 13/35] style(shell): bring the wallet picker onto the desk
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The dialog that the mailbox's own accent button opens arrived as a rounded
pastel card with a soft drop shadow and a circular close button — the one
surface in the flow that still looked like a different product. It takes the
desk's dialog: square, accent hairline, hard offset shadow, square controls.
The mailbox connect wrapper also styled every descendant button, which
repainted that dialog's close button and wallet rows in solid accent; it
styles its own trigger only.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB
---
src/app/uni.module.css | 28 ++++++++++++++--------------
src/components/mail/mail.module.css | 4 ++--
2 files changed, 16 insertions(+), 16 deletions(-)
diff --git a/src/app/uni.module.css b/src/app/uni.module.css
index cb64056..60418ca 100644
--- a/src/app/uni.module.css
+++ b/src/app/uni.module.css
@@ -561,11 +561,11 @@
}
.modal {
width: min(400px, calc(100vw - 32px));
- background: var(--card);
- border: 1px solid var(--line);
- border-radius: 22px;
- box-shadow: 0 20px 60px -18px var(--shadow);
- padding: 18px;
+ background: var(--shell-bg, var(--card));
+ border: 1px solid var(--shell-orange, var(--line));
+ border-radius: 0;
+ box-shadow: var(--shadow-dialog, 0 20px 60px -18px var(--shadow));
+ padding: 16px;
}
.modalHead {
display: flex;
@@ -575,18 +575,18 @@
}
.modalTitle {
margin: 0;
- font-weight: 600;
+ font-weight: 800;
font-size: 17px;
- letter-spacing: -0.01em;
+ letter-spacing: -0.02em;
}
.modalClose {
display: grid;
place-items: center;
width: 30px;
height: 30px;
- border: none;
- border-radius: 50%;
- background: var(--inset);
+ border: 1px solid var(--line);
+ border-radius: 0;
+ background: transparent;
color: var(--muted);
font-size: 20px;
line-height: 1;
@@ -611,9 +611,9 @@
align-items: center;
gap: 13px;
width: 100%;
- padding: 13px 14px;
+ padding: 12px 13px;
border: 1px solid var(--line);
- border-radius: 15px;
+ border-radius: 0;
background: var(--surface-raised);
cursor: pointer;
text-align: left;
@@ -623,7 +623,7 @@
}
.walletRow:not(:disabled):hover {
background: var(--inset);
- border-color: var(--pink-soft-2);
+ border-color: var(--shell-orange, var(--pink));
}
.walletRow:disabled {
opacity: 0.6;
@@ -632,7 +632,7 @@
.walletIcon {
width: 32px;
height: 32px;
- border-radius: 8px;
+ border-radius: 0;
flex: none;
}
.walletName {
diff --git a/src/components/mail/mail.module.css b/src/components/mail/mail.module.css
index 1ad52ca..4405c7b 100644
--- a/src/components/mail/mail.module.css
+++ b/src/components/mail/mail.module.css
@@ -2385,7 +2385,7 @@
/* 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 {
+.connectAction > button {
width: auto;
margin: 0;
padding: 9px 12px;
@@ -2401,7 +2401,7 @@
cursor: pointer;
}
-.connectAction button:hover {
+.connectAction > button:hover {
color: var(--shell-bg);
background: var(--shell-text);
border-color: var(--shell-text);
From d1172e3a32728225db2abfd741c8db8e682aa4ee Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 4 Sep 2026 15:19:45 +0000
Subject: [PATCH 14/35] style(rfq): give the workspace a legible frame
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The view's own navigation was 8px mono — the smallest type on the page, for
the control that switches between its four screens. It reads at the size the
app header and the mail sidebar use.
View titles were set in the reading serif at regular weight, the face the
design system reserves for what a human wrote; New request, Active, Activity
and a record's own heading are UI, so they take the body face at display
weight.
Activity had no container: its heading and two paragraphs of storage prose ran
from the window edge to the far side of a 1440px viewport, beside cards that
were inset. It gets the same 16px inset as Active, and that prose keeps a
measure.
On the operations route the router marked /rfq current as well, so both chips
in the sub-nav claimed to be the open page.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB
---
.scratch-shot.mjs | 11 ++++++++
src/app/rfq/OperationsDashboard.tsx | 6 ++++-
src/app/rfq/RfqActivity.tsx | 1 +
src/app/rfq/rfq.module.css | 39 ++++++++++++++++++++++-------
4 files changed, 47 insertions(+), 10 deletions(-)
create mode 100644 .scratch-shot.mjs
diff --git a/.scratch-shot.mjs b/.scratch-shot.mjs
new file mode 100644
index 0000000..1769d49
--- /dev/null
+++ b/.scratch-shot.mjs
@@ -0,0 +1,11 @@
+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();
diff --git a/src/app/rfq/OperationsDashboard.tsx b/src/app/rfq/OperationsDashboard.tsx
index 783c0c1..1ff0e94 100644
--- a/src/app/rfq/OperationsDashboard.tsx
+++ b/src/app/rfq/OperationsDashboard.tsx
@@ -16,7 +16,11 @@ export default function OperationsDashboard() {
const status = availability.status;
return
diff --git a/src/app/rfq/RfqActivity.tsx b/src/app/rfq/RfqActivity.tsx
index ba5d6b1..e22ccd2 100644
--- a/src/app/rfq/RfqActivity.tsx
+++ b/src/app/rfq/RfqActivity.tsx
@@ -133,6 +133,7 @@ export default function RfqActivity({
}) {
return (
diff --git a/src/app/rfq/rfq.module.css b/src/app/rfq/rfq.module.css
index b3a0bfe..af95750 100644
--- a/src/app/rfq/rfq.module.css
+++ b/src/app/rfq/rfq.module.css
@@ -85,12 +85,31 @@
content: "·";
}
-.activeRecords {
+.activeRecords,
+.activityView {
display: grid;
gap: 12px;
padding: 16px;
}
+.activeRecords > h2,
+.activityView > h2 {
+ margin: 0;
+ font-family: var(--font-body), system-ui, sans-serif;
+ font-size: 22px;
+ font-weight: 800;
+ letter-spacing: -0.03em;
+}
+
+/* Storage and reconciliation prose ran the full window width. */
+.activeRecords > p,
+.activityView > p {
+ max-width: var(--measure-reading);
+ margin: 0;
+ color: var(--shell-muted);
+ line-height: 1.55;
+}
+
.activeCard {
display: grid;
gap: 10px;
@@ -176,9 +195,9 @@
background: transparent;
cursor: pointer;
font-family: var(--font-mono-ui), monospace;
- font-size: 8px;
+ font-size: 10px;
font-weight: 700;
- letter-spacing: 0.1em;
+ letter-spacing: 0.09em;
text-decoration: none;
text-transform: uppercase;
}
@@ -2402,9 +2421,10 @@
.viewHeading {
margin: 0;
padding: 14px 16px 12px;
- font-family: var(--font-reading), Georgia, serif;
- font-size: 20px;
- font-weight: 400;
+ font-family: var(--font-body), system-ui, sans-serif;
+ font-size: 22px;
+ font-weight: 800;
+ letter-spacing: -0.03em;
}
.page > section:focus-visible,
@@ -2541,9 +2561,10 @@
.activityRecord h3 {
margin: 0;
- font-family: var(--font-reading), Georgia, serif;
- font-size: 20px;
- font-weight: 400;
+ font-family: var(--font-body), system-ui, sans-serif;
+ font-size: 16px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
}
.activityIds {
From 848c141bbbb9a59a683105a10485596570fe13c4 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 4 Sep 2026 15:26:03 +0000
Subject: [PATCH 15/35] fix(rfq): answer the disconnected desk once, with the
step that exists
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A disabled rail said the same thing three times — the environment banner, a
wallet-recovery card, and the ticket's own notice — and the only button among
them was a grey "Review wallet connection" that re-read storage which cannot
answer without a wallet. Where the rail cannot execute an RFQ at all, the
recovery card stands down (connecting a wallet does not enable Sepolia) and
the reason leads the view at full width instead of trailing it at a third.
Where a wallet genuinely is the missing piece, the card carries the wallet
control itself, styled as the accent action rather than a browser-default box.
The public market board also stopped pretending: PUBLIC SPOT read "OPT IN" in
the slot where a price goes, and the opt-in that loads third-party data sat
inside the 1D/7D/30D group as though it were a range. The price cell reads
"not loaded", and the opt-in moved to the empty chart it fills, next to the
disclosure of what opting in discloses. The chart's placeholder no longer
reserves 300px of black to say it is empty.
Labels in the densest panel of the product were 8px mono; they are 9px, and
the eight info dots are muted until hovered so the accent belongs to controls.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB
---
src/app/rfq/DeskMarketBoard.tsx | 70 +++++++-----
src/app/rfq/RfqRecoveryCard.tsx | 12 +-
src/app/rfq/RfqWorkspace.tsx | 21 +++-
src/app/rfq/rfq-authority-ui.test.tsx | 5 +-
src/app/rfq/rfq.module.css | 151 +++++++++++++++++++++++---
5 files changed, 209 insertions(+), 50 deletions(-)
diff --git a/src/app/rfq/DeskMarketBoard.tsx b/src/app/rfq/DeskMarketBoard.tsx
index 4631f55..1173eb4 100644
--- a/src/app/rfq/DeskMarketBoard.tsx
+++ b/src/app/rfq/DeskMarketBoard.tsx
@@ -289,13 +289,14 @@ export default function DeskMarketBoard({ pairId }: DeskMarketBoardProps) {
{chart
? formatPrice(chart.summary.latest)
- : priceState.kind === "error"
- ? "—"
- : priceState.kind === "idle"
- ? "OPT IN"
- : "LOADING"}
+ : priceState.kind === "loading"
+ ? "LOADING"
+ : "—"}
- {quoteUnit} · CoinGecko
+
+ {quoteUnit} · CoinGecko
+ {chart || priceState.kind !== "idle" ? "" : " · not loaded"}
+
@@ -348,29 +349,31 @@ export default function DeskMarketBoard({ pairId }: DeskMarketBoardProps) {
PUBLIC OHLC · COINGECKO
-
+ {/* 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 (
-
+
) : 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({
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.
- ✉
-
-
- {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 ?
@@ -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
+
+
-
+
GitHub ↗
-
- Ciphertext is public on-chain. Device encryption is optional.
-
diff --git a/src/components/mail/Thread.tsx b/src/components/mail/Thread.tsx
index deb277f..c6af871 100644
--- a/src/components/mail/Thread.tsx
+++ b/src/components/mail/Thread.tsx
@@ -346,9 +346,7 @@ function ConversationControls({
{proof ?
{senderProofLabel(proof)}
: null}
{fields.conversationId ? (
Conversation tag {fields.conversationId.slice(0, 18)}…
- ) : (
- No conversation tag — a reply starts one.
- )}
+ ) : null}
{canContinue ? (
) : null}
{paymentLink ? null : }
-
- {paymentLink
- ? "decoded from this tab's URL fragment"
- : message.threadProvenance === "device_sent_and_on_chain"
- ? "device-local Sent copy matched to decrypted on-chain evidence"
- : message.direction === "outgoing"
- ? "sealed on this device"
- : "decrypted on this device"}
-
+ {/* Only the two provenances a reader cannot infer from the
+ pane's own head are worth a line under every record. */}
+ {paymentLink ||
+ message.threadProvenance === "device_sent_and_on_chain" ? (
+
+ {paymentLink
+ ? "decoded from this tab's URL fragment"
+ : "device-local Sent copy matched to decrypted on-chain evidence"}
+
+ ) : null}
);
})}
From 76700eb601c34e8855e1d7b563d11bd2f1805d6b Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 4 Sep 2026 17:23:23 +0000
Subject: [PATCH 26/35] fix: answer one gate once, and put conversation actions
on the conversation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An empty mailbox gave three instructions for one state: the welcome sheet
asked for a wallet, the rail said to check for mail, and the scan panel said
to set up a key. One derived gate — wallet, then key, then neither — now
answers in all three places. Search and the local-index note also stop
rendering above a mailbox that has no records to search.
Replying and naming a sender are acts on a conversation but rendered per
record, so a five-record thread carried five reply buttons and five naming
forms, under two different verbs. Reply lives in the reading toolbar, once;
naming lives under the thread head, once, with the reason a reply cannot
prefill a recipient beside it. A record keeps only what is its own: its
conversation tag, its sender proof, its unbound auth claim. The decorative
CLIP / 01 stamp in the thread head is gone.
The signal bar named the RFQ workspace on every route, Mailbox included; it
names the module you are in. Pay was a fourth destination styled as a wallet
utility, sitting among the account controls with an accent border; it joins
the tabs it belongs with.
On a disabled RFQ rail the banner states the rail and the notice below it
restated the same sentence; the notice explains instead. The Elsewhere row no
longer links to a public send whose own label said it was unavailable.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_016c1PxPnJjZfByfqUjYgvKB
---
src/app/components/AppShell.tsx | 29 +++--
src/app/globals.css | 16 +--
src/app/inbox/page.tsx | 14 ++-
src/app/rfq/RfqWorkspace.tsx | 5 +-
src/components/mail/ConversationList.tsx | 32 ++++--
src/components/mail/Thread.tsx | 140 +++++++++++------------
src/components/mail/mail.module.css | 15 +++
7 files changed, 140 insertions(+), 111 deletions(-)
diff --git a/src/app/components/AppShell.tsx b/src/app/components/AppShell.tsx
index b13ee29..db1ef93 100644
--- a/src/app/components/AppShell.tsx
+++ b/src/app/components/AppShell.tsx
@@ -18,6 +18,16 @@ export default function AppShell({ renderLocalnetTools }: AppShellProps) {
const mailActive = pathname.startsWith("/mail") || pathname === "/inbox";
const rfqActive = pathname === "/rfq" || pathname.startsWith("/rfq/");
const connected = walletMode === "privy" ? privyConnected : readyConnected;
+ const payActive = pathname === "/pay";
+ const moduleName = mailActive
+ ? "MAILBOX"
+ : payActive
+ ? "PAY"
+ : pathname === "/contacts"
+ ? "COUNTERPARTIES"
+ : rfqActive
+ ? "RFQ WORKSPACE"
+ : "APP20";
return (
- {/* The bar carries live session state. The sentence that used to sit
- beside it never changed, on any route, in any state — and each view
- states its own boundary where that boundary applies. */}
+ {/* Live session state for the module you are actually in. It used to
+ name the RFQ workspace on every route, Mailbox included. */}
{connected
- ? "WALLET CONNECTED · PUBLIC-NETWORK RFQ DISABLED"
- : "APP20 RFQ WORKSPACE"}
+ ? `${moduleName} · WALLET CONNECTED · PUBLIC-NETWORK RFQ DISABLED`
+ : `${moduleName} · NO WALLET CONNECTED`}
Counterparties
-
-
-
+
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."}
{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
+
+
+
- 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
-
-
- ) : null}
{assigned && onProve ? (
onProve(message.id, assigned)}>
Inspect unbound auth claim
@@ -790,11 +785,16 @@ export default function Thread({
{headDetail}
) : null}
-
- CLIP / 01
-
+
+
{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({