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 (
Counterparties - -
- + Pay + +
diff --git a/src/app/components/session.module.css b/src/app/components/session.module.css index 1c0204a..19d88f9 100644 --- a/src/app/components/session.module.css +++ b/src/app/components/session.module.css @@ -86,9 +86,13 @@ opacity: 0.6; } +/* Which network is current is state, and the header already has one way of + marking current: the tab's inset accent rule. A solid accent block here also + sat flush against the connect button and read as one control. */ .networkToggle .networkActive { - color: var(--shell-bg); - background: var(--shell-orange); + color: var(--shell-text); + background: var(--shell-inset); + box-shadow: inset 0 -2px var(--shell-orange); } .networkToggle span[role="status"] { diff --git a/src/app/contacts/page.tsx b/src/app/contacts/page.tsx index 873d0c6..2413107 100644 --- a/src/app/contacts/page.tsx +++ b/src/app/contacts/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { Link } from "@tanstack/react-router"; +import SelectWallet from "@/app/components/client/WalletHandle/SelectWallet"; import { useActiveStarknetSession } from "@/app/active-session"; import { storeDeskHandoff } from "@/lib/desk-handoff"; import { MAIL_RECOVERY_PHRASE_AUTHORITY_NOTICE } from "@/lib/mail-authority-copy"; @@ -243,8 +244,12 @@ function AddressBookPanel({
Wallet required

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

+ + +
)} diff --git a/src/app/css-module-classes.test.ts b/src/app/css-module-classes.test.ts new file mode 100644 index 0000000..9e600ba --- /dev/null +++ b/src/app/css-module-classes.test.ts @@ -0,0 +1,53 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * A CSS-module class that does not exist resolves to `undefined`: the element + * renders with no styling at all and the word "undefined" in its class list, + * with nothing to notice at build time. Two such references had left a page + * masthead and a form field unstyled before this guard existed. + */ +function componentFiles(): string[] { + return execFileSync("git", ["ls-files", "src/**/*.tsx"], { + encoding: "utf8", + }) + .split("\n") + .filter(Boolean); +} + +function definedClasses(cssPath: string): Set { + const text = readFileSync(cssPath, "utf8"); + const names = new Set(); + for (const match of text.matchAll(/\.([A-Za-z_][\w-]*)/g)) names.add(match[1]); + return names; +} + +function resolveImport(fromFile: string, specifier: string): string { + if (specifier.startsWith("@/")) return resolve("src", specifier.slice(2)); + return resolve(dirname(fromFile), specifier); +} + +describe("CSS module references", () => { + it("only uses classes its stylesheet defines", () => { + const undefinedReferences: string[] = []; + for (const file of componentFiles()) { + const source = readFileSync(file, "utf8"); + const importMatch = source.match( + /import\s+styles\s+from\s+"([^"]+\.css)"/, + ); + if (!importMatch) continue; + const defined = definedClasses(resolveImport(file, importMatch[1])); + const used = new Set( + [...source.matchAll(/styles\.([A-Za-z_][\w]*)/g)].map( + (match) => match[1], + ), + ); + for (const name of used) { + if (!defined.has(name)) undefinedReferences.push(`${file}: ${name}`); + } + } + expect(undefinedReferences).toEqual([]); + }); +}); diff --git a/src/app/funding/FundingReadinessPanel.tsx b/src/app/funding/FundingReadinessPanel.tsx index c976b6f..e3d9da0 100644 --- a/src/app/funding/FundingReadinessPanel.tsx +++ b/src/app/funding/FundingReadinessPanel.tsx @@ -242,7 +242,7 @@ export function FundingReadinessPanelView({ style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", - gap: 12, + gap: 1, }} >
diff --git a/src/app/globals.css b/src/app/globals.css index 75d1a65..c9e1733 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -420,8 +420,7 @@ a { align-items: center; gap: 6px; } -.app-tabs a, -.request-link { +.app-tabs a { display: inline-flex; min-height: 44px; align-items: center; @@ -434,24 +433,18 @@ a { letter-spacing: 0.08em; text-transform: uppercase; } -.app-tabs a[aria-current="page"], -.request-link[aria-current="page"] { +.app-tabs a[aria-current="page"] { border-color: var(--shell-line); color: var(--shell-text); box-shadow: inset 0 -2px var(--shell-orange); } -.app-tabs a:hover, -.request-link:hover { +.app-tabs a:hover { color: var(--shell-text); border-color: var(--shell-orange); } .app-utilities { justify-content: flex-end; } -.request-link { - border-color: var(--shell-orange); - color: var(--shell-text); -} .network-status, .wallet-status { display: inline-flex; @@ -483,6 +476,65 @@ a { overflow-wrap: anywhere; } +/* The funding gate was the one panel still outside the desk's language: a + plain paragraph for its eyebrow, body-sans labels on its facts, and every + sentence running the full width of the window. */ +.funding-readiness-panel > p:first-child { + color: var(--shell-orange); + font-family: var(--font-mono-ui), monospace; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.funding-readiness-panel h2 { + margin: 6px 0 0; + font-size: 20px; + font-weight: 800; + letter-spacing: -0.03em; +} + +.funding-readiness-panel h3 { + margin: 18px 0 6px; + font-family: var(--font-mono-ui), monospace; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--shell-muted); +} + +.funding-readiness-panel p, +.funding-readiness-panel li { + max-width: var(--measure-reading); + line-height: 1.55; +} + +.funding-readiness-panel dl { + border: 1px solid var(--shell-line); + background: var(--shell-line); +} + +.funding-readiness-panel dl > div { + padding: 9px 11px; + background: var(--shell-bg); +} + +.funding-readiness-panel dt { + color: var(--shell-muted); + font-family: var(--font-mono-ui), monospace; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.funding-readiness-panel dd { + margin: 4px 0 0; + font-size: 13px; +} + .panel-heading { display: grid; gap: 5px; @@ -849,9 +901,6 @@ a { font-size: clamp(8px, 2.25vw, 9px); letter-spacing: 0.03em; } - .app-utilities .request-link { - padding: 0 5px; - } /* Wrap rather than clip: the Disconnect action stays reachable at 320px. */ .app-utilities { min-width: 0; diff --git a/src/app/inbox/page.tsx b/src/app/inbox/page.tsx index 20b0c0b..ca76762 100644 --- a/src/app/inbox/page.tsx +++ b/src/app/inbox/page.tsx @@ -5,6 +5,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { hash, validateAndParseAddress } from "starknet"; import { useFrontendProvider } from "@/app/components/client/provider/providerContext"; import { useStoreWallet } from "@/app/components/Wallet/walletContext"; +import { loadReadMessageIds, saveReadMessageIds } from "@/lib/mail-read-state"; +import SelectWallet from "@/app/components/client/WalletHandle/SelectWallet"; import Compose, { type SentEnvelope } from "@/components/mail/Compose"; import ConversationList from "@/components/mail/ConversationList"; import DraftList from "@/components/mail/DraftList"; @@ -107,7 +109,10 @@ import { type ParsedMailEvent, } from "@/lib/mail-scan"; import { verifyContactSnapshot } from "@/lib/contact-backup"; -import { describeMailScanCursor } from "@/lib/mail-correspondents"; +import { + describeMailScanCursor, + replyAddressForConversation, +} from "@/lib/mail-correspondents"; import { authorizeStrk20ValueAction } from "@/lib/mainnet-safety"; import { clearLocalMailboxStorage } from "@/lib/local-mailbox-storage"; import { @@ -178,7 +183,10 @@ import { saveMailAssignment, type MailAssignment, } from "@/lib/mail-assignments"; -import { assembleConversation } from "@/lib/mail-thread"; +import { + assembleConversation, + conversationKeyForMessage, +} from "@/lib/mail-thread"; import { evaluateSenderProof, type SenderProof } from "@/lib/sender-proof"; import { assertWalletOperationPolicy } from "@/lib/wallet-policy"; import * as constants from "@/utils/constants"; @@ -285,6 +293,19 @@ export default function InboxPage() { const [readMessageIds, setReadMessageIds] = useState>( () => new Set(), ); + const [mailboxSearch, setMailboxSearch] = useState(""); + + /* Read state belongs to this device and this account: it is loaded when the + account is known and written back as records are opened, so a reload no + longer marks a whole mailbox unread. */ + useEffect(() => { + setReadMessageIds( + chainId && address + ? loadReadMessageIds(window.localStorage, chainId, address) + : new Set(), + ); + }, [address, chainId]); + const [mailFolder, setMailFolder] = useState("inbox"); const [mailboxFilter, setMailboxFilter] = useState("all"); const [drafts, setDrafts] = useState([]); @@ -298,6 +319,9 @@ export default function InboxPage() { const [storageNotice, setStorageNotice] = useState<{ kind: "ok" | "error"; message: string; + /* A gate that names a missing wallet has to offer the wallet, or the + notice is a dead end: the only connect control lives in the app header. */ + action?: "connect-wallet"; } | null>(null); const [assignments, setAssignments] = useState< Record @@ -396,7 +420,9 @@ export default function InboxPage() { }, [address, chainId]); useEffect(() => { - const query = window.matchMedia("(max-width: 900px)"); + /* Matches the breakpoint where mail.module.css turns the sidebar into a + drawer; if these drift, a closed drawer stays focusable off-screen. */ + const query = window.matchMedia("(max-width: 1179px)"); const update = () => setMobileSidebarMode(query.matches); update(); query.addEventListener("change", update); @@ -532,7 +558,6 @@ export default function InboxPage() { setScanProgress({ pages: 0, events: 0, maxPages: MAIL_SCAN_MAX_PAGES }); setSelectedMessageId(null); activatedMessageIdRef.current = null; - setReadMessageIds(new Set()); setComposerOpen(false); setMobileDetailOpen(false); setSidebarOpen(false); @@ -733,11 +758,35 @@ export default function InboxPage() { mailboxFilter === "all" ? drafts : drafts.filter((draft) => draftMatchesFilter(draft, mailboxFilter)); - const allAnnotatedMessages = visibleMessages.map((message) => ({ - ...message, - assignedAddress: assignments[message.id]?.address, - localConversationId: assignments[message.id]?.conversationId, - })); + /* Naming a counterparty is about a conversation, not one envelope: an + assignment made on any record carries to every record that shares its + conversation, including ones decrypted later. */ + const assignedByConversation = new Map(); + for (const message of visibleMessages) { + const assigned = assignments[message.id]?.address; + if (!assigned) continue; + const conversation = conversationKeyForMessage({ + ...message, + localConversationId: assignments[message.id]?.conversationId, + }); + if (!assignedByConversation.has(conversation)) { + assignedByConversation.set(conversation, assigned); + } + } + const allAnnotatedMessages = visibleMessages.map((message) => { + const localConversationId = assignments[message.id]?.conversationId; + const conversation = conversationKeyForMessage({ + ...message, + localConversationId, + }); + return { + ...message, + assignedAddress: + assignments[message.id]?.address ?? + assignedByConversation.get(conversation), + localConversationId, + }; + }); const visibleIds = new Set(filteredMessages.map((message) => message.id)); const annotatedMessages = allAnnotatedMessages.filter((message) => visibleIds.has(message.id), @@ -811,11 +860,21 @@ export default function InboxPage() { if (current.has(messageId)) return current; const next = new Set(current); next.add(messageId); + if (chainId && address) { + saveReadMessageIds(window.localStorage, chainId, address, next); + } return next; }); }); return () => window.cancelAnimationFrame(frame); - }, [composerOpen, messageActivation, mobileDetailOpen, selectedMessageId]); + }, [ + address, + chainId, + composerOpen, + messageActivation, + mobileDetailOpen, + selectedMessageId, + ]); function setActionState(key: string, state: ThreadActionState) { setActionStates((current) => ({ ...current, [key]: state })); @@ -2930,7 +2989,8 @@ export default function InboxPage() { setStorageNotice({ kind: "error", message: - "Connect a wallet before creating a draft so it is saved under the correct mailbox.", + "Mail is keyed to a wallet: connect one so the draft is saved under the correct mailbox.", + action: "connect-wallet", }); setSidebarOpen(true); return; @@ -2957,7 +3017,8 @@ export default function InboxPage() { setStorageNotice({ kind: "error", message: - "Connect a wallet before creating a draft so it is saved under the correct mailbox.", + "Mail is keyed to a wallet: connect one so the draft is saved under the correct mailbox.", + action: "connect-wallet", }); setSidebarOpen(true); return; @@ -2988,7 +3049,7 @@ export default function InboxPage() { setStorageNotice({ kind: "ok", message: - "Assigned on this device only. That is a local label, not a proof.", + "Named on this device. Every record in this conversation now shows that name, which is a local label and not a proof.", }); } catch (error: unknown) { setStorageNotice({ @@ -3021,6 +3082,23 @@ export default function InboxPage() { setStorageNotice({ kind: "error", message: proofMessage }); } + const walletGateShown = storageNotice?.action === "connect-wallet"; + /* What is actually blocking this mailbox, answered once. The welcome sheet, + the rail's empty state and the scan panel used to give three different + instructions for the same state. */ + const mailboxGate: "wallet" | "key" | null = + !address || !chainId ? "wallet" : keypair ? null : "key"; + + function replyToOpenConversation() { + if (!selectedMessage) return; + openComposerForRecipient({ + address: + replyAddressForConversation(conversationMessages, address) ?? undefined, + conversationId: conversationKeyForMessage(selectedMessage), + inReplyTo: selectedMessage.documentId ?? "", + }); + } + function closeDetail() { if (composerOpen && activeDraft && isBlankDraft(activeDraft)) { removeDraft(activeDraft.id, false); @@ -3030,6 +3108,7 @@ export default function InboxPage() { } function selectFolder(nextFolder: MailFolder) { + setMailboxSearch(""); setMailFolder(nextFolder); setSidebarOpen(false); setMobileDetailOpen(false); @@ -3043,6 +3122,7 @@ export default function InboxPage() { } function selectTypeFilter(nextFilter: MailboxFilter) { + setMailboxSearch(""); setMailboxFilter(nextFilter); setSidebarOpen(false); setMobileDetailOpen(false); @@ -3062,7 +3142,15 @@ export default function InboxPage() { > ☰ - APP20 Mail + {/* The app header's current tab already says MAILBOX. This row carries + what the pane is actually showing, so the phone layout does not + spend one of its few rows repeating the module name. */} + + {composerOpen ? "New document" : folderLabel} + {!composerOpen && mailboxFilter !== "all" ? ( + {filterLabel} + ) : null} +
@@ -3362,12 +3455,20 @@ export default function InboxPage() { > Close + ) : selectedMessage ? ( + ) : null}
{storageNotice ? ( -

- {storageNotice.message} -

+ {storageNotice.message} + {storageNotice.action === "connect-wallet" ? ( + + + + ) : null} +
) : null} {!keypair && selectedMessage && !composerOpen ? (

- {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."}

- + {/* The CTA has to be the step that is actually available: + offering compose to a disconnected desk only produced a + notice pointing at a control in the app header. The gate + notice carries the same control, so only one of the two + asks for the wallet at a time. */} + {walletGateShown ? null : !address || !chainId ? ( +
+ +
+ ) : ( + + )} )} diff --git a/src/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

-
- - {PUBLIC_PRICE_RANGES.map((item) => ( +
+ {publicContextEnabled ? ( - ))} + ) : null} +
+ {PUBLIC_PRICE_RANGES.map((item) => ( + + ))} +
@@ -529,10 +532,19 @@ export default function DeskMarketBoard({ pairId }: DeskMarketBoardProps) { : "Loading public candlesticks…"} {priceState.kind === "idle" ? ( - - Opting in contacts CoinGecko directly and discloses your IP - address, user agent, request timing, and selected range. - + <> +

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

+ + ) : null} {priceState.kind === "error" ? ( <> diff --git a/src/app/rfq/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({
-