refactor(desktop): migrate api-key settings onto the design system
D16 of the design-system migration (Desktop stack, base feature/desktop-d15-account-settings, #3387).
What
- Settings › Coding agents › API keys renders through
@luke/ui'sSettingsSectionandSettingsRow, replacing the desktopSettingsGroupand the hand-built.settings-copymarkup. Each provider's row leads with its mark through the row'sleadingslot. MenuRowgains arowform (desktop adapter, not a shared API): given{ label, description, leading, control }it draws the sharedSettingsRow, with the control and the ⋯ at the row's end. The search anchor and right-click stay on the wrapper, as before. Its other callers (Workspace, Integrations, voice keys) passchildrenand keep their current markup until their own D rows move them.- Add key and Cancel are the barrel
Button(outline), Save the barrelButton(primary,type="submit",busywith "Checking the key"). They replacetoolbar-button bordered-buttonandprimary-button. - The key field is the barrel
TextField(PASSWORD). It's now controlled: the pasted key sits in the row's state while the field is open and is cleared once the service keeps it, or when the field is cancelled. It was previously read from the DOM. The key is still sent once, never shown, and gone from the page once kept (existing test). - New shared prop (approved by the orchestrator):
TextField'sautoComplete?: TextFieldAutoCompletewithTEXT_FIELD_AUTO_COMPLETE = { OFF: "off" }, so the credential field keepsautocomplete="off"on the input itself. It has a catalog line (the text fields example's API key field) and a jsdom test. - Escape in the field still cancels the editor without leaving Settings, and a composition's Escape is still the IME's. The handler now sits on the form and answers only an Escape from the field.
- The whole group stays inside
ph-no-capture. The field no longer carries the class itself (barrel controls take none) but inherits it from the wrapper, which PostHog applies to the wrapper's children too. - Save, replace (Edit key), remove (confirm dialog), the one-change-at-a-time lock, the refusal under the field (
role="alert") and the read failure/Retry are unchanged.
Selectors
- Removed:
.api-key-form .settings-inputand the.api-key-formbranch of.settings-card > … + .error-message. These rules are now container rules on the new.api-keywrapper (.api-key-form,.api-key > .error-message) on the contract's role tokens. The narrow-container rule keeps the field on its own line. .settings-copy .settings-state[data-state="attention"]is now.settings-state[data-state="attention"], so the attention tint reaches a shared row's description. Every othersettings-statesits inside.settings-copy, so nothing else changes.
Visible changes
- The provider name uses the label role (13/18, 500) instead of
strong. The status line uses the caption role in the subtle colour (12/16), like other migrated rows' descriptions. - Buttons and the field take the barrel looks at the same 30px height and 8px radius.
- Save's busy ring shows as soon as the save goes out, as the barrel
Button's busy state does. The old version showed it after 180ms. - Below a 380px row, the ⋯ (or Add key) drops below the words to the row's end, as the shared row contract requires. The old row kept it beside the words.
Tests
text-field.test.ts: new jsdom test. A field asked to is left out of autofill (autocompleteisoff), and one not asked to carries no attribute.api-keys-section.test.ts/coding-agents-page.test.ts: the pasted key is typed through the input's own value setter, as typing does, because the field is now controlled. The row is found by its name's text instead of the retiredstrong. The recording check now asserts the field is inside the unrecorded wrapper. Every other assertion is unchanged, includingautocomplete === "off".pnpm check:affectedexits 0.
Evidence
Captured on Linux with headless Chrome at 1280×840. A throwaway, uncommitted harness mounted the real ApiKeysSection in SettingsPage with the built desktop stylesheet, over a fake bridge with synthetic keys (sk-ant-…wxyz, sk-proj-…k3q9).
| Light | Dark | |
|---|---|---|
| Kept keys: verified, and out of quota | ![]() | ![]() |
| Adding a key, save refused | ![]() | ![]() |
Page narrowed to 380px (rows under 380px): the field takes its own line, with Cancel and Save below it.

Not verified on a Mac
- SF Pro rendering of the rows, the marks and the hint's code face
- The real page against a live account: save, replace and remove were exercised only through the tests and the harness
- Whether the system's password autofill stays away from the field
🤖 Generated with Claude Code
Summary by cubic
Moves Settings › Coding agents › API keys onto @luke/ui's design system: providers render as shared SettingsRows led by their marks, and Add key, Cancel, Save, and the key field use the barrel Button and TextField instead of the desktop's own controls. Save, replace, remove, the refusal under the field, and Escape-in-field cancellation still behave as before.
MenuRow gains a row form that draws the shared SettingsRow with the control and ⋯ at the row's end; its other callers keep their existing markup. The key field is now controlled — the pasted key lives in row state and clears once saved or cancelled — and stays out of autofill via a new TextField prop (TEXT_FIELD_AUTO_COMPLETE), which has a catalog line and a jsdom test.
Visible changes
- The provider name and status line use the shared label and caption roles instead of
strong. - Buttons and the field take the barrel looks at 30px height and 8px radius; Save's busy ring appears immediately rather than after 180ms.



