diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fc44d30 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,100 @@ +name: Release + +on: + push: + branches: [main] + +permissions: + contents: write + +jobs: + release: + name: Build and release + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - uses: swift-actions/setup-swift@v3 + - name: Get swift version + run: swift --version # Swift 6.2 + + - name: Build (release) + run: swift build -c release + + - name: Create app bundle + run: | + mkdir -p Wisp.app/Contents/MacOS + mkdir -p Wisp.app/Contents/Resources + cp .build/release/Wisp Wisp.app/Contents/MacOS/ + cp Support/Info.plist Wisp.app/Contents/ + + # --------------------------------------------------------------------------- + # Optional signing — only runs when the MACOS_CERTIFICATE secret is set. + # To enable: + # 1. Export your Developer ID Application certificate as a .p12 file + # 2. Base64-encode it: base64 -i cert.p12 | pbcopy + # 3. Add GitHub secrets: + # MACOS_CERTIFICATE — base64-encoded .p12 + # MACOS_CERTIFICATE_PWD — .p12 export password + # MACOS_SIGNING_IDENTITY — e.g. "Developer ID Application: Your Name (TEAMID)" + # --------------------------------------------------------------------------- + - name: Import signing certificate + if: ${{ secrets.MACOS_CERTIFICATE != '' }} + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} + run: | + KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain" + KEYCHAIN_PWD="$(openssl rand -base64 32)" + security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" + + echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/certificate.p12" + security import "$RUNNER_TEMP/certificate.p12" \ + -k "$KEYCHAIN_PATH" \ + -P "$MACOS_CERTIFICATE_PWD" \ + -T /usr/bin/codesign + security list-keychain -d user -s "$KEYCHAIN_PATH" + security set-key-partition-list \ + -S apple-tool:,apple: -s -k "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" + + - name: Sign app bundle + if: ${{ secrets.MACOS_CERTIFICATE != '' }} + env: + MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + run: | + codesign \ + --deep --force --verify --verbose \ + --sign "$MACOS_SIGNING_IDENTITY" \ + --entitlements Wisp.entitlements \ + --options runtime \ + Wisp.app + + - name: Create DMG + run: | + hdiutil create \ + -volname "Wisp" \ + -srcfolder Wisp.app \ + -ov -format UDZO \ + Wisp.dmg + + - name: Create PKG (for Jamf / MDM deployment) + run: | + pkgbuild \ + --component Wisp.app \ + --install-location /Applications \ + Wisp.pkg + + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION="$(date +%Y-%m-%d)-${GITHUB_SHA::7}" + gh release create "$VERSION" \ + --title "Wisp $VERSION" \ + --notes "Built from [\`${GITHUB_SHA::7}\`]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA)" \ + --latest \ + Wisp.dmg \ + Wisp.pkg diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..404c727 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Run tests + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - uses: swift-actions/setup-swift@v3 + - name: Get swift version + run: swift --version # Swift 6.2 + + - name: Run tests + run: swift test diff --git a/CLAUDE.md b/CLAUDE.md index fb328ee..2cfde01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ Auto-generated from all feature plans. Last updated: 2026-03-28 - Swift 6.2 with strict concurrency checking + AppKit (NSPanel, Core Animation), WhisperKit (existing) (002-status-indicator-gui) - Swift 6.2 with strict concurrency checking enabled + WhisperKit (existing), KeyboardShortcuts 2.x (Sindre Sorhus — re-add to Package.swift), AVFoundation (existing), CoreAudio (system framework), Apple FoundationModels (existing), AppKit + SwiftUI (003-config-screen) - UserDefaults — two explicit keys (`selectedMicrophoneUID`, `cleanupPrompt`); hotkey managed automatically by KeyboardShortcuts library (003-config-screen) +- Swift 6.1+ with strict concurrency checking enabled + AppKit (NSWindow, NSMenu), SwiftUI (List, Button), Foundation (Codable, JSONEncoder/Decoder, FileManager) (004-transcription-log) +- JSON file — `~/Library/Application Support/Wisp/transcription-log.json` (004-transcription-log) - Swift 5.9+ with strict concurrency checking + WhisperKit (Argmax), KeyboardShortcuts (Sindre Sorhus), AppKi (001-core-dictation-flow) @@ -25,10 +27,10 @@ tests/ Swift 5.9+ with strict concurrency checking: Follow standard conventions ## Recent Changes +- 004-transcription-log: Added Swift 6.1+ with strict concurrency checking enabled + AppKit (NSWindow, NSMenu), SwiftUI (List, Button), Foundation (Codable, JSONEncoder/Decoder, FileManager) - 003-config-screen: Added Swift 6.2 with strict concurrency checking enabled + WhisperKit (existing), KeyboardShortcuts 2.x (Sindre Sorhus — re-add to Package.swift), AVFoundation (existing), CoreAudio (system framework), Apple FoundationModels (existing), AppKit + SwiftUI - 002-status-indicator-gui: Added Swift 6.2 with strict concurrency checking + AppKit (NSPanel, Core Animation), WhisperKit (existing) -- 001-core-dictation-flow: Added Swift 5.9+ with strict concurrency checking + WhisperKit (Argmax), KeyboardShortcuts (Sindre Sorhus), AppKi diff --git a/Support/Info.plist b/Support/Info.plist new file mode 100644 index 0000000..362182f --- /dev/null +++ b/Support/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleExecutable + Wisp + CFBundleIdentifier + com.wisp.Wisp + CFBundleName + Wisp + CFBundleDisplayName + Wisp + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + CFBundlePackageType + APPL + LSUIElement + + NSMicrophoneUsageDescription + Wisp needs microphone access to transcribe your speech. + NSAccessibilityUsageDescription + Wisp needs Accessibility access to paste transcribed text into the active text field. + NSPrincipalClass + NSApplication + + diff --git a/Wisp.entitlements b/Wisp.entitlements new file mode 100644 index 0000000..7563b65 --- /dev/null +++ b/Wisp.entitlements @@ -0,0 +1,15 @@ + + + + + + com.apple.security.device.audio-input + + + com.apple.security.temporary-exception.accessibility + + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/Wisp/App/AppDelegate.swift b/Wisp/App/AppDelegate.swift index eca62a6..7dac880 100644 --- a/Wisp/App/AppDelegate.swift +++ b/Wisp/App/AppDelegate.swift @@ -19,6 +19,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var menuBarController: MenuBarController? private var notificationService: NotificationService? private var overlayWindow: StatusOverlayWindow? + private var logStore = TranscriptionLogStore() + private var logWindow: LogWindow? func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) @@ -48,6 +50,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { keyEquivalent: "," ) ) + menu.addItem( + NSMenuItem( + title: "Show Log", + action: #selector(showLog), + keyEquivalent: "" + ) + ) menu.addItem(.separator()) menu.addItem( NSMenuItem( @@ -59,6 +68,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { statusItem?.menu = menu } + @objc private func showLog() { + if logWindow == nil { + logWindow = LogWindow() + } + logWindow?.show(entries: logStore.entries) + } + @objc private func openPreferences() { guard let store = preferencesStore, let mics = microphoneList else { return } PreferencesWindow.show(preferences: store, microphoneList: mics) @@ -294,8 +310,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { currentSession = nil switch result { - case .completed: - break + case .completed(let text): + logStore.append(text: text) case .discarded(let reason): switch reason { case .tooShort: diff --git a/Wisp/Models/TranscriptionLogEntry.swift b/Wisp/Models/TranscriptionLogEntry.swift new file mode 100644 index 0000000..8c3bc21 --- /dev/null +++ b/Wisp/Models/TranscriptionLogEntry.swift @@ -0,0 +1,13 @@ +import Foundation + +struct TranscriptionLogEntry: Codable, Identifiable, Sendable { + let id: UUID + let text: String + let timestamp: Date + + init(id: UUID = UUID(), text: String, timestamp: Date = Date()) { + self.id = id + self.text = text + self.timestamp = timestamp + } +} diff --git a/Wisp/Models/TranscriptionLogStore.swift b/Wisp/Models/TranscriptionLogStore.swift new file mode 100644 index 0000000..c015074 --- /dev/null +++ b/Wisp/Models/TranscriptionLogStore.swift @@ -0,0 +1,54 @@ +import Foundation + +// Persists transcription history to: +// ~/Library/Application Support/Wisp/transcription-log.json + +@MainActor +final class TranscriptionLogStore { + + private(set) var entries: [TranscriptionLogEntry] = [] + private let url: URL + + static var storageURL: URL { + let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + return appSupport + .appendingPathComponent("Wisp", isDirectory: true) + .appendingPathComponent("transcription-log.json") + } + + init(url: URL = TranscriptionLogStore.storageURL) { + self.url = url + self.entries = Self.load(from: url) + } + + func append(text: String) { + let entry = TranscriptionLogEntry(text: text) + entries.insert(entry, at: 0) + if entries.count > 500 { + entries.removeLast() + } + save() + } + + // MARK: - Private + + private static func load(from url: URL) -> [TranscriptionLogEntry] { + guard let data = try? Data(contentsOf: url) else { return [] } + guard let decoded = try? JSONDecoder().decode([TranscriptionLogEntry].self, from: data) + else { return [] } + return decoded + } + + private func save() { + let directory = url.deletingLastPathComponent() + try? FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + guard let data = try? JSONEncoder().encode(entries) else { return } + try? data.write(to: url, options: .atomic) + } +} diff --git a/Wisp/Services/NotificationService.swift b/Wisp/Services/NotificationService.swift index edfc2f1..35dc2c1 100644 --- a/Wisp/Services/NotificationService.swift +++ b/Wisp/Services/NotificationService.swift @@ -9,19 +9,7 @@ final class NotificationService { alert.informativeText = message alert.alertStyle = .informational alert.addButton(withTitle: "OK") - - // Run non-modally so it doesn't block the app - let window = NSWindow( - contentRect: .zero, - styleMask: [], - backing: .buffered, - defer: true - ) - alert.beginSheetModal(for: window) - - // Auto-dismiss after 3 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - window.close() - } + NSApp.activate(ignoringOtherApps: true) + alert.runModal() } } diff --git a/Wisp/Services/PasteService.swift b/Wisp/Services/PasteService.swift index eca3d8b..7a27f40 100644 --- a/Wisp/Services/PasteService.swift +++ b/Wisp/Services/PasteService.swift @@ -1,4 +1,6 @@ import AppKit +import Carbon +import CoreGraphics @MainActor final class PasteService { @@ -7,32 +9,82 @@ final class PasteService { let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(text, forType: .string) - - // Mark as transient to avoid polluting clipboard managers pasteboard.setData( Data(), forType: NSPasteboard.PasteboardType("org.nspasteboard.TransientType") ) - // Use AppleScript via System Events to paste — works reliably - // even from bare binaries without a bundle - let script = NSAppleScript(source: """ - tell application "System Events" - keystroke "v" using command down - end tell - """) + guard AXIsProcessTrusted() else { + AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) + print("[Wisp] Accessibility not granted — falling back to clipboard") + completion(true) + return + } - // Small delay to ensure clipboard is settled DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - var error: NSDictionary? - script?.executeAndReturnError(&error) - if let error { - print("[Wisp] Paste failed: \(error)") + let source = CGEventSource(stateID: .hidSystemState) + // Look up the key that produces 'v' on the active layout so this + // works correctly on Dvorak and other non-QWERTY keyboards. + let vKey = Self.keyCode(for: "v") ?? 0x09 + + guard + let keyDown = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: true), + let keyUp = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: false) + else { completion(true) - } else { - print("[Wisp] Pasted via System Events") - completion(false) + return + } + + keyDown.flags = .maskCommand + keyUp.flags = .maskCommand + keyDown.post(tap: .cgAnnotatedSessionEventTap) + keyUp.post(tap: .cgAnnotatedSessionEventTap) + + print("[Wisp] Pasted via CGEvent (keyCode: \(vKey))") + completion(false) + } + } + + /// Returns the virtual key code that produces `character` on the current + /// keyboard layout, or nil if not found. + private static func keyCode(for character: Character) -> CGKeyCode? { + guard + let unmanagedSource = TISCopyCurrentKeyboardInputSource(), + let layoutDataPtr = TISGetInputSourceProperty( + unmanagedSource.takeRetainedValue(), + kTISPropertyUnicodeKeyLayoutData + ) + else { return nil } + + let layoutData = Unmanaged.fromOpaque(layoutDataPtr).takeUnretainedValue() + let keyboardLayout = unsafeBitCast( + CFDataGetBytePtr(layoutData), + to: UnsafePointer.self + ) + let target = UniChar(character.unicodeScalars.first!.value) + + for keyCode in 0 ..< 128 { + var deadKeyState: UInt32 = 0 + var output = [UniChar](repeating: 0, count: 4) + var outputLength = 0 + + UCKeyTranslate( + keyboardLayout, + UInt16(keyCode), + UInt16(kUCKeyActionDown), + 0, + UInt32(LMGetKbdType()), + UInt32(kUCKeyTranslateNoDeadKeysBit), + &deadKeyState, + 4, + &outputLength, + &output + ) + + if outputLength == 1 && output[0] == target { + return CGKeyCode(keyCode) } } + return nil } } diff --git a/Wisp/UI/LogView.swift b/Wisp/UI/LogView.swift new file mode 100644 index 0000000..93f9589 --- /dev/null +++ b/Wisp/UI/LogView.swift @@ -0,0 +1,62 @@ +import AppKit +import SwiftUI + +struct LogView: View { + + let entries: [TranscriptionLogEntry] + + static let timestampFormat: Date.FormatStyle = .dateTime + .month(.abbreviated) + .day() + .hour() + .minute() + + var body: some View { + if entries.isEmpty { + Text("No transcriptions yet.") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(entries) { entry in + LogEntryRow(entry: entry) + } + } + } +} + +private struct LogEntryRow: View { + + let entry: TranscriptionLogEntry + + var body: some View { + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + Text(entry.timestamp, format: LogView.timestampFormat) + .font(.caption) + .foregroundStyle(.secondary) + Text(entry.text) + .font(.body) + .textSelection(.enabled) + } + Spacer() + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(entry.text, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + .help("Copy to clipboard") + .onHover { hovering in + if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } + } + } + .padding(10) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary.opacity(0.25), lineWidth: 1) + ) + .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12)) + .listRowSeparator(.hidden) + } +} diff --git a/Wisp/UI/LogWindow.swift b/Wisp/UI/LogWindow.swift new file mode 100644 index 0000000..f7da5de --- /dev/null +++ b/Wisp/UI/LogWindow.swift @@ -0,0 +1,24 @@ +import AppKit +import SwiftUI + +@MainActor +final class LogWindow: NSWindow { + + init() { + super.init( + contentRect: NSRect(x: 0, y: 0, width: 560, height: 480), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) + title = "Transcription Log" + isReleasedWhenClosed = false + center() + } + + func show(entries: [TranscriptionLogEntry]) { + contentView = NSHostingView(rootView: LogView(entries: entries)) + makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } +} diff --git a/Wisp/UI/PreferencesWindow.swift b/Wisp/UI/PreferencesWindow.swift index 11ff17f..b587936 100644 --- a/Wisp/UI/PreferencesWindow.swift +++ b/Wisp/UI/PreferencesWindow.swift @@ -28,8 +28,8 @@ final class PreferencesWindow: NSWindowController { let window = NSWindow(contentViewController: hostingController) window.title = "Wisp Preferences" window.styleMask = [.titled, .closable, .miniaturizable, .resizable] - window.setContentSize(NSSize(width: 520, height: 420)) - window.minSize = NSSize(width: 480, height: 360) + window.setContentSize(NSSize(width: 520, height: 560)) + window.minSize = NSSize(width: 480, height: 520) window.center() window.isReleasedWhenClosed = false // Escape key closes the window without saving (promptDraft is local @State, diff --git a/WispTests/Unit/TranscriptionLogStoreTests.swift b/WispTests/Unit/TranscriptionLogStoreTests.swift new file mode 100644 index 0000000..739e8aa --- /dev/null +++ b/WispTests/Unit/TranscriptionLogStoreTests.swift @@ -0,0 +1,63 @@ +import XCTest +@testable import Wisp + +@MainActor +final class TranscriptionLogStoreTests: XCTestCase { + + private var testURL: URL! + + override func setUp() { + super.setUp() + testURL = FileManager.default.temporaryDirectory + .appendingPathComponent("wisp-test-log-\(UUID().uuidString).json") + } + + override func tearDown() { + try? FileManager.default.removeItem(at: testURL) + testURL = nil + super.tearDown() + } + + // MARK: - Tests + + func testAppend_addsEntry() { + let store = TranscriptionLogStore(url: testURL) + XCTAssertEqual(store.entries.count, 0) + store.append(text: "Hello world") + XCTAssertEqual(store.entries.count, 1) + XCTAssertEqual(store.entries[0].text, "Hello world") + } + + func testAppend_capsAt500_dropsOldest() { + let store = TranscriptionLogStore(url: testURL) + for i in 0..<500 { + store.append(text: "Entry \(i)") + } + XCTAssertEqual(store.entries.count, 500) + // Adding one more should drop the oldest entry + store.append(text: "Entry 500") + XCTAssertEqual(store.entries.count, 500) + // Entries are stored newest-first; oldest (Entry 0) should be gone + XCTAssertFalse(store.entries.contains(where: { $0.text == "Entry 0" })) + XCTAssertTrue(store.entries.contains(where: { $0.text == "Entry 500" })) + } + + func testPersistence_survivesReinit() { + let store1 = TranscriptionLogStore(url: testURL) + store1.append(text: "Persisted entry") + + // Create a new store pointing at the same file + let store2 = TranscriptionLogStore(url: testURL) + XCTAssertEqual(store2.entries.count, 1) + XCTAssertEqual(store2.entries[0].text, "Persisted entry") + } + + func testCorruptFile_startsEmpty() throws { + // Write corrupt JSON to the file + let corrupt = Data("not valid json {{{{".utf8) + try corrupt.write(to: testURL) + + let store = TranscriptionLogStore(url: testURL) + XCTAssertEqual(store.entries.count, 0) + } +} diff --git a/scripts/build-dist.sh b/scripts/build-dist.sh new file mode 100755 index 0000000..cf340af --- /dev/null +++ b/scripts/build-dist.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +APP_NAME="Wisp" +APP_BUNDLE="$APP_NAME.app" +DMG_NAME="$APP_NAME.dmg" +PKG_NAME="$APP_NAME.pkg" +BUILD_DIR="dist" + +# --------------------------------------------------------------------------- +# Optional: set SIGNING_IDENTITY to your Developer ID to sign the app. +# e.g. export SIGNING_IDENTITY="Developer ID Application: Your Name (TEAMID)" +# If unset, the app is built unsigned. +# --------------------------------------------------------------------------- + +echo "==> Building $APP_NAME (release)..." +swift build -c release + +echo "==> Creating app bundle..." +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR" + +rm -rf "$BUILD_DIR/$APP_BUNDLE" +mkdir -p "$BUILD_DIR/$APP_BUNDLE/Contents/MacOS" +mkdir -p "$BUILD_DIR/$APP_BUNDLE/Contents/Resources" + +cp ".build/release/$APP_NAME" "$BUILD_DIR/$APP_BUNDLE/Contents/MacOS/" +cp "Support/Info.plist" "$BUILD_DIR/$APP_BUNDLE/Contents/" + +if [[ -n "${SIGNING_IDENTITY:-}" ]]; then + echo "==> Signing with: $SIGNING_IDENTITY" + codesign \ + --deep --force --verify --verbose \ + --sign "$SIGNING_IDENTITY" \ + --entitlements "$REPO_ROOT/Wisp.entitlements" \ + --options runtime \ + "$BUILD_DIR/$APP_BUNDLE" +else + echo "==> Skipping signing (SIGNING_IDENTITY not set)" +fi + +echo "==> Creating $DMG_NAME..." +rm -f "$BUILD_DIR/$DMG_NAME" +hdiutil create \ + -volname "$APP_NAME" \ + -srcfolder "$BUILD_DIR/$APP_BUNDLE" \ + -ov -format UDZO \ + "$BUILD_DIR/$DMG_NAME" + +echo "==> Creating $PKG_NAME (for Jamf / MDM)..." +rm -f "$BUILD_DIR/$PKG_NAME" +pkgbuild \ + --component "$BUILD_DIR/$APP_BUNDLE" \ + --install-location /Applications \ + "$BUILD_DIR/$PKG_NAME" + +echo "" +echo "Done! Artifacts in $BUILD_DIR/:" +ls -lh "$BUILD_DIR/$DMG_NAME" "$BUILD_DIR/$PKG_NAME" diff --git a/specs/004-transcription-log/checklists/requirements.md b/specs/004-transcription-log/checklists/requirements.md new file mode 100644 index 0000000..13bb493 --- /dev/null +++ b/specs/004-transcription-log/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Transcription Log + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-28 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/004-transcription-log/data-model.md b/specs/004-transcription-log/data-model.md new file mode 100644 index 0000000..d0f1a00 --- /dev/null +++ b/specs/004-transcription-log/data-model.md @@ -0,0 +1,78 @@ +# Data Model: Transcription Log + +**Feature**: 004-transcription-log +**Date**: 2026-03-28 + +## Entities + +### TranscriptionLogEntry + +Represents a single completed transcription event. + +| Field | Type | Constraints | Notes | +|-------|------|-------------|-------| +| `id` | UUID | unique, non-nil | Stable identifier; generated at creation | +| `text` | String | non-empty | Final cleaned transcription text delivered to user | +| `timestamp` | Date | non-nil | Wall-clock time when transcription completed | + +**Serialisation**: `Codable` (JSON). Stored as an array of entry objects in a single file. + +**Identity rule**: Each entry is independently identified by UUID. Duplicate text values with different timestamps are distinct entries (same phrase dictated twice = two separate entries). + +### TranscriptionLogStore + +Manages the in-memory and on-disk state of the log. + +| Concern | Behaviour | +|---------|-----------| +| Capacity cap | Maximum 500 entries; when the cap is exceeded, the oldest entry (lowest `timestamp`) is removed before adding the new one | +| Ordering | Entries stored internally in insertion order; sorted descending by `timestamp` on read for display | +| Persistence path | `~/Library/Application Support/Wisp/transcription-log.json` | +| Write strategy | Full array rewrite after each `append(_:)` call; no incremental append to avoid corruption risk | +| Corrupt/missing file | Silently initialise with empty array; overwrite corrupt file on next write | +| Thread safety | All mutations on `@MainActor` (matching existing AppDelegate patterns) | + +## State Transitions + +``` +(app launch) + │ + ▼ +TranscriptionLogStore.load() + ├─ Success → entries populated from JSON + └─ Failure (corrupt / missing) → entries = [] + +(transcription completes) + │ + ▼ +TranscriptionLogStore.append(entry) + ├─ count ≤ 499 → append, save + └─ count = 500 → remove oldest, append, save + +(user opens log window) + │ + ▼ +LogView reads entries (snapshot at open time) + ├─ entries.count > 0 → display list, newest first + └─ entries.count = 0 → display empty state +``` + +## Persistence Layout + +``` +~/Library/Application Support/Wisp/ +└── transcription-log.json ← JSON array of TranscriptionLogEntry objects +``` + +**JSON shape example**: +```json +[ + { + "id": "A1B2C3D4-...", + "text": "The quick brown fox", + "timestamp": 762220800.0 + } +] +``` + +(`timestamp` stored as `timeIntervalSinceReferenceDate` — standard Swift `Date` Codable behaviour.) diff --git a/specs/004-transcription-log/plan.md b/specs/004-transcription-log/plan.md new file mode 100644 index 0000000..d23640e --- /dev/null +++ b/specs/004-transcription-log/plan.md @@ -0,0 +1,122 @@ +# Implementation Plan: Transcription Log + +**Branch**: `004-transcription-log` | **Date**: 2026-03-28 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/004-transcription-log/spec.md` + +## Summary + +Add a "Show Log" menu item to the Wisp menu bar app that opens a window displaying the +last up to 500 transcriptions in reverse-chronological order, with a per-entry copy button. +Transcription history is persisted to a local JSON file in Application Support and survives +app restarts. No network, no sync, no real-time updates. + +## Technical Context + +**Language/Version**: Swift 6.1+ with strict concurrency checking enabled +**Primary Dependencies**: AppKit (NSWindow, NSMenu), SwiftUI (List, Button), Foundation (Codable, JSONEncoder/Decoder, FileManager) +**Storage**: JSON file — `~/Library/Application Support/Wisp/transcription-log.json` +**Testing**: XCTest (existing test target `WispTests`) +**Target Platform**: macOS 26+, Apple Silicon and Intel +**Project Type**: macOS menu bar desktop app (LSUIElement) +**Performance Goals**: Log window opens in <1 s; write after each transcription adds <5 ms +**Constraints**: Offline-only; log capped at 500 entries; corrupt data silently discarded +**Scale/Scope**: Single user; ≤500 entries; no concurrency on log store (main actor) + +## Constitution Check + +| Principle | Status | Notes | +| --------- | ------ | ----- | +| I. Privacy-First Local Processing | ✅ Pass | Log stored only on-device; no network calls; no telemetry | +| II. Type Safety & Correctness | ✅ Pass | Codable types with explicit fields; no force-unwraps; guard let on file load | +| III. Test-First Development | ✅ Pass | XCTest required for TranscriptionLogStore (append, cap, persist, corrupt recovery) and LogView empty/populated states | +| IV. Performance-Conscious Design | ✅ Pass | File I/O is post-paste on main thread (negligible); log window load is one JSON decode of ≤500 small objects | +| V. Simplicity & YAGNI | ✅ Pass | Minimal new surface: 1 store, 1 window, 1 SwiftUI view, 1 menu item; no delete, no search, no real-time updates in v1 | + +**Gate result**: PASS — no violations, no Complexity Tracking needed. + +## Project Structure + +### Documentation (this feature) + +```text +specs/004-transcription-log/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +└── tasks.md # Phase 2 output (/speckit.tasks) +``` + +### Source Code + +```text +Wisp/ +├── App/ +│ └── AppDelegate.swift # MODIFIED: add menu item; init log store; hook append after handleResult +├── Models/ +│ ├── TranscriptionLogEntry.swift # NEW: Codable struct (id, text, timestamp) +│ └── TranscriptionLogStore.swift # NEW: @MainActor; load/append/cap/persist +└── UI/ + ├── LogWindow.swift # NEW: NSWindow wrapper (open/close/bring to front) + └── LogView.swift # NEW: SwiftUI List with copy buttons + empty state + +WispTests/ +└── TranscriptionLogStoreTests.swift # NEW: unit tests (append, cap at 500, persist, corrupt recovery) +``` + +**Structure Decision**: Single project, existing layout. New files slot into the established +`Models/` and `UI/` directories. No new directories needed. + +## Implementation Phases + +### Phase A: Data Layer + +1. Create `TranscriptionLogEntry.swift` — `Codable`, `Identifiable`, `Sendable` struct with `id: UUID`, `text: String`, `timestamp: Date`. +2. Create `TranscriptionLogStore.swift` — `@MainActor final class`: + - `private(set) var entries: [TranscriptionLogEntry]` + - `init()` loads from JSON (silently empty on failure) + - `func append(text: String)` — creates entry with `Date()`, prepends, drops oldest if over 500, saves + - `private func save()` — encodes entries to JSON, writes to Application Support path + - `static var storageURL: URL` — computed URL for `transcription-log.json` +3. Write `TranscriptionLogStoreTests.swift` **first** (TDD): + - `testAppend_addsEntry()` + - `testAppend_capsAt500_dropsOldest()` + - `testPersistence_survivesReinit()` + - `testCorruptFile_startsEmpty()` + +### Phase B: UI Layer + +1. Create `LogView.swift` — SwiftUI `View`: + - `List` of entries sorted newest-first; each row shows timestamp (formatted "Mar 28, 2:32 PM") + text + copy `Button` + - Empty state: `ContentUnavailableView` or `Text("No transcriptions yet")` + - Copy button calls `NSPasteboard.general.setString(_:forType:)` +2. Create `LogWindow.swift` — thin `NSWindow` subclass or factory: + - Standard titled, closable, resizable window + - Hosts `LogView` via `NSHostingView` + - `func show(store: TranscriptionLogStore)` — opens or brings to front + +### Phase C: Integration + +1. Modify `AppDelegate.swift`: + - Add `private var logStore = TranscriptionLogStore()` property + - Add `private var logWindow: LogWindow?` property + - In menu setup, insert `NSMenuItem(title: "Show Log", action: #selector(showLog), keyEquivalent: "")` before the existing separator + - Add `@objc func showLog()` — creates or reuses `logWindow`, calls `logWindow.show(store: logStore)` + - In `handleResult`, after successful paste/copy, call `logStore.append(text: finalText)` + +## Key Design Decisions + +See [research.md](research.md) for full rationale on each decision. + +| Decision | Choice | +| -------- | ------ | +| Storage format | JSON file in Application Support | +| Window type | NSWindow + SwiftUI content | +| Integration point | `AppDelegate.handleResult` post-paste | +| Corrupt data | Silent discard, empty log | +| Live updates | Static snapshot at window-open time | + +## Open Items / Deferred + +- Real-time log refresh while window is open (deferred to future iteration; requires Combine observer on store) +- Individual entry deletion or "Clear All" (explicitly out of scope for v1 per spec assumptions) +- Log entry display for very long transcriptions (truncate with expand, deferred to v1.1) diff --git a/specs/004-transcription-log/research.md b/specs/004-transcription-log/research.md new file mode 100644 index 0000000..396b678 --- /dev/null +++ b/specs/004-transcription-log/research.md @@ -0,0 +1,67 @@ +# Research: Transcription Log + +**Feature**: 004-transcription-log +**Date**: 2026-03-28 + +## Decision 1: Persistence storage format + +**Decision**: JSON file in `~/Library/Application Support/Wisp/transcription-log.json` + +**Rationale**: The log is a growing list of up to 500 entries. A flat JSON file keeps +the implementation simple (Codable), survives app restarts, and is trivially inspectable. +UserDefaults can technically store arrays but is designed for small scalar preferences, not +bounded-but-growing history lists. CoreData and SQLite add significant complexity with no +benefit at this scale. + +**Alternatives considered**: +- UserDefaults — rejected: designed for preferences, not history; awkward to cap at 500 and truncate old entries; harder to test in isolation +- CoreData — rejected: excessive complexity for a flat list of ~500 text records; violates YAGNI (Constitution §V) +- SQLite — rejected: same reason as CoreData; no relational structure needed + +## Decision 2: Log window type + +**Decision**: `NSWindow` (not NSPanel) containing a SwiftUI `List` view + +**Rationale**: The log window should behave like a standard document window — it gets a +Dock Exposé entry, responds to Cmd+W, and is dismissible with normal window controls. +NSPanel is appropriate for ephemeral overlay HUDs (already used for the status indicator). +SwiftUI is explicitly permitted for settings/preferences panels (Constitution §Platform) and +a log view has the same characteristics: no live audio path, no concurrency constraints. + +**Alternatives considered**: +- NSPanel — rejected: NSPanel is for floating utility overlays (the status indicator uses this); the log window is a persistent browsable history, not a transient overlay +- Pure AppKit NSTableView — rejected: SwiftUI List achieves the same result with far less boilerplate; constitution permits SwiftUI for panels + +## Decision 3: Log entry hook integration point + +**Decision**: Add a `TranscriptionLogStore.append(_:)` call in `AppDelegate.handleResult` +immediately after the paste/copy succeeds, capturing the final cleaned text and current timestamp. + +**Rationale**: `handleResult` is already the authoritative completion point for all +transcription outcomes. Inserting log persistence there requires a single call site and +does not touch the audio pipeline hot path. The log store write is a simple file append +(rewrite capped JSON), adding negligible latency on the main thread after paste is already complete. + +**Alternatives considered**: +- Hook into `transcribeAndPaste` — rejected: that function handles async transcription work; mixing file I/O there increases cognitive complexity +- Hook into `PasteService` — rejected: PasteService should remain focused on clipboard/keypress; logging is app-level concern +- Observe AppState changes — rejected: AppState transitions don't carry the transcribed text payload + +## Decision 4: Corrupt data recovery + +**Decision**: On JSON decode failure, `TranscriptionLogStore` silently initialises with an +empty array and immediately overwrites the corrupt file (or leaves it absent). No error is +surfaced to the user. + +**Rationale**: Clarified in `/speckit.clarify` session (FR-009). A corrupt log file is a +rare scenario; starting fresh is the least disruptive outcome for a background utility app. + +## Decision 5: Live update behaviour + +**Decision**: The log window loads entries once at open time; it does not observe new +dictations in real-time. Closing and reopening the window shows the latest state. + +**Rationale**: Accepted in spec assumptions. Real-time observation requires adding a +Combine/async publisher to `TranscriptionLogStore`, introducing concurrency complexity with +no proportional user benefit — the window is rarely open during active dictation sessions. +This is deferred to a future iteration. diff --git a/specs/004-transcription-log/spec.md b/specs/004-transcription-log/spec.md new file mode 100644 index 0000000..491aacb --- /dev/null +++ b/specs/004-transcription-log/spec.md @@ -0,0 +1,91 @@ +# Feature Specification: Transcription Log + +**Feature Branch**: `004-transcription-log` +**Created**: 2026-03-28 +**Status**: Draft +**Input**: User description: "I want to add a new option to the menu. A "show log" option that shows the log of the last up to 500 messages transcribed by the app. Each one will have a copy button that you can click to copy it to the clipboard." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - View Transcription History (Priority: P1) + +A user who has dictated several messages throughout the day wants to review what was transcribed. They open the menu bar app and select "Show Log" to see their recent transcriptions. The log displays the last up to 500 entries in reverse-chronological order (most recent first), so they can quickly find recent dictations. + +**Why this priority**: This is the core feature — without a browsable log, nothing else in this feature has value. + +**Independent Test**: Can be fully tested by performing a dictation, then opening the log and verifying the transcription appears in the list. Delivers value as a read-only history even without copy functionality. + +**Acceptance Scenarios**: + +1. **Given** the user has completed at least one dictation, **When** they click "Show Log" in the menu, **Then** a window opens listing their transcribed messages with the most recent at the top. +2. **Given** the user has never dictated anything, **When** they open the log, **Then** an empty state message is shown (e.g., "No transcriptions yet"). +3. **Given** the user has completed more than 500 dictations, **When** they open the log, **Then** only the 500 most recent entries are shown. + +--- + +### User Story 2 - Copy a Transcription from Log (Priority: P2) + +A user sees a message they transcribed earlier in the log and wants to re-use it. They click the copy button next to that entry and the text is placed on their clipboard, ready to paste elsewhere. + +**Why this priority**: The copy action is the key interaction that makes the log actionable; without it the log is read-only reference only. + +**Independent Test**: Can be fully tested by opening the log and clicking the copy button on any entry, then pasting to verify the correct text is on the clipboard. + +**Acceptance Scenarios**: + +1. **Given** the log is open with at least one entry, **When** the user clicks the copy button next to an entry, **Then** that entry's full transcribed text is copied to the system clipboard. +2. **Given** the user copies an entry, **When** they paste into any application, **Then** the exact transcribed text appears. +3. **Given** the log is open, **When** the user copies a second entry after a first, **Then** only the second entry's text is on the clipboard. + +--- + +### Edge Cases + +- What happens when the log is open and a new dictation completes — does the log update live or only on next open? +- How does the system handle very long transcriptions displayed in the list? +- If persisted log data is corrupted or unreadable on startup, the app silently discards it and presents an empty log. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The menu bar app MUST display a "Show Log" menu item. +- **FR-002**: Selecting "Show Log" MUST open a window listing the last up to 500 transcriptions produced by the app. +- **FR-003**: Transcriptions in the log MUST be displayed in reverse-chronological order (most recent first). +- **FR-004**: Each log entry MUST display the transcribed text and the timestamp of when the transcription was completed (e.g., "Mar 28, 2:32 PM"). +- **FR-005**: Each log entry MUST include a copy button that, when clicked, copies the entry's full text to the system clipboard. +- **FR-006**: The transcription log MUST persist across app restarts — history must survive closing and reopening the app. +- **FR-007**: When the log contains no entries, the window MUST display an appropriate empty-state message. +- **FR-008**: The log MUST cap stored and displayed entries at 500; entries beyond this limit are automatically dropped (oldest first). +- **FR-009**: If persisted log data is corrupt or unreadable on app startup, the app MUST silently discard it and present an empty log — no error is shown to the user. + +### Key Entities + +- **Transcription Entry**: Represents a single completed dictation event. Key attributes: transcribed text, timestamp of completion. +- **Transcription Log**: An ordered collection of up to 500 Transcription Entries, ordered by timestamp descending. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users can open the transcription log from the menu in under 1 second. +- **SC-002**: All transcriptions produced during the current app session appear in the log when it is opened. +- **SC-003**: Users can copy any log entry to the clipboard with a single click. +- **SC-004**: Transcription history persists across 100% of normal app restarts (non-crash shutdowns). +- **SC-005**: The log correctly limits display to the 500 most recent entries when history exceeds that count. + +## Clarifications + +### Session 2026-03-28 + +- Q: Should each log entry display its timestamp alongside the transcribed text? → A: Yes — show timestamp per entry (e.g., "Mar 28, 2:32 PM") +- Q: What should happen if persisted log data is corrupted or unreadable on startup? → A: Silently discard corrupted data and start with an empty log + +## Assumptions + +- The log shows transcriptions produced by this app only; it does not import or show text from other sources. +- Each log entry stores the transcribed text and a timestamp — no metadata about the source application is required for v1. +- The log window does not need to update in real-time while open; reflecting the state at open time is acceptable. +- There is no requirement to delete individual entries or clear the entire log in v1. +- The "Show Log" menu item appears in the existing menu bar app menu alongside other existing options. +- Log data is stored locally on the user's device and is not synced or shared. diff --git a/specs/004-transcription-log/tasks.md b/specs/004-transcription-log/tasks.md new file mode 100644 index 0000000..40643aa --- /dev/null +++ b/specs/004-transcription-log/tasks.md @@ -0,0 +1,152 @@ +# Tasks: Transcription Log + +**Input**: Design documents from `/specs/004-transcription-log/` +**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅ + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1, US2) +- Paths follow the existing project layout: `Wisp/` for source, `WispTests/` for tests + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Ensure Application Support directory is handled and existing project is ready for new files. + +- [x] T001 Verify `~/Library/Application Support/Wisp/` directory creation is handled in TranscriptionLogStore (no new files yet — document the path in a comment inside the store file once created) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core data layer that MUST be complete before either user story can be implemented. Both US1 and US2 depend on `TranscriptionLogEntry` and `TranscriptionLogStore`. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +> **TDD**: Write tests FIRST (T002), ensure they FAIL, then implement (T003, T004). + +- [x] T002 Write failing XCTest suite for TranscriptionLogStore in `WispTests/TranscriptionLogStoreTests.swift` covering: `testAppend_addsEntry`, `testAppend_capsAt500_dropsOldest`, `testPersistence_survivesReinit`, `testCorruptFile_startsEmpty` +- [x] T003 [P] Create `TranscriptionLogEntry` Codable+Identifiable+Sendable struct in `Wisp/Models/TranscriptionLogEntry.swift` with fields: `id: UUID`, `text: String`, `timestamp: Date` +- [x] T004 Create `TranscriptionLogStore` @MainActor final class in `Wisp/Models/TranscriptionLogStore.swift` with: `static var storageURL: URL`, `private(set) var entries: [TranscriptionLogEntry]`, `init()` loading from JSON (silent empty on failure), `func append(text: String)` (cap at 500, drop oldest, save), `private func save()` writing JSON to storageURL (depends on T003) + +**Checkpoint**: Run `TranscriptionLogStoreTests` — all four tests must pass before proceeding. + +--- + +## Phase 3: User Story 1 — View Transcription History (Priority: P1) 🎯 MVP + +**Goal**: User can open "Show Log" from the menu bar and see a list of their recent transcriptions in reverse-chronological order, with timestamps. + +**Independent Test**: Perform a dictation, open Show Log from menu, verify the transcription appears at the top of the list with a formatted timestamp (e.g., "Mar 28, 2:32 PM"). Verify empty state when no transcriptions exist. + +### Implementation for User Story 1 + +- [x] T005 [P] [US1] Create `LogView` SwiftUI View in `Wisp/UI/LogView.swift` displaying entries as a `List` sorted newest-first; each row shows formatted timestamp ("MMM d, h:mm a") and transcribed text; empty state shows `Text("No transcriptions yet.")` when entries array is empty (depends on T003) +- [x] T006 [P] [US1] Create `LogWindow` NSWindow factory/class in `Wisp/UI/LogWindow.swift` that hosts `LogView` via `NSHostingView`, standard titled/closable/resizable window, `func show(entries: [TranscriptionLogEntry])` opens window or brings it to front (depends on T005) +- [x] T007 [US1] Modify `Wisp/App/AppDelegate.swift`: add `private var logStore = TranscriptionLogStore()` property; add `private var logWindow: LogWindow?` property; in menu setup add `NSMenuItem(title: "Show Log", action: #selector(showLog), keyEquivalent: "")` before the existing separator; add `@objc func showLog()` that creates/reuses logWindow and calls `logWindow.show(entries: logStore.entries)` (depends on T004, T006) +- [x] T008 [US1] Modify `Wisp/App/AppDelegate.swift`: in `handleResult`, after successful paste or clipboard-copy, call `logStore.append(text: finalText)` to record the completed transcription (depends on T007) + +**Checkpoint**: Build and run. Dictate something, open Show Log — entry should appear with timestamp. Open with no history — empty state message should show. + +--- + +## Phase 4: User Story 2 — Copy a Transcription from Log (Priority: P2) + +**Goal**: Each log entry has a copy button that places the entry's text on the system clipboard when clicked. + +**Independent Test**: Open Show Log, click the copy button on any entry, paste into a text field — the exact transcribed text should appear. + +### Implementation for User Story 2 + +- [x] T009 [US2] Modify `Wisp/UI/LogView.swift`: add a copy `Button` to each list row that calls `NSPasteboard.general.clearContents()` then `NSPasteboard.general.setString(entry.text, forType: .string)`; button label should be a clipboard SF Symbol (e.g., `"doc.on.doc"`) (depends on T005) + +**Checkpoint**: Build and run. Open Show Log, click copy on an entry, paste elsewhere — exact text appears. Copying a second entry replaces the first on the clipboard. + +--- + +## Phase 5: Polish & Cross-Cutting Concerns + +**Purpose**: Final quality pass across both stories. + +- [x] T010 [P] Review `TranscriptionLogStore.swift` for Swift 6 strict concurrency warnings — ensure all file I/O is safe and `@MainActor` isolation is correct in `Wisp/Models/TranscriptionLogStore.swift` +- [x] T011 [P] Review `LogView.swift` timestamp formatting — confirm `DateFormatter` or `FormatStyle` produces correct output (e.g., "Mar 28, 2:32 PM") for same-day and cross-day entries in `Wisp/UI/LogView.swift` +- [x] T012 Run full test suite (`swift test`) and verify all `TranscriptionLogStoreTests` pass in `WispTests/TranscriptionLogStoreTests.swift` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — can start immediately +- **Foundational (Phase 2)**: Depends on Phase 1 — BLOCKS all user stories; T002 must be written first (TDD) +- **User Story 1 (Phase 3)**: Depends on Foundational completion (T003, T004) +- **User Story 2 (Phase 4)**: Depends on US1 LogView existing (T005) — adds to it +- **Polish (Phase 5)**: Depends on both stories complete + +### User Story Dependencies + +- **US1 (P1)**: Starts after T003 + T004 complete. T005 and T006 can run in parallel. T007 depends on both T004 and T006. T008 depends on T007. +- **US2 (P2)**: Starts after T005 (LogView exists). Single task T009. + +### Within Each Phase + +- T002 (tests) written FIRST and must FAIL before T003/T004 implementation +- T003 and T004 are sequential (store depends on entry struct) +- T005 and T006 can run in parallel once T003 is done +- T007 integrates T004 + T006 — sequential +- T008 extends T007 — sequential +- T009 modifies T005 — sequential + +--- + +## Parallel Execution Examples + +### Phase 2 (after T002 written and failing) + +```text +T003: Create TranscriptionLogEntry.swift ← start immediately +# T004 waits for T003 +``` + +### Phase 3 (after T004 complete) + +```text +T005: Create LogView.swift ← start in parallel +T006: Create LogWindow.swift ← start in parallel (waits for T005 via NSHostingView) +# T007 waits for T004 + T006 +# T008 waits for T007 +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 2: Foundational (write failing tests → implement store) +2. Complete Phase 3: User Story 1 (view log from menu) +3. **STOP and VALIDATE**: Dictate, open log, verify entry with timestamp appears +4. Ship MVP — log is useful even without copy button + +### Incremental Delivery + +1. Complete Setup + Foundational → data layer ready +2. Add User Story 1 → browsable history in menu → **Demo/Ship** +3. Add User Story 2 → copy button per entry → **Demo/Ship** +4. Apply Polish + +--- + +## Notes + +- [P] tasks = different files, no blocking dependencies between them +- [US1] / [US2] labels map tasks to spec user stories for traceability +- Constitution §III requires TDD: T002 tests must be written and FAILING before T003/T004 implementation +- Constitution §II: no force-unwraps in store file I/O — use `guard let` / `try?` +- `TranscriptionLogStore` is `@MainActor` — consistent with existing AppDelegate patterns +- `LogWindow` should be dismissed with Cmd+W (standard NSWindow behaviour, no special handling needed)