feat(onboarding): the API key field is the same card as the sign-in (#12820)

Flipping "Use API key instead" changed the shape of the connect step
rather than its content: #12801 redrew the sign-in as a borderless
12px-radius card with 44px rows, and the key field stayed a bordered
`rounded-md` box with a 28px control tucked to the right. Two visual
languages in one canvas, one toggle apart.

The field's own note already argued against exactly that — the two are
alternatives to one question and have to read as two answers, not two
kinds of thing. The reasoning held; only its target moved, and matching
by restating measurements is what let it drift.

It composes `OnboardingLoginCard` and the shared row input now, so there
is nothing left to keep in sync. The environment variable takes the slot
the sign-in cards use for their sentence, in mono, still answering what a
paster cannot answer for themselves: where this step will put the key.

Supporting changes: `instruction` widens to `ReactNode`, and the row
input's classes move to an exported `onboardingCardInputClass`.

The tests pin the sharing rather than the appearance — the two inputs
carry the byte-identical class string, and the key field's shell is the
same element the sign-in renders. Both fail against the old bordered box.
This commit is contained in:
Tonio 2026-09-04 00:00:39 -07:00 committed by GitHub
parent 2a5aa5e213
commit b5f8623761
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 159 additions and 39 deletions

View File

@ -0,0 +1,104 @@
// @vitest-environment jsdom
import { createRoot, type Root } from "react-dom/client";
import { flushSync } from "react-dom";
import { afterEach, describe, expect, it } from "vitest";
import {
OnboardingLoginCard,
OnboardingLoginCodeInput,
onboardingCardInputClass,
} from "./AdapterLoginChrome";
import { ApiKeyField } from "./onboarding/ConnectInputCanvas";
/**
* The connect step's canvas holds one of two cards, and the credential switch
* above trades between them. They are two answers to one question, so they have
* to be built the same way and the last time they were only *matched*, by
* restating each other's measurements, they drifted the moment one was redrawn.
*
* These tests pin the sharing rather than the appearance. A colour or a radius
* is the design's to change; what must not change is that both cards get it
* from the same declaration.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
let roots: Root[] = [];
afterEach(async () => {
for (const root of roots) {
await act(async () => root.unmount());
}
roots = [];
document.body.innerHTML = "";
});
async function render(node: React.ReactNode): Promise<HTMLElement> {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
await act(async () => root.render(node));
return container;
}
describe("the connect step's two cards", () => {
it("gives the key field and the sign-in field the same input, from one declaration", async () => {
// The assertion that would have caught the drift this file exists for: not
// "both look like X", which passes right up until one of them is restyled,
// but that the two carry the byte-identical class string.
const signIn = await render(
<OnboardingLoginCard instruction="Open Claude link then come back and enter code">
<OnboardingLoginCodeInput value="" onChange={() => {}} onSubmit={() => {}} />
</OnboardingLoginCard>,
);
const keyCard = await render(
<ApiKeyField envKey="ANTHROPIC_API_KEY" value="" onChange={() => {}} />,
);
const codeInput = signIn.querySelector("input")!;
const keyInput = keyCard.querySelector("input")!;
expect(codeInput.className).toBe(onboardingCardInputClass);
expect(keyInput.className).toBe(codeInput.className);
});
it("wraps the key field in the same card shell as the sign-in", async () => {
// The shell, not just the input. The key field used to draw its own
// bordered box, so flipping the credential switch changed the shape of the
// step rather than its content.
const signIn = await render(
<OnboardingLoginCard instruction="Open Claude link then come back and enter code">
<OnboardingLoginCodeInput value="" onChange={() => {}} onSubmit={() => {}} />
</OnboardingLoginCard>,
);
const keyCard = await render(
<ApiKeyField envKey="ANTHROPIC_API_KEY" value="" onChange={() => {}} />,
);
expect(keyCard.firstElementChild!.className).toBe(signIn.firstElementChild!.className);
});
it("labels the key field with the variable it will be written to", async () => {
// The name answers what a paster cannot answer for themselves — where this
// step puts the key — so it is the label rather than a sentence about it,
// and it reaches assistive tech as the field's name too.
const keyCard = await render(
<ApiKeyField envKey="ANTHROPIC_API_KEY" value="" onChange={() => {}} />,
);
expect(keyCard.textContent).toContain("ANTHROPIC_API_KEY");
expect(keyCard.querySelector("input")!.getAttribute("aria-label")).toBe("ANTHROPIC_API_KEY");
// Still a password field: the key is a secret even while being pasted.
expect(keyCard.querySelector("input")!.getAttribute("type")).toBe("password");
});
});

View File

@ -35,7 +35,12 @@ export function OnboardingLoginCard({
onCancel,
children,
}: {
instruction: string;
/**
* A node rather than a string: the sign-in cards pass a sentence, and the
* key card passes the environment variable it will write to, which has to
* be mono to read as a name rather than as prose.
*/
instruction: ReactNode;
onCancel?: () => void;
children: ReactNode;
}) {
@ -201,6 +206,19 @@ export function OnboardingLoginCodeRow({ code }: { code: string }) {
);
}
/**
* One row's worth of input, shared by every card that takes one.
*
* Exported rather than duplicated because the two inputs that use it the
* browser code here and the API key on the credential card sit in the same
* canvas one toggle apart, so a divergence between them is visible by flipping
* a switch. They differ in what they hold, not in what they look like.
*/
export const onboardingCardInputClass =
"h-(--sz-44px) w-full rounded-lg bg-muted px-5 font-mono text-xs text-foreground " +
"placeholder:font-sans placeholder:text-sm placeholder:text-muted-foreground " +
"outline-none focus-visible:ring-ring/50 focus-visible:ring-(length:--rad-3)";
/**
* The field the browser code is pasted back into.
*
@ -245,7 +263,7 @@ export function OnboardingLoginCodeInput({
onSubmit();
}
}}
className="h-(--sz-44px) w-full rounded-lg bg-muted px-5 font-mono text-xs text-foreground placeholder:font-sans placeholder:text-sm placeholder:text-muted-foreground outline-none focus-visible:ring-ring/50 focus-visible:ring-(length:--rad-3)"
className={onboardingCardInputClass}
/>
);
}

View File

@ -1,7 +1,10 @@
import { useLayoutEffect, useRef, type ReactNode } from "react";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "../../lib/utils";
import {
OnboardingLoginCard,
onboardingCardInputClass,
} from "../AdapterLoginChrome";
import {
CANVAS_CONTENT_ENTER,
CANVAS_ENTER_TRAVEL,
@ -105,20 +108,24 @@ export function ConnectInputCanvas({
* The API key field, for when the credential mode is keys rather than a
* subscription.
*
* Built to the login panel's shape on purpose: same card, same padding, same
* label-left / control-right row, same 28px control height. These two are
* alternatives to each other one canvas shows one or the other, and the
* credential switch above trades between them so they should read as two
* answers to one question rather than as two different kinds of thing. Before
* this the key field was a stacked label over a full-width input with no card
* at all, and flipping the mode changed the shape of the step rather than its
* content.
* Built to the sign-in card's shape on purpose, and that reasoning is
* unchanged from when it was written only its target moved. These two are
* alternatives to each other: one canvas shows one or the other and the
* credential switch above trades between them, so they have to read as two
* answers to one question rather than as two different kinds of thing. It was
* matched to the old bordered panel; the connect step's sign-in became a
* borderless card with 44px rows, and this stayed behind, so flipping the
* toggle changed the shape of the step rather than its content the exact
* failure the original note was written to prevent.
*
* It now composes the same primitives rather than restating their measurements,
* which is what keeps that from happening again.
*
* The variable name is the label rather than a sentence about it. Someone
* pasting a key knows which one they are holding; what they cannot know is where
* this step will put it, and the name answers that in the place it is asked
* while staying short enough to sit opposite the field the way "Sign in to the
* environment" sits opposite its button.
* pasting a key knows which one they are holding; what they cannot know is
* where this step will put it, and the name answers that in the place it is
* asked. It takes the instruction slot the sign-in cards use for their
* sentence, in mono, because it is a name and not prose.
*/
export function ApiKeyField({
envKey,
@ -139,29 +146,20 @@ export function ApiKeyField({
}, []);
return (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
<label className="flex items-center justify-between gap-3">
<span className="font-mono text-xs font-medium text-foreground">
{envKey}
</span>
<input
ref={inputRef}
type="password"
autoComplete="off"
spellCheck={false}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder="Paste your key"
// `h-7` is the login button's height, so the two states put their
// control on the same line and the card does not change depth when the
// mode is flipped.
className={cn(
"h-7 w-(--sz-220px) shrink-0 rounded-md border border-border bg-background px-2",
"font-mono text-xs outline-none placeholder:font-sans",
"focus-visible:ring-ring/50 focus-visible:ring-(length:--rad-3)",
)}
/>
</label>
</div>
<OnboardingLoginCard
instruction={<span className="font-mono">{envKey}</span>}
>
<input
ref={inputRef}
aria-label={envKey}
type="password"
autoComplete="off"
spellCheck={false}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder="Paste your key"
className={onboardingCardInputClass}
/>
</OnboardingLoginCard>
);
}