From 1e6002f57baf1e8c741955b38a3a7f1f29e4a709 Mon Sep 17 00:00:00 2001 From: Adam Urpsis Date: Tue, 4 Aug 2026 15:13:37 -0600 Subject: [PATCH] Offer the free-key link inline with the API-key sheet's rationale A keyless user reading "Blurt needs an AssemblyAI API key" now finds "Don't have a key? Get a free one" in the same breath, as a text link, instead of having to spot the "Get a Free Key" push button down in the action row. The link opens the dashboard's key page and leaves the sheet open behind the browser, ready for the paste; it stays hidden once a key is stored. The button row is now a plain Cancel / default-action pair. Co-Authored-By: Claude Fable 5 (Jeeves) --- .../Blurt/Wizard/Steps/APIKeyStepView.swift | 36 ++++++++++++------- App/Blurt/BlurtUITests/SettingsUITests.swift | 20 +++++++++++ 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/App/Blurt/Blurt/Wizard/Steps/APIKeyStepView.swift b/App/Blurt/Blurt/Wizard/Steps/APIKeyStepView.swift index 2f82321..01e4488 100644 --- a/App/Blurt/Blurt/Wizard/Steps/APIKeyStepView.swift +++ b/App/Blurt/Blurt/Wizard/Steps/APIKeyStepView.swift @@ -100,10 +100,11 @@ struct APIKeyStepView: View { /// a bare glyph toggle, and a footer that swaps the storage reassurance for a /// specific error. /// -/// Button layout follows the sheet convention: the non-dismissing secondary -/// action sits at the leading edge, clear of the Cancel / default-action pair at -/// the trailing edge, and Return / Escape are scoped to the sheet rather than -/// to the whole window. +/// Button layout follows the sheet convention: the Cancel / default-action pair +/// sits at the trailing edge, and Return / Escape are scoped to the sheet rather +/// than to the whole window. A user with no key gets a link with the rationale +/// text ("Don't have a key?") rather than a third button here — following it is +/// reading-flow navigation, not one of the sheet's commit-or-cancel actions. private struct APIKeyEditorSheet: View { var apiKey: APIKeyModel /// The key already stored, empty on first run. Drives the first-connect vs. @@ -176,6 +177,25 @@ private struct APIKeyEditorSheet: View { Text(display.rationale) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) + // The answer to "where do I get one?" sits with the sentence that + // raises the question, phrased as the question a keyless user is + // asking. Link-styled because it reads as part of the rationale, not + // as one of the sheet's actions: it opens the dashboard's key page in + // the browser and leaves the sheet open behind it, ready for the + // paste. Hidden once a key exists — that user has already found the + // dashboard, and the rotate wording above no longer asks the question. + if !display.isConnected { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("Don’t have a key?") + .foregroundStyle(.secondary) + Button("Get a free one") { openURL(APIKeyStore.dashboardURL) } + .buttonStyle(.link) + // "one" has no referent when VoiceOver reads the button alone + // (rotor / Tab navigation skips the static text beside it). + .accessibilityLabel("Get a free API key") + .accessibilityIdentifier(UITestIdentifiers.apiKeyGetKey) + } + } } // A single credential field reads as more native left un-labelled and @@ -254,14 +274,6 @@ private struct APIKeyEditorSheet: View { private var buttonRow: some View { HStack(spacing: 12) { - // The one action a user with no key can actually take, as a real button - // rather than caption-sized footer text. It doesn't dismiss the sheet, so - // it sits at the leading edge, away from Cancel / the default action — - // and the sheet stays open behind the browser, ready for the paste. - if !display.isConnected { - Button("Get a Free Key") { openURL(APIKeyStore.dashboardURL) } - .accessibilityIdentifier(UITestIdentifiers.apiKeyGetKey) - } Spacer(minLength: 12) if isValidating { ProgressView().controlSize(.small) diff --git a/App/Blurt/BlurtUITests/SettingsUITests.swift b/App/Blurt/BlurtUITests/SettingsUITests.swift index fbd8bba..2b7d995 100644 --- a/App/Blurt/BlurtUITests/SettingsUITests.swift +++ b/App/Blurt/BlurtUITests/SettingsUITests.swift @@ -137,6 +137,26 @@ final class SettingsUITests: BlurtUITestCase { "Change… should re-open the sheet on the key field") } + /// The first-connect sheet carries the "Don't have a key?" link; once a key + /// is stored, the reopened sheet drops it. + func testGetKeyLinkShownOnlyBeforeFirstConnect() { + let settings = openSettingsWindow() + let sheet = openKeyEditor(settings) + + XCTAssertTrue( + sheet.buttons[UITestIdentifiers.apiKeyGetKey].waitForExistence(timeout: 5), + "The first-connect sheet should offer the get-a-free-key link") + sheet.buttons[UITestIdentifiers.apiKeyCancel].click() + XCTAssertTrue(sheet.waitForNonExistence(timeout: 5), "Cancel should dismiss the sheet") + + connectValidKey(settings) + + let reopened = openKeyEditor(settings, via: UITestIdentifiers.apiKeyChange) + XCTAssertFalse( + reopened.buttons[UITestIdentifiers.apiKeyGetKey].exists, + "A stored key means the user has found the dashboard — no link on rotate") + } + /// "Cancel" in the sheet discards the edit and leaves the stored key's row /// untouched instead of committing. func testCancelDiscardsKeyEditAndKeepsRow() {