From 2fc34d3cbdcf0e4cac561590bd420dbef153a956 Mon Sep 17 00:00:00 2001 From: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Date: Tue, 29 Sep 2026 09:55:17 -0600 Subject: [PATCH 1/9] Improve identity rows with full npub previews and copy actions Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- docs/input-correction-audit.md | 48 ++++++ .../ChannelTemplatesDialog.tsx | 5 + .../channel-templates/TemplateFields.tsx | 61 ++++---- .../channel-templates/TemplateSettings.tsx | 2 + .../agent-selection.test.tsx | 26 ++-- .../channels/ChannelMembersDialog.test.tsx | 13 +- src/bundled/channels/ChannelMembersDialog.tsx | 84 +++++------ src/bundled/profiles/InstanceProfilePanel.tsx | 2 +- src/bundled/profiles/ProfileAgentIdentity.tsx | 141 +----------------- src/bundled/profiles/ProfilePanel.tsx | 6 +- .../profiles/AgentOwnerPreview.test.tsx | 69 +++++++++ src/features/profiles/AgentOwnerPreview.tsx | 70 +++++++++ .../profiles/useAgentOwnerEvidence.ts | 136 +++++++++++++++++ src/shared/design-system/DESIGN.md | 12 ++ .../design-system/styles/components.css | 10 +- .../design-system/ui/ChoiceControls.test.tsx | 26 ++++ src/shared/design-system/ui/Combobox.tsx | 3 + src/shared/design-system/ui/PreviewCard.tsx | 59 ++++++-- src/shared/design-system/ui/SearchField.tsx | 3 + src/shared/design-system/ui/Textarea.tsx | 9 +- src/shared/design-system/ui/controls.test.tsx | 48 ++++++ src/shared/identity/IdentityRow.test.tsx | 77 ++++++++++ src/shared/identity/IdentityRow.tsx | 113 ++++++++++++++ .../browser/channel-members-focus.fixture.tsx | 28 +++- tests/browser/channel-members-focus.spec.mjs | 126 +++++++++++++++- 25 files changed, 930 insertions(+), 247 deletions(-) create mode 100644 docs/input-correction-audit.md create mode 100644 src/features/profiles/AgentOwnerPreview.test.tsx create mode 100644 src/features/profiles/AgentOwnerPreview.tsx create mode 100644 src/features/profiles/useAgentOwnerEvidence.ts create mode 100644 src/shared/identity/IdentityRow.test.tsx create mode 100644 src/shared/identity/IdentityRow.tsx diff --git a/docs/input-correction-audit.md b/docs/input-correction-audit.md new file mode 100644 index 000000000..4f0ed9b94 --- /dev/null +++ b/docs/input-correction-audit.md @@ -0,0 +1,48 @@ +# Exact-text input correction audit + +Search queries, public keys, code, and machine identifiers should not be silently +rewritten by an OS keyboard. Use `autoCorrect="off"`, `autoCapitalize="none"`, and +`spellCheck={false}` for those fields. Spellcheck alone does not disable correction. +Keep ordinary writing fields unchanged; a global `Input`/`Textarea` opt-out would +also degrade messages, descriptions, profile bios, feedback, and instructions. + +## Included in the identity-preview change + +- `SearchField` and `Combobox.Control` default to exact-text entry, with explicit + caller overrides still supported. +- `Textarea variant="code"` applies the same defaults; its prose variant does not. +- `channel-templates/TemplateFields.tsx` disables correction for agent name/npub search. +- Member search inherits the shared `SearchField` defaults. + +## Follow-up candidates, not repaired by this change + +Audited the following `src/` inputs on 2026-09-29. These need field-level decisions, +not a blanket default on every text field. Some already disable spellcheck or +capitalization, but do not explicitly disable autocorrect. + +| Source | Exact-text fields | +| --- | --- | +| `app/PluginImport.tsx` | Repository URL, branch/tag | +| `app/AgentDefaultsCard.tsx` | Provider/model/effort, environment keys and values | +| `features/identity/IdentitySetup.tsx` | Secret-key import | +| `features/communities/CommunityDialog.tsx` | Relay URL and invite code | +| `features/browser/BrowserHostView.tsx` | Address bar | +| `features/profiles/AvatarEditor.tsx` | Picture URL and emoji | +| `bundled/hosted-communities/HostedCommunities.tsx` | Domain slug, transfer recipient npub | +| `bundled/agents/AgentSettingsFields.tsx` | API key including revealed state, workspace path, arguments JSON | +| `bundled/agents/AgentHarnessEditor.tsx` | Harness argument text | +| `bundled/agents/AgentEnvironmentEditor.tsx` | Environment keys and values | +| `bundled/agents/AgentModelPicker.tsx` | Model ID, host, model filter | +| `bundled/agents/AgentImport.tsx` | Import destination path | +| `bundled/channels/CreateChannelDialog.tsx` | Channel name | +| `bundled/channels/ChannelLifecycleDialog.tsx` | Exact channel-name confirmation | +| `bundled/workflows/WorkflowEditor.tsx` | Workflow identifiers and YAML | +| `bundled/workflows/WorkflowConditions.tsx` | Expressions and condition values | +| `bundled/workflows/WorkflowForm.tsx` | Emoji, durations, expressions, step/channel identifiers | +| `bundled/workflows/WorkflowWebhookFields.tsx` | URL, headers, body | + +Use the code textarea variant where it already fits rather than duplicating its +defaults. For mixed inputs such as environment values and webhook bodies, preserve +exact bytes even when the value happens to resemble prose. Browser DOM tests prove +attributes and override behavior, not native keyboard behavior: confirm correction +and capitalization on macOS/iOS before claiming platform-level coverage. diff --git a/src/bundled/channel-templates/ChannelTemplatesDialog.tsx b/src/bundled/channel-templates/ChannelTemplatesDialog.tsx index d396f19bc..9937c09d9 100644 --- a/src/bundled/channel-templates/ChannelTemplatesDialog.tsx +++ b/src/bundled/channel-templates/ChannelTemplatesDialog.tsx @@ -1,3 +1,4 @@ +import type { RelaySession } from "../../features/relay/session"; import { useEffect, useLayoutEffect, @@ -22,6 +23,7 @@ import { AgentSelection, TemplateFields } from "./TemplateFields"; import styles from "../channels/ChannelTemplates.module.css"; export function ChannelTemplatesDialog({ + session, open, onOpenChange, kit, @@ -30,6 +32,7 @@ export function ChannelTemplatesDialog({ notice, active, }: { + session?: RelaySession | undefined; active(): boolean; open: boolean; onOpenChange(open: boolean): void; @@ -150,6 +153,7 @@ export function ChannelTemplatesDialog({ {draft.type === "team" ? ( setDraft({ ...draft, agents })} @@ -166,6 +170,7 @@ export function ChannelTemplatesDialog({ /> setDraft({ ...draft, ...value })} entries={state.entries} diff --git a/src/bundled/channel-templates/TemplateFields.tsx b/src/bundled/channel-templates/TemplateFields.tsx index cf1347a02..146622a45 100644 --- a/src/bundled/channel-templates/TemplateFields.tsx +++ b/src/bundled/channel-templates/TemplateFields.tsx @@ -1,7 +1,8 @@ +import type { RelaySession } from "../../features/relay/session"; +import { AgentOwnerPreview } from "../../features/profiles/AgentOwnerPreview"; import { npubEncode } from "nostr-tools/nip19"; import { publicKeyLabels } from "../../shared/identity/public-key"; -import { Avatar } from "../../shared/design-system/ui/Avatar"; -import { ChoiceRow } from "../../shared/design-system/ui/ChoiceRow"; +import { IdentityRow } from "../../shared/identity/IdentityRow"; import { useState } from "react"; import { Checkbox } from "../../shared/design-system/ui/Checkbox"; import { Field } from "../../shared/design-system/ui/Field"; @@ -16,10 +17,12 @@ import { import styles from "../channels/ChannelTemplates.module.css"; export function AgentSelection({ + session, selected, agents, onChange, }: { + session?: RelaySession | undefined; selected: readonly string[]; agents: readonly AgentChoice[]; onChange(keys: string[]): void; @@ -36,6 +39,9 @@ export function AgentSelection({
setSearch(e.target.value)} placeholder="Name or npub" @@ -49,35 +55,31 @@ export function AgentSelection({ .includes(search.toLowerCase()), ) .map((agent) => ( - - } - label={agent.name} - description={ - - {labels.get(agent.pubkey)} - + pubkey={agent.pubkey} + name={agent.name} + picture={agent.avatar} + isAgent + previewDetail={ + session ? ( + + ) : undefined + } + keyLabel={labels.get(agent.pubkey)} + render={(content) => ( + + onChange( + checked + ? [...selected, agent.pubkey] + : selected.filter((key) => key !== agent.pubkey), + ) } /> - } - checked={selected.includes(agent.pubkey)} - onCheckedChange={(checked) => - onChange( - checked - ? [...selected, agent.pubkey] - : selected.filter((key) => key !== agent.pubkey), - ) - } + )} /> ))} {!choices.length && ( @@ -91,12 +93,14 @@ export function AgentSelection({ } export function TemplateFields({ + session, value, onChange, entries, agents, acceptedAgents, }: { + session?: RelaySession | undefined; value: Lineup; onChange(value: Lineup): void; acceptedAgents?: readonly string[] | undefined; @@ -162,6 +166,7 @@ export function TemplateFields({ )} onChange({ ...value, agents })} diff --git a/src/bundled/channel-templates/TemplateSettings.tsx b/src/bundled/channel-templates/TemplateSettings.tsx index ddbcff9f1..2379fcfb3 100644 --- a/src/bundled/channel-templates/TemplateSettings.tsx +++ b/src/bundled/channel-templates/TemplateSettings.tsx @@ -70,6 +70,7 @@ function Library({ )} {open && ( {error}

} {draft && ( { if (!open) setDraft(undefined); diff --git a/src/bundled/channel-templates/agent-selection.test.tsx b/src/bundled/channel-templates/agent-selection.test.tsx index 7399b8b1d..c1934ad55 100644 --- a/src/bundled/channel-templates/agent-selection.test.tsx +++ b/src/bundled/channel-templates/agent-selection.test.tsx @@ -7,7 +7,7 @@ import type { SidebarPreferences } from "../../features/relay/sidebar-preference import { act, cleanup, - render, + render as rtlRender, screen, waitFor, within, @@ -49,6 +49,9 @@ import { MentionPicker } from "../mentions/MentionPicker"; import { MentionCompletion } from "../mentions/MentionCompletion"; import type { CompletionResult } from "../../features/conversation/contracts"; +const render = (ui: Parameters[0]) => + rtlRender(ui, { wrapper: ToastProvider }); + beforeEach(() => { localStorage.clear(); vi.stubGlobal( @@ -515,7 +518,7 @@ it("copies a complete managed lineup without an unused legacy warning", async () ); expect( await screen.findByRole("checkbox", { - name: `Calvin ${formatPublicKey(test.fixture.agent.pubkey)}`, + name: `Calvin Agent · ${formatPublicKey(test.fixture.agent.pubkey)}`, }), ).toBeChecked(); expect( @@ -693,7 +696,7 @@ it("copies the saved Canvas and eligible member keys without silently creating a ).toHaveValue("# Saved plan"); expect( screen.getByRole("checkbox", { - name: `Calvin ${formatPublicKey(test.fixture.agent.pubkey)}`, + name: `Calvin Agent · ${formatPublicKey(test.fixture.agent.pubkey)}`, }), ).toBeChecked(); expect( @@ -919,18 +922,23 @@ it("shows an avatar, searchable npub and removable unavailable keys without rend />, ); const row = screen - .getByRole("checkbox", { name: `Calvin ${formatPublicKey(key)}` }) + .getByRole("checkbox", { name: `Calvin Agent · ${formatPublicKey(key)}` }) .closest("label"); assert.exists(row); expect(row.querySelector("img")).toHaveAttribute("src", avatar); - expect(screen.getByTitle(npubEncode(key))).toHaveTextContent( - formatPublicKey(key) ?? "", - ); + expect(row).toHaveTextContent(formatPublicKey(key) ?? ""); + const user = userEvent.setup(); + await user.hover(row); + const preview = await screen.findByRole("dialog", { + name: "Calvin identity", + }); + expect(preview).toHaveTextContent(npubEncode(key)); + await user.unhover(row); expect(document.body.textContent).not.toContain(key); expect(document.body.textContent).not.toContain(missing); await userEvent.click( screen.getByRole("checkbox", { - name: `Unavailable agent ${formatPublicKey(missing)}`, + name: `Unavailable agent Agent · ${formatPublicKey(missing)}`, }), ); expect(change).toHaveBeenCalledWith([]); @@ -1449,7 +1457,7 @@ it("settings offers only the managed namesake and saves its exact key; creation await screen.findByRole("button", { name: "Save as template…" }), ); const checkbox = await screen.findByRole("checkbox", { - name: `Calvin ${formatPublicKey(test.fixture.agent.pubkey)}`, + name: `Calvin Agent · ${formatPublicKey(test.fixture.agent.pubkey)}`, }); expect(screen.getAllByRole("checkbox")).toHaveLength(1); expect(checkbox).toBeChecked(); diff --git a/src/bundled/channels/ChannelMembersDialog.test.tsx b/src/bundled/channels/ChannelMembersDialog.test.tsx index 9ca2d8c52..ef41631d4 100644 --- a/src/bundled/channels/ChannelMembersDialog.test.tsx +++ b/src/bundled/channels/ChannelMembersDialog.test.tsx @@ -11,6 +11,7 @@ import type { RelayEvent } from "../../features/relay/events"; import { PublishRejected } from "../../features/relay/outbox"; import { createAgentControl } from "../../features/agents/control"; import { controlFixture } from "../../features/agents/control-testing"; +import { ToastProvider } from "../../shared/design-system/ui/Toast"; import { ChannelMembersButton } from "./ChannelMembersDialog"; const stops: (() => void)[] = []; afterEach(() => { @@ -99,11 +100,13 @@ async function setup( expect(owner.session.channels.list().status).toBe("ready"), ); render( - , + + + , ); const user = userEvent.setup(); await user.click(screen.getByRole("button", { name: "Channel members" })); diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx index 8e2589225..0d7f65999 100644 --- a/src/bundled/channels/ChannelMembersDialog.tsx +++ b/src/bundled/channels/ChannelMembersDialog.tsx @@ -19,7 +19,8 @@ import { Dialog } from "../../shared/design-system/ui/Dialog"; import { IconButton } from "../../shared/design-system/ui/IconButton"; import { Button } from "../../shared/design-system/ui/Button"; import { SearchField } from "../../shared/design-system/ui/SearchField"; -import { Avatar } from "../../shared/design-system/ui/Avatar"; +import { AgentOwnerPreview } from "../../features/profiles/AgentOwnerPreview"; +import { IdentityRow } from "../../shared/identity/IdentityRow"; import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; import { UsersIcon } from "../../shared/design-system/icons"; import { useMemberSearch } from "./useMemberSearch"; @@ -252,52 +253,49 @@ export function ChannelMembersDialog({ ) => { const isAgent = agent || known.has(key) || profiles.get(key)?.isAgent; const artwork = picture ?? profiles.get(key)?.picture; - const content = ( - <> - - {name} - {key === session.viewer ? " (you)" : ""} - - - {isAgent ? "Agent · " : ""} - {keys.get(key)} - {archived.has(key) ? " · Archived" : ""} - - - ); - const avatar = ( - + ) : undefined + } + detail={archived.has(key) ? "Archived" : undefined} + render={ + adding + ? (content) => ( + { + if (focusedAdd.current?.button === event.currentTarget) + focusedAdd.current = null; + }} + onClick={(event) => { + if (busy.has(key)) return; + if (document.activeElement === event.currentTarget) + focusedAdd.current = { key, button: event.currentTarget }; + void add(key); + }} + /> + ) + : undefined + } /> ); return adding ? ( - { - if (focusedAdd.current?.button === event.currentTarget) - focusedAdd.current = null; - }} - onClick={(event) => { - if (busy.has(key)) return; - if (document.activeElement === event.currentTarget) - focusedAdd.current = { key, button: event.currentTarget }; - void add(key); - }} - /> +
{identity}
) : ( -
  • - {avatar} - {content} +
  • + {identity}
  • ); }; diff --git a/src/bundled/profiles/InstanceProfilePanel.tsx b/src/bundled/profiles/InstanceProfilePanel.tsx index 6ddad8965..726605da5 100644 --- a/src/bundled/profiles/InstanceProfilePanel.tsx +++ b/src/bundled/profiles/InstanceProfilePanel.tsx @@ -1,3 +1,4 @@ +import { useAgentOwnerEvidence } from "../../features/profiles/useAgentOwnerEvidence"; import { useState } from "react"; import { useRelayConnection } from "../../features/relay/react"; import type { RelayData } from "../../features/relay/service"; @@ -14,7 +15,6 @@ import { } from "../../features/profiles/instance-target"; import { profileTarget } from "../../features/profiles/target"; import { Button } from "../../shared/design-system/ui/Button"; -import { useAgentOwnerEvidence } from "./ProfileAgentIdentity"; import { ProfilePanel } from "./ProfilePanel"; /** Host retains the exact target and owns replacement, close and focus restoration. diff --git a/src/bundled/profiles/ProfileAgentIdentity.tsx b/src/bundled/profiles/ProfileAgentIdentity.tsx index 80422949e..ac00e6293 100644 --- a/src/bundled/profiles/ProfileAgentIdentity.tsx +++ b/src/bundled/profiles/ProfileAgentIdentity.tsx @@ -1,150 +1,13 @@ -import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; -import { attestedOwner } from "../../features/agents/owner-attestation"; +import { useEffect, useMemo, useSyncExternalStore } from "react"; import { useIdentityNames } from "../../features/identity-names/react"; import type { PanelProps } from "../../features/panels/service"; import { profileTarget } from "../../features/profiles/target"; -import { type EventData, newer } from "../../features/relay/events"; import { selectProfiles } from "../../features/relay/profile-selection"; -import type { - EventViewSnapshot, - RelaySession, -} from "../../features/relay/session"; +import type { RelaySession } from "../../features/relay/session"; import { Button } from "../../shared/design-system/ui/Button"; import { formatPublicKey } from "../../shared/identity/public-key"; import styles from "./Profiles.module.css"; -type ProfileView = ReturnType; -const noSubscribe = () => () => {}; -const pendingView = (): EventViewSnapshot => pendingSnapshot; -const pendingSnapshot: EventViewSnapshot = Object.freeze({ - status: "loading", - events: [], -}); - -/** Verified NIP-OA owner of the winning signed kind 0, or none. Agent hints - * decide whether to mount this view; they never establish ownership. - * Private admission must inspect readiness as well as signed-head ownership. - * Public identity attribution may still display its retained signed evidence. */ -export function useAgentOwnerEvidence( - session: RelaySession, - pubkey: string | undefined, - attempt = 0, -): { - status: "loading" | "ready" | "error" | "unavailable"; - owner: string | undefined; - failed: boolean; - settled: boolean; -} { - // A session-owned view: live events, reconnect refresh and purge, no polling. - // Capacity or a closed session leaves no view; profile recovery retries. - const [view, setView] = useState(); - // biome-ignore lint/correctness/useExhaustiveDependencies: attempt retries view admission. - useEffect(() => { - if (!pubkey) { - setView(null); - return; - } - let owned: ProfileView; - try { - owned = session.observe([{ kinds: [0], authors: [pubkey], limit: 1 }]); - } catch { - setView(null); - return; - } - setView(owned); - return owned.dispose; - }, [session, pubkey, attempt]); - const events = useSyncExternalStore( - view?.subscribe ?? noSubscribe, - view?.snapshot ?? pendingView, - view?.snapshot ?? pendingView, - ); - // Provenance belongs to the winning signed kind 0 alone, never a display projection. - // The directory's retained head outlives this view, so a reopened pane cannot - // accept an older response than the profile the rest of the session shows. - // Subscribed, so any head change (live, read or disk restore) re-renders. - // Without a live view nothing can signal an auth-only change, so show nothing. - const directoryHead = useSyncExternalStore( - session.profiles.subscribe, - () => (pubkey ? session.profiles.event?.(pubkey) : undefined), - () => (pubkey ? session.profiles.event?.(pubkey) : undefined), - ); - const head = view && pubkey ? directoryHead : undefined; - const latest = events.events - .filter( - (event) => - event.kind === 0 && - event.pubkey === pubkey && - event.delivery !== "failed", - ) - .reduce(newer, head); - const [verified, setVerified] = useState<{ id: string; owner?: string }>(); - useEffect(() => { - if (!latest) return; - let active = true; - void attestedOwner(latest).then((owner) => { - if (active) setVerified({ id: latest.id, ...(owner ? { owner } : {}) }); - }); - return () => { - active = false; - }; - }, [latest]); - useEffect(() => { - if (events.status === "idle") void view?.refresh(); - }, [view, events.status]); - const settled = !!(verified && latest && verified.id === latest.id); - const owner = settled ? verified?.owner : undefined; - // Only an already admitted result from this observation may survive a - // background read. New heads, purges and failed reads must establish it anew. - const [admitted, setAdmitted] = useState<{ - view: ProfileView; - id: string; - }>(); - useEffect(() => { - setAdmitted((previous) => { - if ( - events.status === "ready" && - view && - latest && - latest.id === verified?.id - ) { - return previous?.view === view && previous.id === latest.id - ? previous - : { view, id: latest.id }; - } - return events.status === "loading" && - previous?.view === view && - previous?.id === latest?.id - ? previous - : undefined; - }); - }, [events.status, view, latest, verified]); - const refreshingAdmittedHead = - !!admitted && - events.status === "loading" && - admitted?.view === view && - admitted?.id === latest?.id; - const status = - view === null - ? "unavailable" - : events.status === "error" - ? "error" - : !view || - (events.status !== "ready" && !refreshingAdmittedHead) || - (latest && verified?.id !== latest.id) - ? "loading" - : "ready"; - return { - status, - owner, - failed: !!pubkey && (view === null || events.status === "error"), - // An absent event is conclusive only after its read completed. Failure or - // exhausted capacity must not unlock unverified legacy fallback. - settled: - !pubkey || settled || (!!view && !latest && events.status === "ready"), - }; -} - export function ProfileAgentIdentity({ session, owner, diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index 34b73acd0..9f70c2124 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -1,3 +1,4 @@ +import { useAgentOwnerEvidence } from "../../features/profiles/useAgentOwnerEvidence"; import { useAgentControlRefresh } from "../../features/agents/control-react"; import { UserStatusDisplay } from "../../features/user-status/StatusDisplay"; import { @@ -45,10 +46,7 @@ import { selectProfiles } from "../../features/relay/profile-selection"; import { useRelayConnection } from "../../features/relay/react"; import type { RelayData } from "../../features/relay/service"; import type { RelaySession } from "../../features/relay/session"; -import { - ProfileAgentIdentity, - useAgentOwnerEvidence, -} from "./ProfileAgentIdentity"; +import { ProfileAgentIdentity } from "./ProfileAgentIdentity"; import styles from "./Profiles.module.css"; const emptyState: AgentControlState = { diff --git a/src/features/profiles/AgentOwnerPreview.test.tsx b/src/features/profiles/AgentOwnerPreview.test.tsx new file mode 100644 index 000000000..09557ed5a --- /dev/null +++ b/src/features/profiles/AgentOwnerPreview.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { createHash } from "node:crypto"; +import { schnorr } from "@noble/curves/secp256k1.js"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { npubEncode } from "nostr-tools/nip19"; +import { bytesToHex } from "nostr-tools/utils"; +import { createRelaySession } from "../relay/session"; +import { keypair, profile, signed } from "../relay/testing"; +import { matchesEvent } from "../relay/projection"; +import { AgentOwnerPreview } from "./AgentOwnerPreview"; + +const stops: (() => void)[] = []; +afterEach(() => { + cleanup(); + for (const stop of stops.splice(0)) stop(); +}); + +it.each([true, false])( + "shows only a verified owner, not a profile claim (valid: %s)", + async (valid) => { + const agent = keypair(); + const owner = keypair(); + const other = keypair(); + const digest = new Uint8Array( + createHash("sha256").update(`nostr:agent-auth:${agent.pubkey}:`).digest(), + ); + const events = [ + signed(agent, { + kind: 0, + content: JSON.stringify({ name: "Helper", owner: owner.pubkey }), + tags: [ + [ + "auth", + owner.pubkey, + "", + bytesToHex( + schnorr.sign(digest, valid ? owner.secret : other.secret), + ), + ], + ], + }), + profile(owner, { name: "Morgan" }), + ]; + const host = createRelaySession({ + viewer: owner.pubkey, + relayAuthor: keypair().pubkey, + scope: "https://relay.example.test", + media: () => undefined, + query: async (filters) => + events.filter((event) => + filters.some((filter) => matchesEvent(event, filter)), + ), + }); + stops.push(host.dispose); + render(); + if (valid) { + expect(await screen.findByText("Morgan (you)")).toBeVisible(); + expect(screen.getByText(npubEncode(owner.pubkey))).toBeVisible(); + } else { + expect(await screen.findByText("Owner unavailable")).toBeVisible(); + expect( + screen.queryByText(npubEncode(owner.pubkey)), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Morgan (you)")).not.toBeInTheDocument(); + } + }, +); diff --git a/src/features/profiles/AgentOwnerPreview.tsx b/src/features/profiles/AgentOwnerPreview.tsx new file mode 100644 index 000000000..0ce41f627 --- /dev/null +++ b/src/features/profiles/AgentOwnerPreview.tsx @@ -0,0 +1,70 @@ +import { selectProfiles } from "../relay/profile-selection"; +import { useIdentityNames } from "../identity-names/react"; +import { useEffect, useMemo, useSyncExternalStore } from "react"; +import { npubEncode } from "nostr-tools/nip19"; +import { useAgentOwnerEvidence } from "./useAgentOwnerEvidence"; +import type { RelaySession } from "../relay/session"; +import { Avatar } from "../../shared/design-system/ui/Avatar"; +import { formatPublicKey } from "../../shared/identity/public-key"; + +/** Mounted only for an open preview; metadata is never ownership evidence. */ +export function AgentOwnerPreview({ + session, + pubkey, +}: { + session: RelaySession; + pubkey: string; +}) { + const { owner, status } = useAgentOwnerEvidence(session, pubkey); + const selection = useMemo( + () => selectProfiles(session.profiles, owner ? [owner] : []), + [session.profiles, owner], + ); + const profiles = useSyncExternalStore( + selection.subscribe, + selection.snapshot, + selection.snapshot, + ); + const identityName = useIdentityNames(session.names); + useEffect(() => { + if (owner) + void session.profiles.ensure([owner], "background").catch(() => {}); + }, [session, owner]); + const profile = owner ? profiles.get(owner) : undefined; + const name = owner + ? identityName(owner, profile?.name ?? formatPublicKey(owner) ?? "Owner") + : undefined; + return ( +
    +
    Managed by
    + {owner ? ( + <> +
    + + + {name} + {owner === session.viewer ? " (you)" : ""} + +
    +
    + {npubEncode(owner)} +
    + + ) : ( +
    + {status === "loading" ? "Checking ownership…" : "Owner unavailable"} +
    + )} +
    + ); +} diff --git a/src/features/profiles/useAgentOwnerEvidence.ts b/src/features/profiles/useAgentOwnerEvidence.ts new file mode 100644 index 000000000..aee4efa27 --- /dev/null +++ b/src/features/profiles/useAgentOwnerEvidence.ts @@ -0,0 +1,136 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import { attestedOwner } from "../agents/owner-attestation"; +import { type EventData, newer } from "../relay/events"; +import type { EventViewSnapshot, RelaySession } from "../relay/session"; + +type ProfileView = ReturnType; +const noSubscribe = () => () => {}; +const pendingView = (): EventViewSnapshot => pendingSnapshot; +const pendingSnapshot: EventViewSnapshot = Object.freeze({ + status: "loading", + events: [], +}); + +/** Verified NIP-OA owner of the winning signed kind 0, or none. Agent hints + * decide whether to mount this view; they never establish ownership. + * Private admission must inspect readiness as well as signed-head ownership. + * Public identity attribution may still display its retained signed evidence. */ +export function useAgentOwnerEvidence( + session: RelaySession, + pubkey: string | undefined, + attempt = 0, +): { + status: "loading" | "ready" | "error" | "unavailable"; + owner: string | undefined; + failed: boolean; + settled: boolean; +} { + // A session-owned view: live events, reconnect refresh and purge, no polling. + // Capacity or a closed session leaves no view; profile recovery retries. + const [view, setView] = useState(); + // biome-ignore lint/correctness/useExhaustiveDependencies: attempt retries view admission. + useEffect(() => { + if (!pubkey) { + setView(null); + return; + } + let owned: ProfileView; + try { + owned = session.observe([{ kinds: [0], authors: [pubkey], limit: 1 }]); + } catch { + setView(null); + return; + } + setView(owned); + return owned.dispose; + }, [session, pubkey, attempt]); + const events = useSyncExternalStore( + view?.subscribe ?? noSubscribe, + view?.snapshot ?? pendingView, + view?.snapshot ?? pendingView, + ); + // Provenance belongs to the winning signed kind 0 alone, never a display projection. + // The directory's retained head outlives this view, so a reopened pane cannot + // accept an older response than the profile the rest of the session shows. + // Subscribed, so any head change (live, read or disk restore) re-renders. + // Without a live view nothing can signal an auth-only change, so show nothing. + const directoryHead = useSyncExternalStore( + session.profiles.subscribe, + () => (pubkey ? session.profiles.event?.(pubkey) : undefined), + () => (pubkey ? session.profiles.event?.(pubkey) : undefined), + ); + const head = view && pubkey ? directoryHead : undefined; + const latest = events.events + .filter( + (event) => + event.kind === 0 && + event.pubkey === pubkey && + event.delivery !== "failed", + ) + .reduce(newer, head); + const [verified, setVerified] = useState<{ id: string; owner?: string }>(); + useEffect(() => { + if (!latest) return; + let active = true; + void attestedOwner(latest).then((owner) => { + if (active) setVerified({ id: latest.id, ...(owner ? { owner } : {}) }); + }); + return () => { + active = false; + }; + }, [latest]); + useEffect(() => { + if (events.status === "idle") void view?.refresh(); + }, [view, events.status]); + const settled = !!(verified && latest && verified.id === latest.id); + const owner = settled ? verified?.owner : undefined; + // Only an already admitted result from this observation may survive a + // background read. New heads, purges and failed reads must establish it anew. + const [admitted, setAdmitted] = useState<{ + view: ProfileView; + id: string; + }>(); + useEffect(() => { + setAdmitted((previous) => { + if ( + events.status === "ready" && + view && + latest && + latest.id === verified?.id + ) { + return previous?.view === view && previous.id === latest.id + ? previous + : { view, id: latest.id }; + } + return events.status === "loading" && + previous?.view === view && + previous?.id === latest?.id + ? previous + : undefined; + }); + }, [events.status, view, latest, verified]); + const refreshingAdmittedHead = + !!admitted && + events.status === "loading" && + admitted?.view === view && + admitted?.id === latest?.id; + const status = + view === null + ? "unavailable" + : events.status === "error" + ? "error" + : !view || + (events.status !== "ready" && !refreshingAdmittedHead) || + (latest && verified?.id !== latest.id) + ? "loading" + : "ready"; + return { + status, + owner, + failed: !!pubkey && (view === null || events.status === "error"), + // An absent event is conclusive only after its read completed. Failure or + // exhausted capacity must not unlock unverified legacy fallback. + settled: + !pubkey || settled || (!!view && !latest && events.status === "ready"), + }; +} diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md index 0737df66e..aec3b9ef5 100644 --- a/src/shared/design-system/DESIGN.md +++ b/src/shared/design-system/DESIGN.md @@ -251,6 +251,11 @@ while the frame owns the active perimeter stroke. Read-only values can be read and copied; disabled actions cannot change a value. Search clear restores input focus. Features still own filtering, custom values, and async recovery. +SearchField, Combobox.Control, and code Textarea default to no autocorrection, +capitalization, or spellcheck. Callers can override these defaults explicitly. +Ordinary Input and prose Textarea retain platform defaults. See the +[exact-text input audit](../../../docs/input-correction-audit.md) for remaining fields. + The Forms page in Just Design documents states, usage, and a form-in-dialog example. Review it with both themes, narrow widths, and enlarged text before introducing another form treatment. @@ -369,6 +374,13 @@ offers an `option` variant for picker rows with even 8px padding and immediate hover feedback. It forwards normal button events, refs and data attributes so unread observation, preloading and product shortcuts remain with the caller. +PreviewCard may expose one supplemental action through `actionRef`, such as copying +an identity's full npub. It remains non-modal and never takes focus on hover. Tab +from the trigger reaches the action; Shift+Tab returns to the trigger; forward Tab +continues after the trigger. Escape dismisses the preview before restoring focus. +The positioned portal owns its layer above dialogs. An optional content anchor +keeps previews near compact identity content inside wider actionable rows. + ## Menu row corners Every shared menu item uses `--radius-pill` on all four corners. First, middle and diff --git a/src/shared/design-system/styles/components.css b/src/shared/design-system/styles/components.css index a800ca6d1..334cef326 100644 --- a/src/shared/design-system/styles/components.css +++ b/src/shared/design-system/styles/components.css @@ -252,7 +252,6 @@ /* A preview is supplemental context; destination cards can also open it. It is portal-rendered by Base UI so a clipped parent cannot hide it. */ .buzz-preview-card { - z-index: var(--layer-popover); display: grid; width: max-content; max-width: min(18rem, 75vw); @@ -266,8 +265,12 @@ transform var(--duration-state) var(--easing-state); } - .buzz-preview-card[data-destination] { + .buzz-preview-card[data-destination], + .buzz-preview-card[data-interactive] { pointer-events: auto; + } + + .buzz-preview-card[data-destination] { cursor: pointer; color: inherit; text-decoration: none; @@ -819,7 +822,8 @@ @layer components { /* The positioned portal owns the stacking context, not its popup child. */ .buzz-menu-positioner, - .buzz-popover-positioner { + .buzz-popover-positioner, + .buzz-preview-card-positioner { z-index: var(--layer-popover); } .buzz-menu-popup { diff --git a/src/shared/design-system/ui/ChoiceControls.test.tsx b/src/shared/design-system/ui/ChoiceControls.test.tsx index 44ea6df5f..5fd4611da 100644 --- a/src/shared/design-system/ui/ChoiceControls.test.tsx +++ b/src/shared/design-system/ui/ChoiceControls.test.tsx @@ -338,3 +338,29 @@ it("keeps compact choices labelled and opens by click and keyboard", async () => expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); expect(trigger).toHaveFocus(); }); + +it("defaults combobox queries to exact text while permitting explicit correction", () => { + const view = render( + + + , + ); + const input = screen.getByRole("combobox", { name: "Query" }); + expect(input).toHaveAttribute("autocorrect", "off"); + expect(input).toHaveAttribute("autocapitalize", "none"); + expect(input).toHaveAttribute("spellcheck", "false"); + view.rerender( + + + , + ); + expect(input).toHaveAttribute("autocorrect", "on"); + expect(input).toHaveAttribute("autocapitalize", "sentences"); + expect(input).toHaveAttribute("spellcheck", "true"); +}); diff --git a/src/shared/design-system/ui/Combobox.tsx b/src/shared/design-system/ui/Combobox.tsx index 338a6c443..14e3d462c 100644 --- a/src/shared/design-system/ui/Combobox.tsx +++ b/src/shared/design-system/ui/Combobox.tsx @@ -69,6 +69,9 @@ function Control({ } > ; }; /** @@ -29,51 +38,83 @@ export function PreviewCard({ open, onOpenChange, side = "bottom", + anchor, delay = 250, className, link, + actionRef, "aria-label": label, }: PreviewCardProps) { const triggerRef = useRef(null); const popupRef = useRef(null); + const focusedTrigger = useRef(null); + const returnFocusOnClose = useRef(false); + const restoreFocus = () => + (focusedTrigger.current ?? triggerRef.current)?.focus(); return ( - + { + onOpenChange?.(next, details); + returnFocusOnClose.current = + !next && + !details.isCanceled && + details.reason === "escape-key" && + !!popupRef.current?.contains(document.activeElement); + }} + onOpenChangeComplete={(next) => { + if (!next && returnFocusOnClose.current) { + returnFocusOnClose.current = false; + restoreFocus(); + } + }} + > { + focusedTrigger.current = event.target as HTMLElement; + }} onKeyDown={(event) => { if ( - link && + (link || actionRef) && event.key === "Tab" && !event.shiftKey && popupRef.current ) { event.preventDefault(); - popupRef.current.focus(); + (actionRef?.current ?? popupRef.current).focus(); } }} /> - + { - if (link && event.key === "Tab") { + if ((link || actionRef) && event.key === "Tab") { // The portal is at the end of the document. Resume from its // trigger so Tab order follows the link's position in prose. - triggerRef.current?.focus(); + restoreFocus(); if (event.shiftKey) event.preventDefault(); } - if (link && event.key === "Escape") triggerRef.current?.focus(); }} className={["buzz-preview-card", className] .filter(Boolean) .join(" ")} render={link} - role={link ? "link" : "tooltip"} + role={link ? "link" : actionRef ? "dialog" : "tooltip"} + aria-modal={actionRef ? false : undefined} + data-interactive={actionRef ? "" : undefined} tabIndex={link ? 0 : undefined} data-destination={link ? "" : undefined} aria-label={label} diff --git a/src/shared/design-system/ui/SearchField.tsx b/src/shared/design-system/ui/SearchField.tsx index 145f07874..3c3800480 100644 --- a/src/shared/design-system/ui/SearchField.tsx +++ b/src/shared/design-system/ui/SearchField.tsx @@ -45,6 +45,9 @@ export function SearchField({ ) : null; const input = ( } + render={ +