diff --git a/packages/ui/README.md b/packages/ui/README.md --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -35,7 +35,7 @@ | `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/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, and `visibleSuggestions` for which of the merged list the text in the field is about. 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. | @@ -64,6 +64,7 @@ | `src/lib/components/MarkdownEditor.svelte` | The one editor every markdown field is. Bindable string in, string out; degrades to a textarea; optionally offers image upload. | | `src/lib/components/PrivateDisclosure.svelte`, `JoinPrivate.svelte`, `PrivateDevices.svelte` | The private-mode surfaces: the disclosure, the picker's ticket card (whose Join button does nothing until the disclosure is acknowledged), and the space page's section — what this replica holds, your devices, and everybody else's as the directory has them. Tickets are not here: one is how a person gets in, so it is minted on that person's row in Members. | | `src/lib/components/TicketPanel.svelte` | The one surface that displays a ticket (ADR §23) — fingerprint beside it, copy button, and the line asking for the fingerprint to be read back. Used by the invite and by a member row's Ticket button. | +| `src/lib/components/SignInField.svelte` | The identity field every sign-in surface draws: the standard `name`/`autocomplete="username"` hints, and the app's own combobox over `handle-suggest.svelte.ts` — remembered identities and the directory's handles in one listbox that paints when an answer arrives rather than at the next keystroke. One component, so a login page cannot ship the field without the semantics, the suggestions or the sentence naming who sees what is typed. | | `src/lib/components/NewSpace.svelte` | The picker's create form, public and private in one vector: `--private` is the only difference, and the disclosure gates the button when it is set. | | `bin/serve.ts` | `radial-ui`: serves the built bundle on loopback, nothing else. | @@ -109,15 +110,26 @@ 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*. +The identity field on both sign-in surfaces is one component (`SignInField.svelte`) and it 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*. + +The app draws that dropdown itself. It was a ``, which is the browser's popup rather than +the app's: it opens on a keystroke and offers whatever options existed at that moment, and a +directory's answer arrives a debounce and a round trip later. With something remembered to show in +the meantime that still looked like a typeahead — but the **fully logged out** screen is exactly the +one with nothing remembered, so the only suggestions it can offer were always the late ones, and none +of them was ever displayed. A combobox and a listbox of the app's own paint when the answer lands, +match on text the app normalizes (a leading `@` is dropped for the comparison exactly as it is for +the ask, which a datalist could not do), and take a suggestion from the arrow keys or a click. +`autocomplete="username"` stays on the input for a password manager that does hold something. ### Comments from outside the space diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css --- a/packages/ui/src/app.css +++ b/packages/ui/src/app.css @@ -1190,6 +1190,27 @@ 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); } +/* The list under the sign-in field. Drawn by the app rather than left to a ``, because a + directory's answers arrive after the keystroke that asked for them and the browser's own popup is + closed by then — which is why the fully logged out screen, the one with nothing remembered to show + in the meantime, never appeared to have a typeahead at all (`SignInField.svelte`). + Over the content rather than in it: the button below must not move while somebody is typing. */ +.signin-combo { position: relative; } +.signin-list { + position: absolute; z-index: 40; left: 0; right: 0; top: calc(100% + 4px); + display: grid; gap: 1px; padding: 4px; max-height: 188px; overflow: auto; + background: var(--paper); border: 1px solid var(--line); border-radius: 9px; + box-shadow: 0 2px 6px oklch(0.25 0.03 262 / 0.10), 0 14px 30px -10px oklch(0.25 0.03 262 / 0.30); +} +.signin-list[hidden] { display: none; } +.signin-option { + width: 100%; padding: 6px 8px; border: 0; border-radius: 6px; background: none; + cursor: pointer; color: var(--ink); text-align: left; + font-family: var(--mono); font-size: var(--field-ident-size); line-height: 1.4; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.signin-option:hover { background: var(--hover); } +.signin-option.on { background: var(--accent); color: var(--on-accent); } .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/test/sign-in-surfaces.test.mjs b/packages/ui/test/sign-in-surfaces.test.mjs --- a/packages/ui/test/sign-in-surfaces.test.mjs +++ b/packages/ui/test/sign-in-surfaces.test.mjs @@ -15,13 +15,14 @@ } } -/** The tag the human types an identity into, isolated so an assertion cannot drift onto another. */ -function identityField(source) { - const field = source - .match(/]*>/g) - ?.find((tag) => tag.includes('bind:value={identifier}')) - assert.ok(field, 'sign-in surface has an identity input bound to `identifier`') - return field +/** The one field all of them draw, which is where the semantics and the suggestions now live. */ +const field = await readFile(new URL('lib/components/SignInField.svelte', src), 'utf8') + +/** The tag a human types an identity into, isolated so an assertion cannot drift onto another. */ +function identityInput(source) { + const tag = source.match(/]*>/g)?.find((element) => element.includes('bind:value')) + assert.ok(tag, 'the shared sign-in field has an identity input') + return tag } describe('sign-in surfaces', () => { @@ -44,39 +45,56 @@ } }) - // The rendered behaviour of these attributes is asserted against the real element in - // `src/lib/components/sign-in-fields.svelte.test.ts`; this is the source-level half, which is what - // catches a surface that never mounts in a test at all. - it('identify every sign-in field as a username for browser autocomplete', () => { + // One field, drawn by everybody. A surface with its own `` could carry its + // own semantics, its own debounce, its own appview and its own idea of what is safe to send — and + // could ship without any of them. The rendered behaviour is asserted against the real element in + // `src/lib/components/sign-in-fields.svelte.test.ts`; this is the half that catches a surface which + // never mounts in a test at all. + it('draw the one shared identity field rather than one of their own', () => { + const ids = [] for (const { name, source } of surfaces) { - const field = identityField(source) - assert.match(field, /\bname="username"/, name) - assert.match(field, /\bautocomplete="username"/, name) + assert.match(source, /import SignInField from '\.\/SignInField\.svelte'/, name) + const use = source.match(//) + assert.ok(use, `${name} draws the shared sign-in field`) + assert.doesNotMatch(source, /]*name="username"/, `${name} has no field of its own`) + const id = use[0].match(/\bid="([^"]+)"/) + assert.ok(id, `${name} scopes the field an id`) + ids.push(id[1]) } + // Scoped per surface, because the picker and the account popover are on screen together: a + // duplicate would leave one field's `aria-controls` pointing at the other's list. + assert.equal(new Set(ids).size, ids.length, `each surface scopes its own list, got ${ids}`) }) - // Both surfaces ask the same module, so neither can end up with its own debounce, its own appview - // or its own idea of what is safe to send — and neither can offer a directory's suggestions - // without saying that the field's text is what buys them. + it('identify that field as a username for browser autocomplete', () => { + const input = identityInput(field) + assert.match(input, /\bname="username"/) + assert.match(input, /\bautocomplete="username"/) + }) + + // The list is the app's own, and that is the point of it: a `` is offered by the browser + // at the moment of a keystroke, so the directory's answer — a debounce and a round trip later — + // arrived to a popup that had already closed, and the fully logged out screen, which has no + // remembered identity to show in the meantime, appeared to have no typeahead at all. + it('draw the suggestions themselves rather than handing them to the browser', () => { + const input = identityInput(field) + assert.match(input, /\brole="combobox"/) + assert.match(input, /\baria-expanded=/) + assert.match(input, /\baria-controls=/) + // No `list=`, so no `` is what fills this in — whatever the file says elsewhere. + assert.doesNotMatch(input, /\blist=/) + assert.match(field, /role="listbox"/) + assert.match(field, /role="option"/) + }) + + // Both surfaces ask one module, so none can grow its own debounce, its own appview or its own idea + // of what is safe to send — and none can offer a directory's suggestions without saying that the + // field's text is what buys them. it('offer directory suggestions through the shared module, and disclose it', () => { - for (const { name, source } of surfaces) { - assert.match(source, /from '\$lib\/handle-suggest\.svelte\.js'/, name) - assert.match(source, /new HandleTypeahead\(\)/, name) - assert.match(source, /typeahead\.enabled/, name) - assert.match(source, /signin-source/, name) - assert.match(source, /\{typeahead\.host\}/, name) - } - }) - - it('point every sign-in field at a list of remembered identities', () => { - for (const { name, source } of surfaces) { - const list = identityField(source).match(/\blist="([^"]+)"/) - assert.ok(list, `${name} offers remembered identities as suggestions`) - // Scoped per component, because the picker and the account popover are on screen together. - assert.ok( - source.includes(``), - `${name} defines the datalist its identity field names`, - ) - } + assert.match(field, /from '\$lib\/handle-suggest\.svelte\.js'/) + assert.match(field, /new HandleTypeahead\(\)/) + assert.match(field, /typeahead\.enabled/) + assert.match(field, /signin-source/) + assert.match(field, /\{typeahead\.host\}/) }) }) diff --git a/packages/ui/src/lib/handle-suggest.svelte.ts b/packages/ui/src/lib/handle-suggest.svelte.ts --- a/packages/ui/src/lib/handle-suggest.svelte.ts +++ b/packages/ui/src/lib/handle-suggest.svelte.ts @@ -4,8 +4,9 @@ // are exact, free, and known here — and empty on a fresh profile, or for a second identity this // browser has never held. That is the case this module answers: the prefix being typed is asked of // Bluesky's public appview, whose `app.bsky.actor.searchActorsTypeahead` is the same call Bluesky's -// own login and search fields make, and the handles it names join the datalist behind the remembered -// ones. +// own login and search fields make, and the handles it names join the list behind the remembered +// ones. `SignInField.svelte` draws that list — which is not a detail: these answers arrive after the +// keystroke that asked for them, and a browser's `` popup has closed by then. // // A directory query in an app with no central server deserves its boundary stated, so: three things // this is not. @@ -106,10 +107,10 @@ * the caller turns that into an empty dropdown: "the directory did not answer" is not worth * interrupting somebody typing their own handle. * - * Results are kept to handles CONTAINING the query, because a `datalist` is filtered by the browser - * against the field's value: the appview also matches display names, and an option whose handle does - * not contain what was typed is one no browser would show. Filtering here keeps the list this module - * reports and the list a person sees the same list. + * Results are kept to handles CONTAINING the query, because that is what the list under the field + * shows (`visibleSuggestions`): the appview also matches display names, and an account whose handle + * has nothing to do with what was typed is a row nobody could act on. Filtering here keeps the list + * this module reports and the list a person sees the same list. */ export async function searchHandles(input: { query: string @@ -165,6 +166,29 @@ if (merged.length === MAX_SUGGESTIONS) break } return merged +} + +/** + * Which of those are about the text in the field. + * + * `mergeSuggestions` decides the order and the dedupe; this decides what is worth showing. It exists + * because the app draws the list rather than handing it to a ``, and the two filter by + * different rules: a datalist compares options against the field's LITERAL value, so a leading `@` — + * which `handleQuery` drops before asking, and which anybody used to Bluesky types — bought directory + * answers that could never be displayed beside it (the field held `@ali`, every option `alice.test`). + * Here the comparison drops the sigil exactly as the ask does. + * + * A suggestion identical to what has been typed is dropped: it is the browser telling somebody the + * word they just finished typing, and it is what a list left open behind an accepted suggestion would + * otherwise consist of. An empty field suggests everything, which is the remembered identities. + */ +export function visibleSuggestions(merged: string[], typed: string): string[] { + const text = typed.trim().replace(/^@+/, '').toLowerCase() + if (!text) return merged + return merged.filter((value) => { + const label = value.toLowerCase() + return label !== text && label.includes(text) + }) } export interface TypeaheadOptions { diff --git a/packages/ui/src/lib/handle-suggest.test.ts b/packages/ui/src/lib/handle-suggest.test.ts --- a/packages/ui/src/lib/handle-suggest.test.ts +++ b/packages/ui/src/lib/handle-suggest.test.ts @@ -9,6 +9,7 @@ typeaheadEnabled, typeaheadHost, typeaheadOrigin, + visibleSuggestions, } from './handle-suggest.svelte.js' /** An appview that answers with the actors it was handed, and records what it was asked. */ @@ -58,8 +59,8 @@ it('drops what nobody could sign in as, and what no browser would show', async () => { const { fetcher } = appview([ actor('handle.invalid', 'Alison'), - // The appview matches display names too; a `datalist` is filtered by the browser against the - // field's value, so an option whose handle does not contain the query is one nobody sees. + // The appview matches display names too, and the list under the field is filtered on the + // handle, so an account whose handle has nothing to do with the query is one nobody sees. actor('bob.test', 'Alice Bobson'), { did: 'did:plc:shapeless' }, actor('ALICE.test'), @@ -96,6 +97,36 @@ const suggested = Array.from({ length: 40 }, (_, index) => `person${index}.test`) expect(mergeSuggestions(['alice.test'], suggested)).toHaveLength(MAX_SUGGESTIONS) expect(mergeSuggestions([], []).length).toBe(0) + }) +}) + +describe('what the field shows for what is in it', () => { + const merged = ['alice.test', 'did:plc:nohandle', 'alicia.example'] + + it('offers everything it has to an empty field', () => { + expect(visibleSuggestions(merged, '')).toEqual(merged) + expect(visibleSuggestions(merged, ' ')).toEqual(merged) + }) + + it('keeps the ones the text is about', () => { + expect(visibleSuggestions(merged, 'ali')).toEqual(['alice.test', 'alicia.example']) + expect(visibleSuggestions(merged, 'ALI')).toEqual(['alice.test', 'alicia.example']) + expect(visibleSuggestions(merged, 'did:')).toEqual(['did:plc:nohandle']) + }) + + // The sigil is dropped before the directory is asked (`handleQuery`), so it has to be dropped + // before the answer is matched: the app draws this list, and a `` comparing options with + // the field's literal `@ali` was why that spelling could receive suggestions and show none. + it('ignores a leading sigil, exactly as the ask does', () => { + expect(visibleSuggestions(merged, '@ali')).toEqual(['alice.test', 'alicia.example']) + expect(visibleSuggestions(merged, '@@Alice.TEST')).toEqual([]) + }) + + it('never offers somebody the word they have just finished typing', () => { + expect(visibleSuggestions(merged, 'alice.test')).toEqual([]) + expect(visibleSuggestions(['alice.test', 'alice.test.example'], 'alice.test')).toEqual([ + 'alice.test.example', + ]) }) }) diff --git a/packages/ui/src/lib/components/Account.svelte b/packages/ui/src/lib/components/Account.svelte --- a/packages/ui/src/lib/components/Account.svelte +++ b/packages/ui/src/lib/components/Account.svelte @@ -2,10 +2,10 @@ 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' + import SignInField from './SignInField.svelte' // Reading a space needs no account, so this stays out of the way until it has something to say: // one word signed out, the human's own disc signed in. Nothing about it is a profile — a DID and a @@ -23,22 +23,6 @@ let identifier = $state('') 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. @@ -167,39 +151,19 @@ 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. + `handle-suggest.svelte.ts` asks as the prefix is typed. `SignInField` is both, and the whole + of the field: the picker draws the same one, with its own `id` because the two can be on + screen at once. -->
- - {#if typeahead.enabled} - - {/if} +
+ {/each} +
+ +{#if typeahead.enabled} + +{/if} diff --git a/packages/ui/src/lib/components/SpacePicker.svelte b/packages/ui/src/lib/components/SpacePicker.svelte --- a/packages/ui/src/lib/components/SpacePicker.svelte +++ b/packages/ui/src/lib/components/SpacePicker.svelte @@ -15,7 +15,6 @@ import { goto } from '$app/navigation' import { account, rememberedIdentities, signIn, signOut } from '$lib/auth.svelte.js' import { unknownActor } from '$lib/directory.js' - import { HandleTypeahead, mergeSuggestions } from '$lib/handle-suggest.svelte.js' import { discoverOwned, forgetOwned, owned } from '$lib/owned.svelte.js' import { forget, leave, openFixture, openSpace, session } from '$lib/session.svelte.js' import { selectSpace } from '$lib/space-navigation.js' @@ -24,6 +23,7 @@ import Glyph from './Glyph.svelte' import JoinPrivate from './JoinPrivate.svelte' import NewSpace from './NewSpace.svelte' + import SignInField from './SignInField.svelte' const { overlay = false }: { overlay?: boolean } = $props() @@ -32,20 +32,6 @@ let byTicket = $state(false) let creating = $state(false) let field = $state(null) - - // The same two sources the account popover's field offers, held per surface — see - // `handle-suggest.svelte.ts` for why the directory is asked at all, and what is never sent to it. - const typeahead = new HandleTypeahead() - const suggestions = $derived( - mergeSuggestions( - rememberedIdentities.map((identity) => identity.handle ?? identity.did), - typeahead.handles, - ), - ) - $effect(() => { - void typeahead.suggest(identifier) - }) - $effect(() => () => typeahead.dispose()) const busy = $derived(session.status === 'opening') const signedIn = $derived(account.status === 'signed-in') @@ -136,36 +122,18 @@ {/if} - + - - {#if typeahead.enabled} - - {/if} +