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:
parent
2a5aa5e213
commit
b5f8623761
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue