From 67d05332bf07c66747593b96e5c7f2fc174a5016 Mon Sep 17 00:00:00 2001 From: "claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla)" Date: Sat, 8 Aug 2026 17:48:56 +0000 Subject: [PATCH] Suggest Bluesky handles beside the identities this browser remembers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sign-in field's remembered identities are exact, free, and empty on a fresh profile — which is the profile signing in for the first time. So the prefix being typed is now also asked of a public appview's `app.bsky.actor.searchActorsTypeahead`, and the handles it names join the same `datalist` behind the remembered ones. `handle-suggest.svelte.ts` holds the whole of it, shared by both surfaces so neither can grow its own debounce, its own appview, or its own idea of what is safe to send. One instance per field (the picker carries the popover's chip, and two fields can hold different text), one debounced ask, aborted when the typing moves on and dropped on a sequence number when a stale answer wins the race anyway. What it is not, stated where somebody will look for it: not identity resolution — signing in still resolves the handle through `auth.svelte.ts`, so a handle the appview never heard of signs in exactly as before; not anything the fold reads — no record is fetched or validated, the way `guests.ts` and `issues.ts` already have to promise; and not silent — keystrokes leave the tab before anything is submitted, so both surfaces name the host that sees them before anything is typed, and `PUBLIC_RADIAL_HANDLE_TYPEAHEAD` points elsewhere or `off`. Less than the field is sent, too: a DID, a PDS address, and anything with whitespace or a path in it are never asked about, since none of them is a handle. Results are kept to handles containing the query, because a `datalist` is browser-filtered against the field's value and the appview also matches display names — so the list this module reports and the list a person sees are one list. Guards: `handle-suggest.test.ts` (19 pure tests) over what is worth asking, what comes back, the merge, and the class's debounce, race and off switch; `sign-in-fields.svelte.test.ts` mounts both surfaces against a stubbed directory and asserts the options a human would see, that a DID asks nobody, and that the host is named; `sign-in-surfaces.test.mjs` derives both surfaces and requires the shared module and the disclosure. Verified each new guard fails when what it claims is removed. Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) --- packages/ui/README.md | 11 + packages/ui/src/app.css | 4 + packages/ui/src/lib/components/Account.svelte | 39 ++- .../ui/src/lib/components/SpacePicker.svelte | 25 +- .../components/sign-in-fields.svelte.test.ts | 64 +++- packages/ui/src/lib/handle-suggest.svelte.ts | 290 ++++++++++++++++++ packages/ui/src/lib/handle-suggest.test.ts | 207 +++++++++++++ packages/ui/test/sign-in-surfaces.test.mjs | 13 + 8 files changed, 640 insertions(+), 13 deletions(-) create mode 100644 packages/ui/src/lib/handle-suggest.svelte.ts create mode 100644 packages/ui/src/lib/handle-suggest.test.ts diff --git a/packages/ui/README.md b/packages/ui/README.md index 3a72f3b..ea95de3 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -35,6 +35,7 @@ if a `node:*` import creeps back onto that path. | `src/lib/private-tabs.ts` | One transport per browser **profile**, shared by every tab of it (`docs/adr-private-mode-iroh.md` §26). The endpoint identity is a per-profile IndexedDB row, so two tabs binding it independently produced two endpoints with one node id and a relay routed to whichever registered last. `navigator.locks` elects one tab to bind; one `BroadcastChannel` relays the others' dials, frames and inbound-connection offers through it. The holder owns the **transport and not the replica** — every tab keeps its own `PrivateEndpoint`, bus, connections and ingestor over its own stores. A promotion re-reads the identity so the next holder is the same node id; relayed links are orphaned so they throw and the buses above redial. `MemoryTabNetwork` is several tabs in one process, for the tests. | | `src/lib/private-mode.ts` | Private mode as a person meets it (`docs/adr-private-mode-iroh.md` §19, §23): the four-point privacy disclosure ADR §3 requires *before* anything is published, the device rows — this browser's key store joined to the directory the fold read, including whether each device is addressed, retired, or has never said, which is what lets the space page keep the retired ones in a closed disclosure of their own — and the session-wired actions (accept a ticket, found a space, mint a ticket, rotate, retire). Founding runs `sidecar/private-cli.ts`'s sequence step for step and bookmarks last, so a create that could not seal the genesis announces nothing; minting reads the fold and writes nothing, and always names the founder's published key. The rules stay in `core`; what is here is the wiring, the explicit no-device-revocation compromise, and the one action that answers something going wrong — retiring a device withdraws its published address so no replica connects to it, and the surface says in as many words that it disconnects rather than un-counts (`docs/adr-private-mode-iroh.md` §29). | | `src/lib/identity.ts` | DID → PDS and DID → handle resolution, cached. A handle is shown only when it resolves back to the DID that claimed it. | +| `src/lib/handle-suggest.svelte.ts` | What a sign-in field offers while somebody types: this browser's remembered identities, then the handles a public appview's typeahead names for the prefix. One debounced, abortable ask per field, kept out of the sequence a stale answer could win. It is a dropdown and never a decision — signing in still resolves the handle through `auth.svelte.ts`, nothing here is fetched as a record, and a DID or a PDS address is never sent. `PUBLIC_RADIAL_HANDLE_TYPEAHEAD=off` turns the directory half off; both surfaces disclose it while it is on. | | `src/lib/units.ts` | Presentation over `timeline()`: a row's text, what quick find searches it by, its badges, its status disc, and the two cross-target capture relations no single target's index can see. Also the tip/open-request split — `requestState()` and the `isClaimed`/`isAssigned`/`isOpen`/`isAwaiting` predicates every list groups and counts by, because `UnitView.state` describes what LANDED and stays `judged` while a successor runs. | | `src/lib/requests.ts`, `verdicts.ts`, `admin.ts` | What each surface may offer and what it writes, as pure functions: the ⊕ menu, the review form and its findings, space administration. Tested without rendering anything. | | `src/lib/labels.ts` | The reading side of goal labels: the argv a label editor writes (always `--set`, always the whole set — the record has no add or remove), the space's label vocabulary with counts (which IS the registry: there is none on-protocol), and a chip's hue as a pure function of its text. The normalization *rule* is `@radial/core`'s, shared with the sidecar so a label typed here and one typed at a shell cannot differ. | @@ -108,6 +109,16 @@ served at — so `PUBLIC_RADIAL_ORIGIN=https://radial.example pnpm build` emits into the build. Without it the bundle is complete and still signs in on a loopback host, where the client id encodes the metadata instead of pointing at it. +The identity field on both sign-in surfaces suggests two things. The first is the identities this +browser has signed in as before, kept in `localStorage` beside the "Continue as" buttons — free, and +empty on a fresh profile. The second is what a public directory names for the prefix being typed +(`handle-suggest.svelte.ts`), which is what a person signing in for the first time actually needs. +That ask leaves the tab before anything is submitted, so both surfaces say where the suggestions come +from, a DID or a PDS address is never sent, and `PUBLIC_RADIAL_HANDLE_TYPEAHEAD` is the knob: an +appview of your own, or `off` to leave the field with the remembered identities alone. It is a +dropdown and nothing more — the handle a person settles on is resolved by the same code whether they +picked it or typed it in full, and `PUBLIC_RADIAL_HANDLE_DIRECTORY` is what governs *that*. + ### Comments from outside the space A goal page has a **Community** section when an admin has turned guest comments on diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css index 21fd2fe..a3d7f2c 100644 --- a/packages/ui/src/app.css +++ b/packages/ui/src/app.css @@ -1186,6 +1186,10 @@ select.sl { resize: none; cursor: pointer; padding-right: 8px; } .signin-or { display: flex; align-items: center; gap: 8px; margin: 12px 0 9px; color: var(--ink-2); font-size: 10.5px; } .signin-or::before, .signin-or::after { content: ''; height: 1px; flex: 1; background: var(--line-soft); } .signin-or span { flex: none; } +/* Where the field's suggestions come from, said on both sign-in surfaces before anything is typed: + the remembered ones are this browser's, the rest are a public directory's, and asking one means + the prefix leaves the tab (`handle-suggest.svelte.ts`). */ +.signin-source { margin: 8px 0 0; font-size: 11.5px; line-height: 1.45; color: var(--ink-2); } .acct-pop .acts { padding-top: 11px; } .acct-who { display: flex; align-items: center; gap: 8px; margin-bottom: 9px; } .acct-who .nm { font-size: 13.5px; font-weight: 600; } diff --git a/packages/ui/src/lib/components/Account.svelte b/packages/ui/src/lib/components/Account.svelte index 809dd8a..4c23216 100644 --- a/packages/ui/src/lib/components/Account.svelte +++ b/packages/ui/src/lib/components/Account.svelte @@ -2,6 +2,7 @@ import { account, rememberedIdentities, signIn, signOut } from '$lib/auth.svelte.js' import { unknownActor } from '$lib/directory.js' import { focusStranded, keepFocus } from '$lib/focus.js' + import { HandleTypeahead, mergeSuggestions } from '$lib/handle-suggest.svelte.js' import type { Space } from '$lib/space.js' import { ui } from '$lib/ui.svelte.js' import Disc from './Disc.svelte' @@ -23,6 +24,22 @@ let field = $state(null) let chip = $state(null) + // The suggestions this field offers: the identities this browser has held, then whatever the + // public directory names for the prefix being typed. One instance per surface, because the picker + // carries this popover's chip and the two fields can hold different text at once. + const typeahead = new HandleTypeahead() + const suggestions = $derived( + mergeSuggestions( + rememberedIdentities.map((identity) => identity.handle ?? identity.did), + typeahead.handles, + ), + ) + $effect(() => { + void typeahead.suggest(identifier) + }) + // A popover that goes away takes its pending ask with it. + $effect(() => () => typeahead.dispose()) + // A member's own resolved handle wins; before the directory has one, the handle the session was // established under stands in. Either way the disc's colour comes from the DID, so it never moves. const me = $derived.by(() => { @@ -147,11 +164,13 @@ field could offer — a browser fills them from a value it recorded for this origin, and this origin records neither kind: the password is typed at the PDS, so no manager ever stores a login here, and `submit` cancels the submission, so form history never captures the handle - either. The suggestions therefore come from the one place that does hold identities Radial - knows: `rememberedIdentities`, the same list the "Continue as" buttons above are built from. - A `datalist` turns it into type-ahead that filters as you type, in every browser, without - depending on a submission the app deliberately prevents. The id is scoped to this component - because the picker carries the same field and both can be on screen at once. + either. The suggestions therefore come from the two places that do hold identities: this + browser's `rememberedIdentities`, the same list the "Continue as" buttons above are built + from, and — for the fresh profile where that list is empty — the public directory + `handle-suggest.svelte.ts` asks as the prefix is typed. A `datalist` turns both into + type-ahead that filters as you type, in every browser, without depending on a submission the + app deliberately prevents. The id is scoped to this component because the picker carries the + same field and both can be on screen at once. -->
+ {#if typeahead.enabled} + + {/if}