diff --git a/src/app/components/AppShell.tsx b/src/app/components/AppShell.tsx
index 3a36694..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 (
- {keypair
- ? "Check for mail or compose a letter."
- : "Composing sets up your mailbox key when needed."}
+ {walletGateShown
+ ? "Composing sets up your mailbox key when needed."
+ : !address || !chainId
+ ? "Mail is keyed to a wallet: connect one to read this mailbox or write a letter. Your mailbox key is set up here when it is first needed."
+ : keypair
+ ? "Check for mail or compose a letter."
+ : "Composing sets up your mailbox key when needed."}
-
- Compose encrypted mail
-
+ {/* The CTA has to be the step that is actually available:
+ offering compose to a disconnected desk only produced a
+ notice pointing at a control in the app header. The gate
+ notice carries the same control, so only one of the two
+ asks for the wallet at a time. */}
+ {walletGateShown ? null : !address || !chainId ? (
+
+
+
+ ) : (
+
+ Compose encrypted mail
+
+ )}
)}
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
{pairLabel} candlesticks
-
-
setPublicContextEnabled((value) => !value)}
- >
- {publicContextEnabled
- ? "Disable third-party data"
- : "Load CoinGecko context"}
-
- {PUBLIC_PRICE_RANGES.map((item) => (
+
+ {publicContextEnabled ? (
setRange(item.value)}
+ className={styles.publicContextToggle}
+ onClick={() => setPublicContextEnabled(false)}
>
- {item.label}
+ Disable third-party data
- ))}
+ ) : null}
+
+ {PUBLIC_PRICE_RANGES.map((item) => (
+ setRange(item.value)}
+ >
+ {item.label}
+
+ ))}
+
@@ -529,10 +532,19 @@ export default function DeskMarketBoard({ pairId }: DeskMarketBoardProps) {
: "Loading public candlesticks…"}
{priceState.kind === "idle" ? (
-
- Opting in contacts CoinGecko directly and discloses your IP
- address, user agent, request timing, and selected range.
-
+ <>
+
+ Opting in contacts CoinGecko directly and discloses your
+ IP address, user agent, request timing, and selected
+ range.
+
+
setPublicContextEnabled(true)}
+ >
+ Load CoinGecko context
+
+ >
) : null}
{priceState.kind === "error" ? (
<>
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({
-
+
- Invite every named fixture maker with only the reviewed size
- bucket.
+
+ Invite every named fixture maker with only the reviewed size
+ bucket.
+
- {flow.kind === "working"
- ? "Requesting…"
- : "Request collateralized quotes"}
+ {flow.kind === "working" ? "Requesting…" : "Request quotes"}
{waitForMaturity ? (
| "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,17 @@ export default function MakerCohortPanel({
? directoryFreshnessState(directory, now)
: "unavailable";
return (
-
+
Invited-maker cohort
Governed makers {summary.governed} · invited {summary.invited} ·
responded {summary.responded} · refused {summary.refused} · unavailable{" "}
{summary.unavailable}.
-
+
Maker-directory epoch
{directory?.epoch ?? "Unavailable"}
@@ -144,6 +175,24 @@ export default function MakerCohortPanel({
{maker.makerId}
{selected ?
· Selected : null}
+
+
+ {eligible
+ ? selected
+ ? "Eligible · selected"
+ : "Eligible"
+ : "Excluded"}
+ {" "}
+ ·{" "}
+ {eligible
+ ? maker.rationale
+ : exclusionReason(maker, {
+ keyEligible,
+ freshness,
+ quoted: Boolean(quote),
+ quoting: quotes.length > 0,
+ })}
+
{quote &&
sellDecimals !== undefined &&
@@ -163,43 +212,33 @@ export default function MakerCohortPanel({
"No eligible signed quote"
)}
-
-
-
Key ID
-
- {maker.keyId}
-
-
-
-
Key status
-
- {keyPresentation(maker, now)} · valid
- through{" "}
- {new Date(maker.keyValidUntil * 1_000).toISOString()}
-
-
-
-
Invitation
- {maker.invitationStatus}
-
-
-
Capacity band
- {maker.capacityBand} · raw inventory not exposed
-
-
-
Eligibility
-
-
- {eligible
- ? selected
- ? "Eligible · selected"
- : "Eligible"
- : "Excluded"}
- {" "}
- · {maker.rationale}
-
-
-
+
+ Key, invitation and capacity
+
+
+
Key ID
+
+ {maker.keyId}
+
+
+
+
Key status
+
+ {keyPresentation(maker, now)} · valid
+ through{" "}
+ {new Date(maker.keyValidUntil * 1_000).toISOString()}
+
+
+
+
Invitation
+ {maker.invitationStatus}
+
+
+
Capacity band
+ {maker.capacityBand} · raw inventory not exposed
+
+
+
);
})}
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
- RFQ workspace
+ {/* Without exact matching the router marks /rfq current on
+ /rfq/operations too, so both chips read as the open page. */}
+
+ RFQ workspace
+
Operations
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() {
/>
{rfqStateLabel(record.state, record.mode)}
{record.terms ? (
-
- {record.terms.sellAmount} base units {record.terms.sellSymbol} →{" "}
- {record.selectedQuote?.buyAmount ??
- record.terms.buyAmount ??
- "unselected"}{" "}
- base units {record.terms.buySymbol}
-
+
+ {/* A 23-digit integer is the exact record, not something a person
+ can read at a glance; it stays, one rank below the human units. */}
+
+ {humanUnits(
+ BigInt(record.terms.sellAmount),
+ record.terms.sellDecimals,
+ )}{" "}
+ {record.terms.sellSymbol} →{" "}
+ {receivedAmount === undefined
+ ? "unselected"
+ : `${humanUnits(receivedAmount, record.terms.buyDecimals)} ${record.terms.buySymbol}`}
+
+
+ exactly {record.terms.sellAmount} →{" "}
+ {receivedAmount?.toString() ?? "unselected"} base units
+
+
) : null}
{record.mode === "v3" ? (
diff --git a/src/app/rfq/RfqActiveList.tsx b/src/app/rfq/RfqActiveList.tsx
index 5022854..a02e68a 100644
--- a/src/app/rfq/RfqActiveList.tsx
+++ b/src/app/rfq/RfqActiveList.tsx
@@ -56,16 +56,19 @@ export default function RfqActiveList({
/>
))
) : loadState === "ready" ? (
-
- No active RFQ for this wallet and chain. Start one from{" "}
- New .
+
+ Nothing in flight for this wallet and chain.{" "}
+ Start a request
) : null}
-
{RFQ_STORAGE_DISCLOSURE}
-
- Restoring and reconciling never automatically resubmits fund, fill,
- claim, or refund.
-
+
+ What this browser stores
+ {RFQ_STORAGE_DISCLOSURE}
+
+ Restoring and reconciling never automatically resubmits fund, fill,
+ claim, or refund.
+
+
{records.length && records.every(lifecycleMayForget) && onClearAll ? (
Forget all terminal browser history for this wallet and chain
diff --git a/src/app/rfq/RfqActivity.tsx b/src/app/rfq/RfqActivity.tsx
index ba5d6b1..c6424d1 100644
--- a/src/app/rfq/RfqActivity.tsx
+++ b/src/app/rfq/RfqActivity.tsx
@@ -11,8 +11,20 @@ import { AuthorityStrip } from "./RfqAuthorityStrip";
import RfqRecoveryCard from "./RfqRecoveryCard";
import { useRfqAuthorityPresentation } from "./ui/use-rfq-authority-presentation";
import type { WorkspaceLoadState } from "./workspace-load-state";
+import { humanUnits } from "./human-units";
import styles from "./rfq.module.css";
+/** The received leg in human units, or why there is not one yet. */
+function receivedLabel(record: RfqLifecycleRecord): string {
+ const raw = record.selectedQuote?.buyAmount ?? record.terms?.buyAmount;
+ if (!record.terms || raw === undefined) return "unselected";
+ try {
+ return `${humanUnits(BigInt(raw), record.terms.buyDecimals)} ${record.terms.buySymbol}`;
+ } catch {
+ return "unselected";
+ }
+}
+
function activityBucket(record: RfqLifecycleRecord): string | undefined {
if (record.mode !== "v3" || !record.bucket || !record.terms) return undefined;
if (record.terms.sellSymbol !== "STRK" && record.terms.sellSymbol !== "USDC") {
@@ -52,12 +64,9 @@ function RfqActivityRecord({
{heading} · {rfqStateLabel(record.state, record.mode)}
{record.terms ? (
-
- {record.terms.sellAmount} base units {record.terms.sellSymbol} →{" "}
- {record.selectedQuote?.buyAmount ??
- record.terms.buyAmount ??
- "unselected"}{" "}
- base units {record.terms.buySymbol}
+
+ {humanUnits(BigInt(record.terms.sellAmount), record.terms.sellDecimals)}{" "}
+ {record.terms.sellSymbol} → {receivedLabel(record)}
) : null}
{record.mode === "v3" ? (
@@ -133,6 +142,7 @@ export default function RfqActivity({
}) {
return (
@@ -157,9 +167,19 @@ export default function RfqActivity({
))
) : loadState === "ready" ? (
- No saved RFQ history for this wallet and chain yet.
+
+ No RFQ history for this wallet and chain yet.{" "}
+ Start a request
+
) : null}
- {RFQ_STORAGE_DISCLOSURE}
+
+ What this browser stores
+ {RFQ_STORAGE_DISCLOSURE}
+
+ Restoring and reconciling never automatically resubmits fund, fill,
+ claim, or refund.
+
+
{records.length && records.every(lifecycleMayForget) && onClearAll ? (
Forget all terminal browser history for this wallet and chain
diff --git a/src/app/rfq/RfqFinalReview.tsx b/src/app/rfq/RfqFinalReview.tsx
index 0e67ac3..1205bd7 100644
--- a/src/app/rfq/RfqFinalReview.tsx
+++ b/src/app/rfq/RfqFinalReview.tsx
@@ -9,16 +9,9 @@ import {
} from "./rfq-final-review";
import CopyableId from "./CopyableId";
import RfqCountdown from "./RfqCountdown";
+import { humanUnits } from "./human-units";
import styles from "./rfq.module.css";
-function humanUnits(value: bigint, decimals: number): string {
- if (decimals === 0) return value.toString();
- const digits = value.toString().padStart(decimals + 1, "0");
- const whole = digits.slice(0, -decimals) || "0";
- const fraction = decimals ? digits.slice(-decimals).replace(/0+$/, "") : "";
- return fraction ? `${whole}.${fraction}` : whole;
-}
-
export type RfqFinalReviewV3DisplayTerms = RfqFinalReviewV3Terms &
Readonly<{
sellSymbol: string;
diff --git a/src/app/rfq/RfqRecoveryCard.tsx b/src/app/rfq/RfqRecoveryCard.tsx
index 433a5d6..6a772f2 100644
--- a/src/app/rfq/RfqRecoveryCard.tsx
+++ b/src/app/rfq/RfqRecoveryCard.tsx
@@ -1,3 +1,5 @@
+import SelectWallet from "@/app/components/client/WalletHandle/SelectWallet";
+import { useStoreWallet } from "@/app/components/Wallet/walletContext";
import styles from "./rfq.module.css";
import type { WorkspaceLoadState } from "./workspace-load-state";
@@ -54,8 +56,12 @@ export default function RfqRecoveryCard({
busy?: boolean;
onRetry?: () => void;
}) {
+ const address = useStoreWallet((state) => state.address);
const recovery = RECOVERY[loadState];
if (!recovery) return null;
+ /* Offline with no wallet at all, the honest control is the wallet itself:
+ "Review wallet connection" only re-read storage that cannot answer yet. */
+ const askForWallet = loadState === "stale/offline" && !address;
return (
Reported: {detail}
) : null}
- {recovery.retryLabel && onRetry ? (
+ {askForWallet ? (
+
+
+
+ ) : recovery.retryLabel && onRetry ? (
{busy ? "Checking…" : recovery.retryLabel}
diff --git a/src/app/rfq/RfqWorkspace.tsx b/src/app/rfq/RfqWorkspace.tsx
index e345bad..1a8ae8c 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,
+ }),
+ ),
);
}
@@ -199,10 +200,19 @@ export function RfqWorkspaceActiveBoundary(props: {
);
}
+/* #activity was the history tab: someone arriving on it expects every record,
+ not only the ones still in flight. */
+function initialRecordScope(hash: string): RecordScope {
+ return hash.replace(/^#/, "") === "activity" ? "all" : "in-flight";
+}
+
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 +286,11 @@ 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(() =>
+ initialRecordScope(hash),
+ );
const [pairId, setPairId] = useState(requestedPair);
const [records, setRecords] = useState([]);
const [loadState, setLoadState] = useState("loading");
@@ -1161,21 +1176,12 @@ export default function RfqWorkspace() {
- Active
+ Records
-
- Activity
-
- Operations
{recordError ? {recordError}
: null}
{view === "compatibility" ? (
@@ -1186,23 +1192,55 @@ export default function RfqWorkspace() {
Open dry cross-chain review
) : null}
- {view === "active" ? (
-
- void runRecordAction(record, action)}
- onRemove={(record) => void removeRecord(record)}
- onClearAll={() => void clearAllRecords()}
- onRetryLoad={retryWorkspaceLoad}
- />
+ {view === "records" ? (
+
+
+ setRecordScope("in-flight")}
+ >
+ In flight {activeRecords.length}
+
+ setRecordScope("all")}
+ >
+ All {records.length}
+
+
+ {recordScope === "in-flight" ? (
+
+ void runRecordAction(record, action)
+ }
+ onRemove={(record) => void removeRecord(record)}
+ onClearAll={() => void clearAllRecords()}
+ onRetryLoad={retryWorkspaceLoad}
+ />
+ ) : (
+ <>
+ void removeRecord(record)}
+ onClearAll={() => void clearAllRecords()}
+ onRetryLoad={retryWorkspaceLoad}
+ />
+
+ >
+ )}
{resumeReviewRecord ? (
) : null}
- {view === "activity" ? (
-
- void removeRecord(record)}
- onClearAll={() => void clearAllRecords()}
- onRetryLoad={retryWorkspaceLoad}
- />
-
-
- ) : null}
{view === "new" ? (
New request
-
+ {/* 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
- ? "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."}
)}
+
+ {/* 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
+
+
-
+
+ Elsewhere
+ RFQ operations status
Shield / unshield funding
- Public send · unavailable
Mail · coordination only
Cross-chain dry review
Privy recovery
diff --git a/src/app/rfq/human-units.ts b/src/app/rfq/human-units.ts
new file mode 100644
index 0000000..ac68309
--- /dev/null
+++ b/src/app/rfq/human-units.ts
@@ -0,0 +1,13 @@
+/**
+ * Base units rendered for a person: the integer part grouped with literal
+ * commas, the fraction never grouped. The separator is fixed rather than
+ * locale-formatted so a decimal point cannot become a group mark elsewhere.
+ */
+export function humanUnits(value: bigint, decimals: number): string {
+ const digits = value.toString().padStart(decimals + 1, "0");
+ const whole = decimals === 0 ? value.toString() : digits.slice(0, -decimals);
+ const grouped = (whole || "0").replace(/\B(?=(\d{3})+(?!\d))/g, ",");
+ if (decimals === 0) return grouped;
+ const fraction = digits.slice(-decimals).replace(/0+$/, "");
+ return fraction ? `${grouped}.${fraction}` : grouped;
+}
diff --git a/src/app/rfq/rfq-authority-ui.test.tsx b/src/app/rfq/rfq-authority-ui.test.tsx
index 24ab6be..f1f0b05 100644
--- a/src/app/rfq/rfq-authority-ui.test.tsx
+++ b/src/app/rfq/rfq-authority-ui.test.tsx
@@ -184,7 +184,7 @@ describe("activity records", () => {
const markup = renderToStaticMarkup(
undefined} />,
);
- expect(markup).not.toContain("No saved RFQ history");
+ expect(markup).not.toContain("No RFQ history for this wallet");
}
expect(
renderToStaticMarkup( ),
@@ -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-workspace-ia.test.tsx b/src/app/rfq/rfq-workspace-ia.test.tsx
index 5d08b94..587158a 100644
--- a/src/app/rfq/rfq-workspace-ia.test.tsx
+++ b/src/app/rfq/rfq-workspace-ia.test.tsx
@@ -83,7 +83,19 @@ describe("RFQ workspace information architecture", () => {
workspace.match(
/activeOptions=\{\{ exact: true, includeHash: true \}\}/g,
),
- ).toHaveLength(3);
+ ).toHaveLength(2);
+ });
+
+ it("keeps one records view with a scope instead of two tabs over one set", () => {
+ expect(workspace).toContain('aria-label="Record scope"');
+ expect(workspace).toContain('hash="records"');
+ expect(workspace).not.toContain('hash="activity"');
+ // Existing bookmarks still resolve.
+ expect(workspace).toContain('value === "active"');
+ expect(workspace).toContain('value === "activity"');
+ expect(workspace).toContain(
+ 'hash.replace(/^#/, "") === "activity" ? "all" : "in-flight"',
+ );
});
it("moves focus to a labelled region after hash navigation", () => {
@@ -91,8 +103,7 @@ describe("RFQ workspace information architecture", () => {
expect(workspace).toContain("viewRegionRef.current?.focus()");
for (const label of [
'aria-label="New RFQ request"',
- 'aria-label="Active RFQs"',
- 'aria-label="RFQ activity"',
+ 'aria-label="RFQ records"',
]) {
expect(workspace).toContain(label);
}
diff --git a/src/app/rfq/rfq.module.css b/src/app/rfq/rfq.module.css
index b3a0bfe..d7480d3 100644
--- a/src/app/rfq/rfq.module.css
+++ b/src/app/rfq/rfq.module.css
@@ -45,6 +45,34 @@
background: var(--shell-bg);
}
+/* An odd final row left the grid's own rule colour showing as an empty
+ painted cell beside the last fact. */
+.operationsDashboard dl > div:last-child:nth-child(odd) {
+ grid-column: 1 / -1;
+}
+
+.operationsDashboard dt {
+ color: var(--shell-muted);
+ font-family: var(--font-mono-ui), monospace;
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+}
+
+.operationsDashboard dd {
+ margin: 4px 0 0;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.operationsDashboard > header p,
+.operationsDashboard > p,
+.operationsDashboard section > p {
+ max-width: var(--measure-reading);
+ line-height: 1.55;
+}
+
.operationsDashboard code,
.operationsDashboard td {
overflow-wrap: anywhere;
@@ -85,12 +113,131 @@
content: "·";
}
-.activeRecords {
+/* One set of records, one view, an explicit scope — instead of two tabs whose
+ difference was a filter. */
+.recordScope {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 16px 16px 0;
+}
+
+.recordScope button {
+ display: inline-flex;
+ min-height: 34px;
+ align-items: center;
+ gap: 8px;
+ border: 1px solid var(--shell-line);
+ padding: 0 12px;
+ color: var(--shell-muted);
+ background: transparent;
+ cursor: pointer;
+ font-family: var(--font-mono-ui), monospace;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+}
+
+.recordScope button[aria-pressed="true"] {
+ color: var(--shell-bg);
+ background: var(--shell-orange);
+}
+
+.recordScope button strong {
+ font-size: 10px;
+}
+
+/* The scope control is the visible label for the list under it, so the list's
+ own heading stays for screen readers and stops repeating it on screen. */
+.recordScope + .activeRecords > h2,
+.recordScope + .activityView > h2 {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+.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;
+}
+
+.recordsEmpty a {
+ color: var(--shell-orange);
+ font-weight: 700;
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+
+/* Storage caveats are reference, not the page: one closed line under the
+ records, opened by whoever needs the policy. */
+.storageDisclosure {
+ max-width: var(--measure-reading);
+ border-top: 1px solid var(--shell-line);
+ color: var(--shell-muted);
+}
+
+.storageDisclosure > summary {
+ display: flex;
+ min-height: 36px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ color: var(--shell-muted);
+ font-family: var(--font-mono-ui), monospace;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ cursor: pointer;
+ list-style: none;
+}
+
+.storageDisclosure > summary::-webkit-details-marker {
+ display: none;
+}
+
+.storageDisclosure > summary::after {
+ color: var(--shell-orange);
+ content: "+";
+ font-size: 14px;
+ line-height: 1;
+}
+
+.storageDisclosure[open] > summary::after {
+ content: "−";
+}
+
+.storageDisclosure > p {
+ margin: 0 0 10px;
+ line-height: 1.55;
+}
+
.activeCard {
display: grid;
gap: 10px;
@@ -99,8 +246,7 @@
background: var(--shell-panel);
}
-.activeCard header,
-.phaseAction {
+.activeCard header {
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -108,6 +254,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));
@@ -176,9 +363,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;
}
@@ -529,28 +716,89 @@
color: var(--shell-danger, #e5484d);
}
+/* The ticket is the view: it is what a person came here to fill in. */
.privateWorkspace {
display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(380px, 420px);
+ grid-template-columns: minmax(0, 1fr);
max-width: 1600px;
- min-height: 620px;
gap: 16px;
margin: 0 auto;
padding: 0 16px 16px;
background: var(--shell-bg);
}
+.marketDisclosure {
+ max-width: 1600px;
+ margin: 0 auto 16px;
+ border: 1px solid var(--shell-line);
+ background: var(--shell-panel);
+}
+
+.marketDisclosure > summary {
+ display: flex;
+ min-height: 44px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 0 14px;
+ color: var(--shell-text);
+ cursor: pointer;
+ font-family: var(--font-mono-ui), monospace;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.09em;
+ list-style: none;
+ text-transform: uppercase;
+}
+
+.marketDisclosure > summary::-webkit-details-marker {
+ display: none;
+}
+
+.marketDisclosure > summary::after {
+ color: var(--shell-orange);
+ content: "+";
+ font-size: 15px;
+}
+
+.marketDisclosure[open] > summary {
+ border-bottom: 1px solid var(--shell-line);
+}
+
+.marketDisclosure[open] > summary::after {
+ content: "\2212";
+}
+
+.marketDisclosureHint {
+ margin-left: auto;
+ margin-right: 10px;
+ color: var(--shell-muted);
+ font-size: 9px;
+ letter-spacing: 0.08em;
+}
+
.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;
align-self: start;
flex-direction: column;
- order: 1;
- border: 1px solid var(--shell-line);
+ border: 0;
background: var(--shell-bg);
}
@@ -624,9 +872,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;
}
@@ -653,8 +901,8 @@
.marketStats small {
color: var(--shell-muted);
- font-size: 8px;
- line-height: 1.25;
+ font-size: 9px;
+ line-height: 1.3;
}
.infoTip {
@@ -679,7 +927,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;
@@ -691,6 +939,7 @@
.infoTipButton:hover,
.infoTipButton:focus-visible {
border-color: var(--shell-orange);
+ color: var(--shell-orange);
}
.infoTipButton:focus-visible {
@@ -802,9 +1051,35 @@
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 {
@@ -847,7 +1122,7 @@
}
.chartFrame {
- min-height: 320px;
+ min-height: 200px;
padding: 8px 10px 0;
background: var(--shell-bg);
}
@@ -948,7 +1223,7 @@
.chartStatus {
display: grid;
- min-height: 300px;
+ min-height: 180px;
place-content: center;
justify-items: center;
gap: 8px;
@@ -969,19 +1244,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;
@@ -1172,10 +1452,13 @@
align-self: start;
align-items: flex-start;
justify-content: stretch;
- order: 2;
background: var(--shell-bg);
}
+.tradeTicket > * {
+ width: 100%;
+}
+
.fundingDetails {
margin: 10px 14px 12px;
border: 1px solid var(--shell-line);
@@ -1223,9 +1506,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 {
@@ -1241,6 +1524,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;
}
@@ -1337,33 +1642,35 @@
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);
}
+/* Sits in the flow at the top of the ticket. Nothing behind it goes inert and
+ nothing is covered, so the wallet picker and the rest of the desk stay
+ reachable while it is up. */
.privacyBriefingDialog {
- width: min(540px, calc(100vw - 32px));
- max-height: calc(100dvh - 32px);
- margin: auto;
- overflow: auto;
- border: 1px solid var(--shell-orange);
+ position: static;
+ width: auto;
+ height: auto;
+ max-height: none;
+ margin: 0;
+ overflow: visible;
+ border: 0;
+ border-bottom: 1px solid var(--shell-orange);
padding: 0;
color: var(--shell-text);
background: var(--shell-bg);
- box-shadow: 10px 10px 0 rgb(0 0 0 / 45%);
font-family: var(--font-reading), sans-serif;
}
-.privacyBriefingDialog::backdrop {
- background: rgb(5 6 6 / 82%);
- backdrop-filter: blur(2px);
-}
-
.privacyBriefingDialog > header {
padding: 14px 16px 12px;
border-bottom: 1px solid var(--shell-line);
@@ -2027,7 +2334,7 @@
}
.finalReview dd {
- font-family: var(--font-sans-ui), sans-serif;
+ font-family: var(--font-body), system-ui, sans-serif;
font-size: 14px;
}
@@ -2347,13 +2654,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 {
@@ -2402,9 +2753,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,
@@ -2473,27 +2825,83 @@
.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;
+}
+
+.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;
}
@@ -2522,15 +2930,42 @@
.activityRecord summary,
.quoteComparison summary,
.finalReviewDetails summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
cursor: pointer;
min-height: 44px;
padding: 12px 0;
font-family: var(--font-mono-ui), monospace;
font-size: 11px;
letter-spacing: 0.06em;
+ list-style: none;
text-transform: uppercase;
}
+.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";
+}
+
.activityRecord {
display: grid;
gap: 8px;
@@ -2541,9 +2976,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 {
@@ -2586,6 +3022,57 @@
background: var(--shell-panel);
}
+.cohortPanel {
+ display: grid;
+ gap: 10px;
+}
+
+.cohortPanel > h3 {
+ margin: 0;
+ font-family: var(--font-body), system-ui, sans-serif;
+ font-size: 16px;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+}
+
+.cohortPanel > p {
+ max-width: var(--measure-reading);
+ margin: 0;
+ color: var(--shell-muted);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.cohortFacts {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 1px;
+ margin: 0;
+ background: var(--shell-line);
+}
+
+.cohortFacts > div {
+ min-width: 0;
+ padding: 8px 10px;
+ background: var(--shell-bg);
+}
+
+.cohortFacts dt {
+ color: var(--shell-muted);
+ font-family: var(--font-mono-ui), monospace;
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+}
+
+.cohortFacts dd {
+ margin: 3px 0 0;
+ font-size: 12px;
+ line-height: 1.45;
+ overflow-wrap: anywhere;
+}
+
.makerCohort {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
@@ -2637,6 +3124,74 @@
overflow-wrap: anywhere;
}
+.makerVerdict {
+ margin: 0;
+ color: var(--shell-muted);
+ line-height: 1.45;
+}
+
+.makerVerdict strong {
+ color: var(--shell-text);
+}
+
+/* Key and capacity facts are evidence, not the decision: closed by default. */
+.makerFacts > summary {
+ list-style: none;
+ color: var(--shell-muted);
+ font-family: var(--font-mono-ui), monospace;
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ cursor: pointer;
+}
+
+.makerFacts > summary::-webkit-details-marker {
+ display: none;
+}
+
+.makerFacts > summary::before {
+ color: var(--shell-orange);
+ content: "+ ";
+}
+
+.makerFacts[open] > summary::before {
+ content: "− ";
+}
+
+.makerFacts > dl {
+ margin-top: 8px;
+}
+
+/* Outranks the form's generic label and span rules: this is a sentence the
+ user agrees to, not a field caption. */
+.privateIntentForm .cohortConsent {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ border: 1px solid var(--shell-line);
+ padding: 10px 12px;
+ cursor: pointer;
+}
+
+.privateIntentForm .cohortConsent > span {
+ color: var(--shell-text);
+ font-family: var(--font-reading), sans-serif;
+ font-size: 12px;
+ font-weight: 400;
+ letter-spacing: 0;
+ line-height: 1.45;
+ text-transform: none;
+}
+
+.cohortConsent input {
+ flex: none;
+ width: 16px;
+ height: 16px;
+ margin: 1px 0 0;
+ accent-color: var(--shell-orange);
+}
+
.bucketNotice,
.maturityEstimate,
.invoiceTerms,
@@ -2836,6 +3391,50 @@
gap: 10px;
}
+.finalReviewSummary dt {
+ color: var(--shell-muted);
+ font-family: var(--font-mono-ui), monospace;
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 0.09em;
+ line-height: 1.9;
+ text-transform: uppercase;
+}
+
+.finalReviewSummary dd {
+ max-width: var(--measure-reading);
+ margin: 0;
+ line-height: 1.5;
+}
+
+/* The privacy list is prose and was running the full width of the sheet. */
+.finalReview h4 {
+ margin: 6px 0 0;
+ font-family: var(--font-mono-ui), monospace;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+ color: var(--shell-muted);
+}
+
+.finalReview > ul {
+ display: grid;
+ gap: 6px;
+ max-width: var(--measure-reading);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.finalReview > ul li {
+ padding-left: 12px;
+ border-left: 1px solid var(--shell-line);
+ color: var(--shell-muted);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
.finalReviewWarning {
margin: 0;
color: var(--shell-orange);
@@ -2863,8 +3462,31 @@
.finalReviewAccept {
border: 1px solid var(--shell-orange);
+ color: var(--on-accent);
+ background: var(--shell-orange);
+ font: inherit;
+ font-size: 13px;
+ font-weight: 800;
+ cursor: pointer;
+}
+
+.finalReviewAccept:hover:not(:disabled) {
color: var(--shell-orange);
+ background: transparent;
+}
+
+.finalReviewDecline {
+ border: 1px solid var(--shell-line);
+ color: var(--shell-text);
+ background: transparent;
+ font: inherit;
+ font-size: 13px;
font-weight: 700;
+ cursor: pointer;
+}
+
+.finalReviewDecline:hover:not(:disabled) {
+ border-color: var(--shell-text);
}
.finalReviewAccept:disabled,
@@ -2873,9 +3495,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);
@@ -2883,6 +3530,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;
@@ -3247,8 +3910,9 @@
min-height: 40px;
flex: 1;
justify-content: center;
- padding: 0 6px;
- font-size: 8px;
+ padding: 0 4px;
+ font-size: 9px;
+ letter-spacing: 0.06em;
}
.viewHeading {
diff --git a/src/app/uni.module.css b/src/app/uni.module.css
index 5f35190..60418ca 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 {
@@ -535,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;
@@ -549,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;
@@ -585,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;
@@ -597,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;
@@ -606,7 +632,7 @@
.walletIcon {
width: 32px;
height: 32px;
- border-radius: 8px;
+ border-radius: 0;
flex: none;
}
.walletName {
diff --git a/src/components/mail/Compose.tsx b/src/components/mail/Compose.tsx
index bb33f33..7ca6280 100644
--- a/src/components/mail/Compose.tsx
+++ b/src/components/mail/Compose.tsx
@@ -1212,12 +1212,11 @@ export default function Compose({
}
rows={hasAnyAttachment ? 2 : 3}
required
+ hintClassName={styles.fieldHint}
hint={
<>
{recipientEntries.length} / {MAX_MULTI_RECIPIENTS} recipients.
- Recipient count is public; identities are absent from
- MessagePosted. Attachments are bilateral; body-only delivery can
- be multi-recipient.
+ Attachments are bilateral; a body-only letter can go to several.
>
}
/>
@@ -1267,8 +1266,8 @@ export default function Compose({
})}
- This is one document, not a mode picker. Add any combination; text
- and existing attachments are never cleared.
+ Everything you add goes into this one document. Adding an
+ attachment never clears your text or the others.
@@ -1477,21 +1476,25 @@ export default function Compose({
>
{sendButtonLabel}
- {
- 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/ConversationList.test.ts b/src/components/mail/ConversationList.test.ts
index c3786fc..27ec34d 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,72 @@ 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,
+ );
+ });
+});
+
+describe("naming a sealed sender", () => {
+ it("shows the name everywhere once a record carries an assignment", () => {
+ const message = compositeMessage("incoming");
+ message.envelope = decodeEnvelope(encodeEnvelope("text", { body: "hi" }));
+ message.assignedAddress = "0xb0b";
+ const named = conversationCorrespondent(message, [], "0xa11ce");
+ expect(named.primary).toBe("0xb0b");
+ expect(named.detail).toMatch(/not authenticated/i);
+ expect(named.fullAddress).toBe("0xb0b");
+
+ const labelled = conversationCorrespondent(
+ message,
+ [{ address: "0xb0b", label: "Bob", addedAt: 1 }],
+ "0xa11ce",
+ );
+ expect(labelled.primary).toBe("Bob");
+ });
+
+ it("stays sealed when nothing has been named", () => {
+ const message = compositeMessage("incoming");
+ message.envelope = decodeEnvelope(encodeEnvelope("text", { body: "hi" }));
+ expect(conversationCorrespondent(message, [], "0xa11ce").primary).toBe(
+ "Sealed sender",
+ );
+ });
+
+ it("makes a named counterparty searchable by name and address", () => {
+ const message = compositeMessage("incoming");
+ message.envelope = decodeEnvelope(encodeEnvelope("text", { body: "hi" }));
+ message.assignedAddress = "0xb0b";
+ const named = conversationCorrespondent(
+ message,
+ [{ address: "0xb0b", label: "Bob", addedAt: 1 }],
+ "0xa11ce",
+ );
+ expect(messageMatchesSearch(message, named, "bob")).toBe(true);
+ expect(messageMatchesSearch(message, named, "0xb0b")).toBe(true);
+ });
+});
diff --git a/src/components/mail/ConversationList.tsx b/src/components/mail/ConversationList.tsx
index 6b07ea5..b5a8ee2 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,11 @@ import {
parseEscrowFundPayload,
parseEscrowTimeoutPayload,
} from "@/lib/escrow";
-import { formatDeviceSentRecipients } from "@/lib/mail-correspondents";
+import {
+ conversationCorrespondent,
+ correspondentHeadline,
+ type ConversationCorrespondent,
+} from "./correspondent";
import {
mailMessageDateTime,
mailboxMatchesFilter,
@@ -30,8 +33,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,89 +97,15 @@ 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;
- }
- })();
+/* 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";
- 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 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 {
@@ -190,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":
@@ -297,9 +224,40 @@ type ConversationListProps = {
selfAddress: string;
folderLabel: string;
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;
};
+/**
+ * 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,
@@ -308,8 +266,20 @@ function ConversationList({
selfAddress,
folderLabel,
filterLabel,
+ search = "",
+ onSearchChange,
+ gate = null,
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 (
- {messages.length}
+ {visible.length}
-
- 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.”
-
-
+ {onSearchChange && messages.length ? (
+
+ onSearchChange(event.target.value)}
+ />
+
+ ) : null}
{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 ? (
- {messages.map((message) => {
+ {visible.map(({ message, correspondent }) => {
const selected = selectedMessageId === message.id;
- const correspondent = conversationCorrespondent(
- message,
- aliases,
- selfAddress,
- );
const unread =
message.direction !== "outgoing" &&
!readMessageIds.has(message.id);
@@ -358,7 +336,7 @@ function ConversationList({
- {correspondent.primary}
+ {correspondentHeadline(correspondent)}
{correspondent.detail ? (
{correspondent.detail}
@@ -369,19 +347,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"}
+
+ )}
@@ -391,20 +368,43 @@ function ConversationList({
) : (
- ✉
-
-
- {filterLabel === "All" || filterLabel === "All types"
- ? "Nothing here yet"
- : `No ${filterLabel.toLowerCase()} yet`}
-
-
- {folderLabel === "Sent"
- ? "Sent copies live only on this device. Write a message to create one."
- : folderLabel === "Inbox"
- ? "Check for new mail, or write a message."
- : "Choose another mailbox, or write a message."}
+ {search.trim() ? "⌕" : "✉"}
+ {search.trim() ? (
+ <>
+ No opened record matches
+
+ Search reads only what this device has already decrypted. Check
+ for new mail to widen it.
+
+ >
+ ) : 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."}
+
+ >
+ ) : (
+ <>
+
+ {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/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"}
+
+
{posted.label}
@@ -114,7 +119,6 @@ function DraftList({
}`
: "Letter"}
- DRAFT
No {filterLabel.toLowerCase()} drafts
- Click New to start a device-private document.
+ Compose to start a device-private document.
)}
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/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/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/Thread.tsx b/src/components/mail/Thread.tsx
index f5f8cf8..afcf2a2 100644
--- a/src/components/mail/Thread.tsx
+++ b/src/components/mail/Thread.tsx
@@ -43,16 +43,17 @@ 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,
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 = {
@@ -310,82 +311,82 @@ 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,
- onProve,
}: {
- message: LocalMailMessage;
+ messages: LocalMailMessage[];
selfAddress: string;
- proof?: SenderProof;
- conversationAddress?: string;
- onReply?: ThreadProps["onReply"];
+ responseAddress?: string;
+ canReply: boolean;
onAssign?: ThreadProps["onAssign"];
- onProve?: ThreadProps["onProve"];
}) {
- const fields = conversationFieldsFromPayload(
- message.envelope.type,
- message.envelope.type === "unsupported" ? null : message.envelope.payload,
- );
- const conversationId = conversationKeyForMessage(message);
- const claimed = replyAddressForMessage(message, selfAddress);
- const assigned = message.assignedAddress;
- const responseAddress = claimed ?? assigned ?? conversationAddress;
- const canContinue = Boolean(onReply);
+ const target = messages.find((message) => message.direction !== "outgoing");
+ const assigned = target?.assignedAddress;
const [assignInput, setAssignInput] = useState(assigned ?? "");
+ if (!target || !onAssign) return null;
return (
-
- {proof ?
{senderProofLabel(proof)}
: null}
- {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.
-
- )}
- {canContinue ? (
-
- onReply?.({
- address: responseAddress,
- conversationId,
- inReplyTo: fields.documentId ?? message.documentId ?? "",
- })
- }
- >
- Continue conversation
-
- ) : null}
- {canContinue && !responseAddress ? (
-
- Recipient cannot be prefilled because the sender is sealed and this
- conversation has no single device-local recipient or local sender
- assignment. Supply the counterparty address in the draft.
+
+ {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}
- {message.direction !== "outgoing" && onAssign ? (
+
+ Name this sender on this device
+
+
+ );
+}
+
+/** What belongs to one record: how it identifies itself, and its own claim. */
+function RecordControls({
+ message,
+ proof,
+ onProve,
+}: {
+ message: LocalMailMessage;
+ proof?: SenderProof;
+ onProve?: ThreadProps["onProve"];
+}) {
+ const fields = conversationFieldsFromPayload(
+ message.envelope.type,
+ message.envelope.type === "unsupported" ? null : message.envelope.payload,
+ );
+ const assigned = message.assignedAddress;
+ if (!proof && !fields.conversationId && !(assigned && onProve)) return null;
+ return (
+
+ {proof ?
{senderProofLabel(proof)}
: null}
+ {fields.conversationId ? (
+
Conversation tag {fields.conversationId.slice(0, 18)}…
) : null}
{assigned && onProve ? (
onProve(message.id, assigned)}>
@@ -426,6 +427,21 @@ export default function Thread({
const conversationAddress =
replyAddressForConversation(messages, selfAddress) ?? undefined;
+ /* The head names what is open. "Correspondence" was a constant, so the pane
+ never said which record you were reading; the rail's identity is reused
+ verbatim so both panes call the same counterparty the same thing. */
+ const head = messages.length
+ ? conversationCorrespondent(messages[0], aliases, selfAddress)
+ : null;
+ const headDetail = head
+ ? [
+ head.detail,
+ `${messages.length} record${messages.length === 1 ? "" : "s"}`,
+ ]
+ .filter(Boolean)
+ .join(" · ")
+ : null;
+
useEffect(() => {
if (!messages.length || focusVersion === 0) return;
const frame = requestAnimationFrame(() => headingRef.current?.focus());
@@ -440,8 +456,6 @@ export default function Thread({
if (envelope.type === "composite") {
const composite = parseCompositePayload(envelope.payload);
if (!composite) return ;
- const recipientCount =
- message.recipientCount ?? publicRecipientCount(message.record);
function renderAttachment(
attachment: CompositeAttachment,
attachmentIndex: number,
@@ -490,11 +504,6 @@ export default function Thread({
{composite.attachments.length === 1 ? "" : "s"}
-
- {recipientCount} recipient{recipientCount === 1 ? "" : "s"}; the
- count is public ciphertext metadata while identities are absent from
- MessagePosted.
-
{composite.body ? (
{composite.body}
) : (
@@ -508,23 +517,16 @@ export default function Thread({
}
if (envelope.type === "text") {
- const recipientCount =
- message.recipientCount ?? publicRecipientCount(message.record);
+ /* Recipient count and the sealed-identity caveat are not repeated here:
+ the message meta row above carries the count, and "What the chain sees"
+ below states the absence once, where the public record is shown. */
return (
{envelope.version === 0 ? "LEGACY LETTER" : "PRIVATE LETTER"}
-
- {recipientCount} recipient{recipientCount === 1 ? "" : "s"} ·
- count public
-
-
- Recipient identities are sealed and absent from MessagePosted. The
- count above is public ciphertext-format metadata.
-
{message.plaintext}
);
@@ -681,8 +683,8 @@ export default function Thread({
status={deal?.chainStatus}
termsVerified={Boolean(
deal?.chainDeal &&
- deal.chainDeal.status !== "empty" &&
- contractDealMatchesFund(deal.chainDeal, fund),
+ deal.chainDeal.status !== "empty" &&
+ contractDealMatchesFund(deal.chainDeal, fund),
)}
ownDeal={ownDeal}
busy={action?.pending}
@@ -768,19 +770,31 @@ export default function Thread({
}
return (
-
+
-
LOCAL PLAINTEXT / CARBON COPY
-
- Correspondence
+
+ {head ? correspondentHeadline(head) : "Correspondence"}
+ {headDetail ? (
+ {headDetail}
+ ) : null}
-
- CLIP / 01
-
+
+
{messages.length ? (
{messages.map((message) => {
@@ -827,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)}
@@ -853,15 +863,16 @@ export default function Thread({
) : 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}
);
})}
diff --git a/src/components/mail/correspondent.ts b/src/components/mail/correspondent.ts
new file mode 100644
index 0000000..3207791
--- /dev/null
+++ b/src/components/mail/correspondent.ts
@@ -0,0 +1,120 @@
+import { feltEquals } from "@/lib/addresses";
+import { findAliasByAddress, type AliasRecord } from "@/lib/aliases";
+import { parseCompositePayload } from "@/lib/composite";
+import { parseOfferPayload, parsePaymentRequestPayload } from "@/lib/otc";
+import { parseEscrowFundPayload } from "@/lib/escrow";
+import { formatDeviceSentRecipients } from "@/lib/mail-correspondents";
+import type { LocalMailMessage } from "./Thread";
+
+export type ConversationCorrespondent = {
+ primary: string;
+ detail?: string;
+ fullAddress?: string;
+};
+
+/**
+ * How a record names its counterparty. Shared by the list rail and the thread
+ * head so one message cannot be "Sealed sender" in one pane and something else
+ * in the other.
+ */
+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.direction === "outgoing") {
+ return {
+ primary: "Private recipient",
+ detail: "Device-local Sent copy · recipient was not stored",
+ };
+ }
+ /* A sealed record whose sender you named once on this device is shown by
+ that name everywhere, with the caveat attached: naming is a local label,
+ never authentication. Without a name it stays sealed, and one name covers
+ one fact — "Sealed sender" and "Sealed counterparty" differed only by
+ envelope type, which says nothing about who sent a record. */
+ if (message.assignedAddress) {
+ const alias = findAliasByAddress(aliases, message.assignedAddress)?.label;
+ return {
+ primary: alias ?? message.assignedAddress,
+ detail: "Named on this device · not authenticated",
+ fullAddress: message.assignedAddress,
+ };
+ }
+ return { primary: "Sealed sender" };
+}
+
+/**
+ * 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, shortenFelt(fullAddress));
+}
diff --git a/src/components/mail/mail.module.css b/src/components/mail/mail.module.css
index 2c1c456..1c8b345 100644
--- a/src/components/mail/mail.module.css
+++ b/src/components/mail/mail.module.css
@@ -182,6 +182,17 @@
line-height: 1.6;
}
+/* 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,
+.authWarning,
+.offerNote,
+.receiptWarning {
+ max-width: var(--measure-reading);
+}
+
.copy {
margin-top: 20px;
font-size: 14px;
@@ -326,8 +337,8 @@
.restoreWarning {
padding: 12px;
- border: var(--frame-deal);
- background: var(--shell-deal-quiet);
+ border: 1px solid var(--shell-danger);
+ background: color-mix(in srgb, var(--shell-danger) 12%, var(--shell-bg));
color: var(--shell-text);
font-size: 12px;
line-height: 1.5;
@@ -340,9 +351,9 @@
.warningButton {
margin-top: 10px;
padding: 10px 12px;
- border: 1px solid var(--shell-line);
+ border: 1px solid var(--shell-danger);
color: var(--shell-bg);
- background: var(--shell-deal, #c496ae);
+ background: var(--shell-danger);
font: inherit;
font-weight: 700;
cursor: pointer;
@@ -649,11 +660,30 @@
gap: 20px;
}
+/* The head names the counterparty, so it has to survive an address as a title. */
+.threadHeading h2 {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ font-size: 18px;
+ font-weight: 800;
+ letter-spacing: -0.03em;
+ line-height: 1.15;
+}
+
+.threadHeadDetail {
+ margin-top: 3px;
+ color: var(--shell-muted);
+ font-family: var(--font-mono-ui), monospace;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.07em;
+ line-height: 1.4;
+ text-transform: uppercase;
+}
+
.scanActions {
- display: flex;
- justify-content: flex-end;
- gap: 8px;
- flex-wrap: wrap;
+ display: grid;
+ gap: 5px;
}
.scanActions .secondaryButton {
@@ -704,8 +734,7 @@
}
.sheetType,
-.sheetMeta,
-.receiptWarning {
+.sheetMeta {
font-family: var(--font-mono-ui), monospace;
font-size: 11px;
font-weight: 700;
@@ -713,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);
@@ -804,10 +845,13 @@
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: var(--frame-deal);
- background: var(--shell-deal-quiet);
+ padding: 8px 12px;
+ border: 0;
+ border-left: var(--border-record-width) solid var(--shell-text);
+ background: var(--shell-panel);
}
.inlineStatus {
@@ -832,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);
@@ -1099,10 +1149,12 @@
line-height: 1.5;
}
+/* Where the plaintext lives is a fact about the record, not an action, so it
+ reads as data — accent stays with what the user is doing. */
.localLabel {
display: inline-block;
margin-top: 16px;
- color: var(--shell-orange);
+ color: var(--shell-muted);
font-family: var(--font-mono-ui), monospace;
font-size: 11px;
font-weight: 700;
@@ -1284,6 +1336,27 @@
text-transform: uppercase;
}
+.mobileModuleTitle {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 7px;
+ overflow: hidden;
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.mobileFilterChip {
+ flex: none;
+ padding: 2px 5px;
+ border: 1px solid var(--shell-line);
+ color: var(--shell-muted);
+ font-size: 9px;
+ font-style: normal;
+ letter-spacing: 0.06em;
+}
+
.sidebarCloseRow {
display: none;
}
@@ -1323,6 +1396,7 @@
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
+ padding-inline: 12px;
}
.composeButton,
@@ -1382,7 +1456,7 @@
}
.mailboxNav > .sidebarLabel {
- margin: 0 8px 5px;
+ margin: 0 12px 5px;
}
.mailboxNav button {
@@ -1390,7 +1464,7 @@
align-items: center;
justify-content: space-between;
width: 100%;
- padding: 6px 10px;
+ padding: 6px 12px;
border: 0;
color: var(--shell-text);
background: transparent;
@@ -1576,7 +1650,7 @@
}
.walletErrorText {
- color: var(--shell-deal, #c496ae);
+ color: var(--shell-danger);
}
.sidebarWalletActions {
@@ -1630,7 +1704,7 @@
margin-top: 9px;
padding: 8px;
border: 0;
- background: var(--shell-deal-quiet, #2e2129);
+ background: var(--shell-panel);
font-size: 10px;
line-height: 1.5;
}
@@ -1644,7 +1718,7 @@
}
.walletResultError {
- background: var(--shell-deal-quiet, #2e2129);
+ background: color-mix(in srgb, var(--shell-danger) 12%, var(--shell-bg));
}
.walletResult p,
@@ -1661,6 +1735,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;
@@ -1781,7 +1866,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);
@@ -1791,6 +1879,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);
}
@@ -1875,7 +1982,6 @@
.scanActions {
display: grid;
- grid-template-columns: 1fr auto;
gap: 5px;
margin-top: 10px;
}
@@ -1883,8 +1989,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;
@@ -1898,6 +2005,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;
@@ -1964,12 +2080,41 @@
width: 32px;
height: 32px;
place-items: center;
- border: 1px solid var(--shell-orange);
- color: var(--shell-orange);
+ border: 1px solid var(--shell-line);
+ color: var(--shell-muted);
font-size: 10px;
font-weight: 700;
}
+/* 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;
@@ -1984,7 +2129,7 @@
}
.railPrivacyNote strong {
- color: var(--shell-public, #8cafb7);
+ color: var(--shell-text);
font-family: var(--font-mono-ui), monospace;
font-size: 10px;
letter-spacing: 0.08em;
@@ -2027,8 +2172,7 @@
.conversationItemActive {
border-left-color: var(--shell-orange);
background: var(--shell-panel);
- box-shadow: inset -4px 0 0
- var(--shell-public-shadow, rgba(140, 175, 183, 0.14));
+ box-shadow: none;
}
.conversationTopline {
@@ -2050,8 +2194,10 @@
line-height: 1.25;
}
+/* Provenance, not deal terms: the mauve record class is reserved for
+ negotiated values, so a "Sealed letter" caption reads as plain secondary. */
.correspondentIdentity small {
- color: var(--shell-deal, #c496ae);
+ color: var(--shell-muted);
font-size: 10px;
font-weight: 700;
line-height: 1.3;
@@ -2080,7 +2226,8 @@
margin-top: auto;
}
-.typeBadge {
+.typeBadge,
+.provenanceBadge {
padding: 3px 5px;
border: 1px solid var(--shell-line);
color: var(--shell-muted);
@@ -2092,6 +2239,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;
@@ -2192,10 +2345,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;
@@ -2225,6 +2398,49 @@
color: var(--shell-text);
}
+/* A notice that names a missing wallet carries the wallet control itself. */
+.storageNotice {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-5);
+}
+
+.connectAction {
+ display: inline-flex;
+ flex: none;
+}
+
+/* SelectWallet ships the app's legacy rounded CTA; on the desk it has to be
+ the same square orange control as every other primary action here. */
+.connectAction > button {
+ width: auto;
+ margin: 0;
+ padding: 9px 12px;
+ border: 1px solid var(--shell-orange);
+ border-radius: 0;
+ color: var(--on-accent);
+ background: var(--shell-orange);
+ font: inherit;
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ cursor: pointer;
+}
+
+.connectAction > button:hover {
+ color: var(--shell-bg);
+ background: var(--shell-text);
+ border-color: var(--shell-text);
+ filter: none;
+}
+
+.welcomeConnect {
+ margin-top: 12px;
+}
+
.threadPanel,
.composerSheet,
.welcomeState {
@@ -2239,6 +2455,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 {
@@ -2251,16 +2475,6 @@
box-shadow: none;
}
-.recipientDisclosure {
- margin-top: 10px;
- padding: 8px 10px;
- border: var(--frame-public);
- color: var(--shell-muted);
- background: var(--shell-public-quiet);
- font-size: 11px;
- line-height: 1.5;
-}
-
.sheetClip {
color: var(--shell-orange);
font-family: var(--font-mono-ui), monospace;
@@ -2370,11 +2584,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);
@@ -2443,8 +2680,8 @@
top: 17px;
right: 18px;
padding: 5px 7px;
- border: 2px solid var(--shell-public, #8cafb7);
- color: var(--shell-public, #8cafb7);
+ border: 2px solid var(--shell-muted);
+ color: var(--shell-muted);
content: "LOCAL ONLY";
font-family: var(--font-mono-ui), monospace;
font-size: 10px;
@@ -2595,7 +2832,7 @@
}
/* Tablet: two panes remain visible; account/sidebar moves into a drawer. */
-@media (max-width: 1023px) {
+@media (max-width: 1179px) {
.page {
grid-template-rows: 62px minmax(0, 1fr);
}
@@ -2694,10 +2931,6 @@
grid-column: 2;
}
- .backToList {
- display: inline-flex;
- }
-
.readingScroll {
padding: 12px;
}
@@ -2709,6 +2942,16 @@
display: none;
}
+ /* The topbar names the folder on phones, so the rail heading would be the
+ same words again in a viewport that is already 40% chrome. */
+ .railHeading {
+ display: none;
+ }
+
+ .backToList {
+ display: inline-flex;
+ }
+
.mailWorkspace {
grid-template-columns: minmax(0, 1fr);
}
@@ -2812,41 +3055,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 {
@@ -2871,15 +3126,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;
}
@@ -2936,7 +3194,7 @@
.attachmentHeading button {
padding: 6px 9px;
- color: var(--shell-deal, #c496ae);
+ color: var(--shell-danger);
}
.composePreflight {
@@ -2975,12 +3233,12 @@
}
.composePreflightError {
- border-color: var(--shell-deal, #c496ae);
- background: var(--shell-deal-quiet, #2e2129);
+ border-color: var(--shell-danger);
+ background: color-mix(in srgb, var(--shell-danger) 12%, var(--shell-bg));
}
.composePreflightError .preflightIssue {
- color: var(--shell-deal, #c496ae);
+ color: var(--shell-danger);
font-weight: 700;
}
@@ -3003,13 +3261,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;
- color: var(--shell-deal, #c496ae);
+ 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);
@@ -3077,7 +3347,7 @@
right: 10px;
bottom: 7px;
border: 0;
- color: var(--shell-deal, #c496ae);
+ color: var(--shell-danger);
background: transparent;
font: inherit;
font-size: 10px;
@@ -3164,11 +3434,79 @@
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%;
+}
+
+/* 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);
+}
+
+.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 {
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.
+ }
+}
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(
diff --git a/tests/ui/app20.localnet.spec.ts b/tests/ui/app20.localnet.spec.ts
index 4e31a65..f5dd7ff 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",
@@ -596,9 +609,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);
});
@@ -613,9 +625,8 @@ test("all APP20 localnet journeys", async ({
await messageRow(page, compositeBody).click();
await expect(threadBody(page, compositeBody)).toBeVisible();
await expect(messageRow(page, compositeBody)).toContainText("OPENED");
- await expect(
- page.getByRole("heading", { name: "Correspondence" }),
- ).toBeFocused();
+ // The thread head now names the counterparty, so it is addressed by id.
+ await expect(page.locator("#thread-title")).toBeFocused();
await expect(
page.getByText("PRIVATE PAYMENT MEMO", { exact: true }),
).toBeVisible();
@@ -914,7 +925,7 @@ test("all APP20 localnet journeys", async ({
)
.fill(multiBody);
await expect(
- page.getByText(/2 \/ 66 recipients\. Recipient count is public/),
+ page.getByText(/2 \/ 66 recipients\./),
).toBeVisible();
await screenshot(page, "24-multi-recipient-compose", testInfo);
await page.getByRole("button", { name: "Send encrypted message" }).click();
diff --git a/tests/ui/private-intents.localnet.spec.ts b/tests/ui/private-intents.localnet.spec.ts
index 50f1b6c..1658ab6 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 });
@@ -158,7 +171,7 @@ async function prepareCohortReview(desk: Locator) {
);
await expect(preflight).toContainText("BRIEFED ONCE");
const prepare = desk.getByRole("button", {
- name: "Prepare size-blind cohort review",
+ name: "Review what makers will see",
});
await expect(prepare).toBeEnabled();
await prepare.click();
@@ -181,9 +194,7 @@ async function requestCollateralizedQuotes(page: Page, desk: Locator) {
candidate.url().endsWith("/private-intents/quotes"),
{ timeout: 180_000 },
);
- await desk
- .getByRole("button", { name: "Request collateralized quotes" })
- .click();
+ await desk.getByRole("button", { name: "Request quotes" }).click();
const outgoing = await posted;
const comparison = desk.getByRole("region", {
name: /Compare all makers/,
@@ -454,6 +465,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",
});
@@ -643,7 +655,7 @@ test("v3 keeps floors sealed, expires locks, and atomically settles single and s
await ensureMailboxKey(page, "bob");
await scanRecent(page);
const paymentMemoRow = page.getByRole("button", {
- name: /Settlement memo · 0\.1 USDC/,
+ name: /Memo\. 0\.1 USDC to settle/,
});
await expect(paymentMemoRow).toBeVisible({ timeout: 60_000 });
await paymentMemoRow.click();
@@ -976,7 +988,10 @@ 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 secondPage
+ .getByText("What this browser stores", { 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..1ae9a4c 100644
--- a/tests/ui/rfq-accessibility.spec.ts
+++ b/tests/ui/rfq-accessibility.spec.ts
@@ -97,12 +97,10 @@ async function prepareFinalReview(page: Page) {
"BRIEFED ONCE",
);
await desk
- .getByRole("button", { name: "Prepare size-blind cohort review" })
+ .getByRole("button", { name: "Review what makers will see" })
.click();
await desk.getByLabel("Maker cohort review").getByRole("checkbox").check();
- await desk
- .getByRole("button", { name: "Request collateralized quotes" })
- .click();
+ await desk.getByRole("button", { name: "Request quotes" }).click();
const comparison = desk.getByRole("region", { name: /Compare all makers/ });
await expect(comparison).toBeFocused({ timeout: 60_000 });
@@ -122,7 +120,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(),
),
@@ -233,6 +231,8 @@ test("RFQ privacy briefing is versioned once and remains reviewable", async ({
const preflight = page.getByLabel("Privacy preflight");
await expect(preflight).toContainText("BRIEFED ONCE");
+ // The freshness tip sits in the public market board, closed by default.
+ await page.getByText("Public market context", { exact: true }).click();
const freshnessHelp = page.getByRole("button", {
name: "About maker-data freshness",
});
@@ -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 ({
@@ -350,7 +345,7 @@ test("RFQ screen-reader shape, heading order, zoom reflow, and responsive hierar
await expect(
page.getByRole("heading", { name: "Private RFQ", level: 1 }),
).toHaveCount(1);
- for (const name of ["New", "Active", "Activity"]) {
+ for (const name of ["New", "Records"]) {
await expect(
page.getByRole("link", { name, exact: true }),
).toHaveAccessibleName(name);
@@ -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);
}
}
@@ -466,6 +456,8 @@ test("authority and copy controls expose unambiguous text rather than colour-onl
});
await page.reload();
await connectLocalnetWallet(page);
+ // A cancelled record is terminal: it lives under the All scope.
+ await page.getByRole("button", { name: /^All/ }).click();
const authority = page
.getByRole("article", { name: "STRK → USDC · Cancelled" })
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..68efdf1 100644
--- a/tests/ui/rfq-scope-invalidation.spec.ts
+++ b/tests/ui/rfq-scope-invalidation.spec.ts
@@ -21,11 +21,11 @@ async function prepareQuoteRequest(desk: Locator) {
"BRIEFED ONCE",
);
await desk
- .getByRole("button", { name: "Prepare size-blind cohort review" })
+ .getByRole("button", { name: "Review what makers will see" })
.click();
await desk.getByLabel("Maker cohort review").getByRole("checkbox").check();
await expect(
- desk.getByRole("button", { name: "Request collateralized quotes" }),
+ desk.getByRole("button", { name: "Request quotes" }),
).toBeEnabled();
}
@@ -102,9 +102,7 @@ test("an Alice quote request is discarded after switching to Bob", async ({
const release = await delayQuoteResponse(page);
const quoteRequest = page.waitForRequest("**/private-intents/quotes");
try {
- await desk
- .getByRole("button", { name: "Request collateralized quotes" })
- .click();
+ await desk.getByRole("button", { name: "Request quotes" }).click();
await quoteRequest;
await page.locator('[data-localnet-identity="bob"]').click();
@@ -119,8 +117,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);
@@ -137,9 +136,7 @@ test("a LOCAL quote request is discarded after disconnecting and selecting Sepol
const release = await delayQuoteResponse(page);
const quoteRequest = page.waitForRequest("**/private-intents/quotes");
try {
- await desk
- .getByRole("button", { name: "Request collateralized quotes" })
- .click();
+ await desk.getByRole("button", { name: "Request quotes" }).click();
await quoteRequest;
await page.getByRole("button", { name: "Disconnect wallet" }).click();
diff --git a/tests/ui/swap-routes.localnet.spec.ts b/tests/ui/swap-routes.localnet.spec.ts
index 775a42c..83289f1 100644
--- a/tests/ui/swap-routes.localnet.spec.ts
+++ b/tests/ui/swap-routes.localnet.spec.ts
@@ -27,7 +27,7 @@ async function gotoRoute(page: Page, route: string) {
throw lastError;
}
-test("keeps RFQ canonical, public context opt-in, swap non-executable, and market planning proposal-only", async ({
+test("keeps RFQ canonical, public context opt-in, and market planning proposal-only", async ({
page,
}) => {
let coinGeckoRequests = 0;
@@ -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();
@@ -60,40 +62,6 @@ test("keeps RFQ canonical, public context opt-in, swap non-executable, and marke
await expect.poll(() => coinGeckoRequests).toBe(1);
await expect(page.getByText("0.50000", { exact: true })).toBeVisible();
- await gotoRoute(page, "/swap/strk/usdc");
- await expect(
- page.getByRole("heading", { name: "STRK / USDC" }),
- ).toBeVisible();
- await expect(
- page.getByText("PAIR HANDOFF · NO EXECUTION", { exact: true }),
- ).toBeVisible();
- await expect(
- page.getByRole("button", { name: /request|accept|execute/i }),
- ).toHaveCount(0);
- await page.getByRole("link", { name: "Open RFQ" }).click();
- await expect(page).toHaveURL(/\/rfq\?pair=STRK_USDC#new$/);
- await expect(page.getByLabel("Private intent market")).toHaveValue(
- "STRK_USDC",
- );
-
- await gotoRoute(page, "/swap/usdc/strk");
- await expect(
- page.getByRole("heading", { name: "USDC / STRK" }),
- ).toBeVisible();
- await page.getByRole("link", { name: "Open RFQ" }).click();
- await expect(page).toHaveURL(/\/rfq\?pair=USDC_STRK#new$/);
- await expect(page.getByLabel("Private intent market")).toHaveValue(
- "USDC_STRK",
- );
-
- await gotoRoute(page, "/swap/eth/usdc");
- await expect(
- page.getByRole("heading", { name: "Pair unavailable" }),
- ).toBeVisible();
- await expect(
- page.getByText("No RFQ, quote, proposal, or transaction was created."),
- ).toBeVisible();
-
await gotoRoute(page, "/pools/create/eth/usdc#review");
await expect(page).toHaveURL(/\/rfq\/markets\/eth\/usdc\/proposal#review$/);
await expect(