···11+import { useEffect, useRef, useState } from 'react';
22+import { createOAuthClient } from '../lib/auth/oauth';
33+import { isValidAccountInput, isValidHandleOrDid, normalizeHandle } from '../lib/auth/config';
44+import { lookupActor, type ActorPreview } from '../lib/landing/actor-lookup';
55+66+/**
77+ * The landing page's primary CTA (client-only). As you type a handle it debounces and
88+ * looks you up on the public Bluesky AppView, showing a confirmation card. Submitting
99+ * hands the raw value to the existing OAuth flow — Start ALWAYS works; the lookup is
1010+ * delightful reassurance, never a gate (Decision: brainstorm 2026-06-09).
1111+ */
1212+export default function HandleStart() {
1313+ const [ value, setValue ] = useState( '' );
1414+ const [ preview, setPreview ] = useState< ActorPreview | null >( null );
1515+ const [ looking, setLooking ] = useState( false );
1616+ const [ error, setError ] = useState< string | null >( null );
1717+ const [ avatarOk, setAvatarOk ] = useState( true );
1818+ const reqId = useRef( 0 );
1919+2020+ // Debounced live lookup. Each keystroke cancels the pending timer; a request id
2121+ // guards against out-of-order responses overwriting a newer query.
2222+ useEffect( () => {
2323+ const trimmed = value.trim();
2424+ if ( ! isValidHandleOrDid( trimmed ) ) {
2525+ setPreview( null );
2626+ setLooking( false );
2727+ return;
2828+ }
2929+ const id = ++reqId.current;
3030+ setLooking( true );
3131+ const timer = setTimeout( async () => {
3232+ const found = await lookupActor( trimmed );
3333+ if ( id === reqId.current ) {
3434+ setPreview( found );
3535+ setAvatarOk( true );
3636+ setLooking( false );
3737+ }
3838+ }, 350 );
3939+ return () => clearTimeout( timer );
4040+ }, [ value ] );
4141+4242+ async function onSubmit( event: React.FormEvent ) {
4343+ event.preventDefault();
4444+ if ( ! isValidAccountInput( value ) ) {
4545+ setError( 'Enter a handle (alice.bsky.social), a DID, or a PDS URL.' );
4646+ return;
4747+ }
4848+ setError( null );
4949+ try {
5050+ const client = await createOAuthClient();
5151+ // Redirects to the authorization server; the promise never resolves.
5252+ await client.signIn( normalizeHandle( value ) );
5353+ } catch ( err ) {
5454+ setError( err instanceof Error ? err.message : String( err ) );
5555+ }
5656+ }
5757+5858+ const name = preview?.displayName ?? preview?.handle ?? null;
5959+6060+ return (
6161+ <form className="handlestart" onSubmit={ onSubmit }>
6262+ <div className="handlestart__row">
6363+ <div className="handlestart__field">
6464+ <span className="handlestart__at" aria-hidden="true">@</span>
6565+ <input
6666+ className="handlestart__input"
6767+ name="handle"
6868+ autoComplete="username"
6969+ autoCapitalize="none"
7070+ autoCorrect="off"
7171+ spellCheck={ false }
7272+ placeholder="you.bsky.social"
7373+ aria-label="Your handle, DID, or PDS URL"
7474+ value={ value }
7575+ onChange={ ( e ) => setValue( e.target.value ) }
7676+ />
7777+ { looking && <span className="handlestart__spinner" aria-hidden="true" /> }
7878+ </div>
7979+ <button className="handlestart__go" type="submit">Start →</button>
8080+ </div>
8181+8282+ { preview && (
8383+ <div className="handlestart__card">
8484+ { preview.avatar && avatarOk ? (
8585+ <img
8686+ className="handlestart__avatar"
8787+ src={ preview.avatar }
8888+ alt=""
8989+ width={ 36 }
9090+ height={ 36 }
9191+ onError={ () => setAvatarOk( false ) }
9292+ />
9393+ ) : (
9494+ <span className="handlestart__avatar handlestart__avatar--fallback" aria-hidden="true">
9595+ { ( name ?? '?' ).charAt( 0 ).toUpperCase() }
9696+ </span>
9797+ ) }
9898+ <span className="handlestart__who">
9999+ <span className="handlestart__name">{ name }</span>
100100+ <span className="handlestart__handle">@{ preview.handle }</span>
101101+ </span>
102102+ <span className="handlestart__check" aria-hidden="true">✓</span>
103103+ </div>
104104+ ) }
105105+106106+ { error ? (
107107+ <p className="handlestart__hint handlestart__hint--error" role="alert">{ error }</p>
108108+ ) : (
109109+ <p className="handlestart__hint">Your handle, DID, or PDS — whatever you’ve got.</p>
110110+ ) }
111111+ </form>
112112+ );
113113+}
+38
src/components/HandleStart.wiring.test.ts
···11+/**
22+ * Source-level wiring guard for the landing handle input. The component can't be
33+ * rendered through vitest (jsdom is pinned for the WordPress block suites), so we pin
44+ * the wiring at the source level — the same strategy as src/pages/index.phase.test.ts.
55+ */
66+import { readFileSync } from 'node:fs';
77+import { dirname, join } from 'node:path';
88+import { fileURLToPath } from 'node:url';
99+import { describe, expect, it } from 'vitest';
1010+1111+// Resolve the sibling .tsx via a plain path join. `new URL( './HandleStart.tsx',
1212+// import.meta.url )` can't be used here: vite rewrites a .tsx module URL to the dev
1313+// server (http://localhost…), which fileURLToPath rejects. The .astro-reading guard in
1414+// src/pages/index.phase.test.ts avoids this because vite leaves .astro URLs as file://.
1515+const src = readFileSync(
1616+ join( dirname( fileURLToPath( import.meta.url ) ), 'HandleStart.tsx' ),
1717+ 'utf8'
1818+);
1919+2020+describe( 'HandleStart wiring', () => {
2121+ it( 'looks the writer up via the public-AppView helper', () => {
2222+ expect( src ).toMatch( /import \{ lookupActor.*\} from '\.\.\/lib\/landing\/actor-lookup'/ );
2323+ } );
2424+2525+ it( 'debounces the lookup with a timer', () => {
2626+ expect( src ).toMatch( /setTimeout\(/ );
2727+ expect( src ).toMatch( /clearTimeout\(/ );
2828+ } );
2929+3030+ it( 'starts sign-in through the existing OAuth client', () => {
3131+ expect( src ).toMatch( /createOAuthClient/ );
3232+ expect( src ).toMatch( /\.signIn\(\s*normalizeHandle\(/ );
3333+ } );
3434+3535+ it( 'guards submission with isValidAccountInput so Start fails closed only on garbage', () => {
3636+ expect( src ).toMatch( /isValidAccountInput\(/ );
3737+ } );
3838+} );