From bbdcfad06eb1923fd9b692d0c2a2efa02cd744bd Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 21 Sep 2026 17:22:48 -0700 Subject: [PATCH] Make native Aesel a first-line notebook with stable animated chrome --- apple/aesel/README.md | 17 ++- apple/aesel/Sources/AeselApp.swift | 10 +- apple/aesel/Sources/AeselComposer.swift | 25 ++++- apple/aesel/Sources/AeselScene.swift | 34 +++--- apple/aesel/Sources/ApplePlatform.swift | 27 ++++- apple/aesel/Sources/ContentView.swift | 117 ++++++++++++-------- apple/aesel/Tests/ComposerFocusChecks.swift | 62 +++++++++++ 7 files changed, 215 insertions(+), 77 deletions(-) create mode 100644 apple/aesel/Tests/ComposerFocusChecks.swift diff --git a/apple/aesel/README.md b/apple/aesel/README.md index 459939599f..e0caa049fc 100644 --- a/apple/aesel/README.md +++ b/apple/aesel/README.md @@ -6,6 +6,9 @@ AppKit. Both run the bundled JavaScript session, notebook renderer, and AC piece preview; the Mac target does not use Electron or start a Node process. The live WebKit preview stays in the upper-right corner, including for new pieces and thread changes, and can expand to fill the workspace. +The animated title and version share a fixed top strip with the Mac window +controls. A blank notebook starts editing on its first ruled line; the remaining +paper accepts clicks. Letter animation never changes the title's hit area. Appearance follows the system automatically. Native controls and the notebook share the system palette; enabled Mac buttons use a pointing hand. @@ -19,6 +22,14 @@ root with `node easel/native/install.mjs`. A connected provider can generate without AC sign-in; AC generation and publication retain their account gates. AC's model is Automatic. iPhone pairing to the helper is not implemented. +Native CLI sessions share Electron's piece-first, responsive-layout, network-clock, +sound-design and reply instructions, plus the bundled AC guides. Each turn +includes a fresh preview image when available. `ac_frame` and `ac_preview` inspect +only the matching native thread; unavailable captures are reported explicitly. +Native observations distinguish snapshot size from drawable canvas size, but do +not yet certify the exact rendered source revision or expose the full worker +console. No preview claim should exceed that evidence. + ```sh ./run.sh mac # build and open Aesel Native.app ``` @@ -35,9 +46,11 @@ To build both targets without installing, run `./bundle-session.sh` and `xcodegen generate`, then build schemes `AeselMac` (macOS) and `Aesel` (`generic/platform=iOS`, with signing disabled for a compile check). -The preview state transitions have a standalone native check: +The editor focus regression and preview transitions have standalone native checks: ```sh +xcrun swiftc -parse-as-library Sources/AeselComposer.swift Tests/ComposerFocusChecks.swift -o /tmp/aesel-composer-check +/tmp/aesel-composer-check xcrun swiftc Sources/Session.swift Tests/SessionPreviewChecks.swift -o /tmp/aesel-preview-check /tmp/aesel-preview-check ``` @@ -118,4 +131,4 @@ including the `@` character, with the same fallback palette as desktop. ## MCP and visual acceptance -The native app exposes a private same-user automation mailbox. The monorepo adapter and Aesthetic Eye workflow live at `slab/bin/aesel-mcp.mjs`, `slab/bin/aesel-eye.mjs` and `slab/AESEL-EYE.md`. Tests inspect and act through stable UI control IDs without activating the window. Preview URLs use the same `nogap`, `nolabel` and `autoreload` contract as Electron; the footer shows the persisted piece revision starting at v0. +The native app exposes a private same-user automation mailbox. The monorepo adapter and Aesthetic Eye workflow live at `slab/bin/aesel-mcp.mjs`, `slab/bin/aesel-eye.mjs` and `slab/AESEL-EYE.md`. Tests inspect and act through stable UI control IDs without activating the window. Preview URLs use the same `nogap`, `nolabel` and `autoreload` contract as Electron; the top strip shows the persisted piece revision starting at v0. diff --git a/apple/aesel/Sources/AeselApp.swift b/apple/aesel/Sources/AeselApp.swift index 9d718fa765..08d3f08aed 100644 --- a/apple/aesel/Sources/AeselApp.swift +++ b/apple/aesel/Sources/AeselApp.swift @@ -25,9 +25,9 @@ struct AeselApp: App { height: ceil(geometry.size.height * displayScale) / displayScale / uiScale) .environment(\.aeselUIScale, uiScale) .scaleEffect(uiScale, anchor: .topLeading) - }.frame(minWidth: 220, minHeight: 160).clipped() + }.frame(minWidth: 220, minHeight: 160).clipped().ignoresSafeArea(.container, edges: .top) } - .windowToolbarStyle(.expanded) + .windowStyle(.hiddenTitleBar) .defaultSize(width: 840, height: 680) .commands { CommandGroup(after: .toolbar) { @@ -81,8 +81,10 @@ struct AeselApp: App { session.braincellDollars = 2.41 session.freeDollars = 1 session.purchasedDollars = 1.41 - session.append(.you, "Make a little orange circle.") - session.append(.ac, "The **orange** circle follows your pointer.\n\nTry a radius of `24` or a blue background.\n\n```js\nconst radius = 24;\nwipe(\"blue\");\n```\n\n[Open Aesthetic Computer](https://aesthetic.computer)") + if ProcessInfo.processInfo.environment["AESEL_PREVIEW_EMPTY"] != "1" { + session.append(.you, "Make a little orange circle.") + session.append(.ac, "The **orange** circle follows your pointer.\n\nTry a radius of `24` or a blue background.\n\n```js\nconst radius = 24;\nwipe(\"blue\");\n```\n\n[Open Aesthetic Computer](https://aesthetic.computer)") + } session.previewURL = nil if ProcessInfo.processInfo.environment["AESEL_PREVIEW_PIECE"] == "1" { session.previewURL = Session.draftPreviewURL diff --git a/apple/aesel/Sources/AeselComposer.swift b/apple/aesel/Sources/AeselComposer.swift index 717065dac7..46a4a0c3a7 100644 --- a/apple/aesel/Sources/AeselComposer.swift +++ b/apple/aesel/Sources/AeselComposer.swift @@ -24,6 +24,15 @@ struct AeselComposer: NSViewRepresentable { final class Editor: NSTextView { var focusChanged: (Bool) -> Void = { _ in } var resized: () -> Void = {} + var wantsFocus = false + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + DispatchQueue.main.async { [weak self] in + guard let self, self.wantsFocus else { return } + self.window?.makeFirstResponder(self) + } + } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() if result { focusChanged(true) }; return result @@ -36,6 +45,7 @@ struct AeselComposer: NSViewRepresentable { } final class Coordinator: NSObject, NSTextViewDelegate { var owner: AeselComposer + var requestedFocus: Bool? init(_ owner: AeselComposer) { self.owner = owner } func measure(_ view: NSTextView) { guard let layout = view.layoutManager, let container = view.textContainer else { return } @@ -60,7 +70,10 @@ struct AeselComposer: NSViewRepresentable { func makeNSView(context: Context) -> Editor { let view = Editor(frame: .zero) view.isRichText = false; view.allowsUndo = true; view.drawsBackground = false; view.isHorizontallyResizable = false - view.isVerticallyResizable = true; view.autoresizingMask = [.width] + view.isEditable = true; view.isSelectable = true + view.minSize = .zero + view.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + view.isVerticallyResizable = false; view.autoresizingMask = [.width] view.textContainer?.widthTracksTextView = true view.textContainer?.lineFragmentPadding = 0 view.textContainerInset = NSSize(width: 0, height: 4) @@ -83,8 +96,14 @@ struct AeselComposer: NSViewRepresentable { storage.addAttributes(attributes, range: NSRange(location: 0, length: storage.length)) } view.typingAttributes = attributes; view.insertionPointColor = NSColor(color) - if focused && view.window?.firstResponder !== view { view.window?.makeFirstResponder(view) } - else if !focused && view.window?.firstResponder === view { view.window?.makeFirstResponder(nil) } + // A click reports focus asynchronously. Unrelated SwiftUI updates must + // not revoke that click before its binding update arrives. + view.wantsFocus = focused + if context.coordinator.requestedFocus != focused { + context.coordinator.requestedFocus = focused + if focused && view.window?.firstResponder !== view { view.window?.makeFirstResponder(view) } + else if !focused && view.window?.firstResponder === view { view.window?.makeFirstResponder(nil) } + } context.coordinator.measure(view) } } diff --git a/apple/aesel/Sources/AeselScene.swift b/apple/aesel/Sources/AeselScene.swift index df44ce0d5d..a7ae0ab13f 100644 --- a/apple/aesel/Sources/AeselScene.swift +++ b/apple/aesel/Sources/AeselScene.swift @@ -304,23 +304,25 @@ struct AeselTitle: View { let width = text.reduce(horizontalInset * 2) { $0 + (String($1) as NSString).size(withAttributes: [.font: Rock.font(size)]).width } let limit = maximumWidth.map { max(0, $0) / max(1, width) } ?? CGFloat.greatestFiniteMagnitude let restingScale = min(1, limit) - let scale = min(hovered && !reduceMotion ? 1.5 : 1, limit) - HStack(alignment: .top, spacing: 0) { - ForEach(Array(text.enumerated()), id: \.offset) { index, letter in - let hash = Self.fnv("rock\(index)\(text)") - let face = face(index, handle: handle) - RockLetter(text: String(letter), size: size, face: face, beat: Double(index) * 0.12) - // A new letter is a new view, so the sway never crossfades old ink into new. - .id("\(index)|\(letter)|\(face)") - .rotationEffect(.degrees(-Double(Int((hash >> 8) % 9) - 4) * 0.9)) - .offset(y: -(CGFloat(hash % 5) / 2 - 1)) + Color.clear + .frame(width: width * restingScale, height: size * 1.6) + .overlay(alignment: .leading) { + HStack(alignment: .top, spacing: 0) { + ForEach(Array(text.enumerated()), id: \.offset) { index, letter in + let hash = Self.fnv("rock\(index)\(text)") + let face = face(index, handle: handle) + RockLetter(text: String(letter), size: size, face: face, beat: Double(index) * 0.12) + // A new letter is a new view, so the sway never crossfades old ink into new. + .id("\(index)|\(letter)|\(face)") + .rotationEffect(.degrees(-Double(Int((hash >> 8) % 9) - 4) * 0.9)) + .offset(y: -(CGFloat(hash % 5) / 2 - 1)) + } + } + .padding(.horizontal, horizontalInset) + .fixedSize() + .scaleEffect(restingScale, anchor: .leading) + .allowsHitTesting(false) } - } - .padding(.horizontal, horizontalInset) - .fixedSize() - .scaleEffect(scale, anchor: hoverAnchor) - .animation(reduceMotion ? nil : .interpolatingSpring(mass: 0.8, stiffness: 250, damping: 12, initialVelocity: 0), value: hovered) - .frame(width: width * restingScale, alignment: .leading) .contentShape(Rectangle()) .onHover { inside in if inside && !hovered { hoverSound?() } diff --git a/apple/aesel/Sources/ApplePlatform.swift b/apple/aesel/Sources/ApplePlatform.swift index 845c85e02c..0618bdcb91 100644 --- a/apple/aesel/Sources/ApplePlatform.swift +++ b/apple/aesel/Sources/ApplePlatform.swift @@ -155,7 +155,7 @@ extension View { } #if os(macOS) -/// Electron used an ordinary compact NSWindow title bar, with the piece name. +/// Keep native window controls over the notebook's own title strip. private struct AeselWindowTitle: NSViewRepresentable { let title: String let paper: Color @@ -184,9 +184,10 @@ private struct AeselWindowTitle: NSViewRepresentable { updateBacking() if window.toolbar != nil { window.toolbar = nil } window.toolbarStyle = .expanded - window.styleMask.remove([.fullSizeContentView, .unifiedTitleAndToolbar]) - window.titlebarAppearsTransparent = false - window.titleVisibility = .visible + window.styleMask.insert(.fullSizeContentView) + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.titlebarSeparatorStyle = .none if window.title != pieceTitle { window.title = pieceTitle } } } @@ -209,3 +210,21 @@ extension View { #endif } } + +/// Only the empty header drags the window; document clicks belong to editing. +#if os(macOS) +struct AeselWindowDragArea: NSViewRepresentable { + final class DragView: NSView { + override func mouseDown(with event: NSEvent) { + if event.clickCount == 2 { window?.zoom(nil) } + else { window?.performDrag(with: event) } + } + } + func makeNSView(context: Context) -> DragView { DragView() } + func updateNSView(_ view: DragView, context: Context) {} +} +#else +struct AeselWindowDragArea: View { + var body: some View { Color.clear } +} +#endif diff --git a/apple/aesel/Sources/ContentView.swift b/apple/aesel/Sources/ContentView.swift index 23ad5149c4..df9a61925b 100644 --- a/apple/aesel/Sources/ContentView.swift +++ b/apple/aesel/Sources/ContentView.swift @@ -25,11 +25,11 @@ struct ContentView: View { @State private var expandedPreview = false @State private var previewHidden = false @State private var notebookHeight: CGFloat = 24 - @State private var notebookTop: CGFloat = 60 + @State private var notebookTop: CGFloat = 12 /// Slab's "complete" is an attention colour that clears once you return to /// the window; here, once you touch the draft again. @State private var attended = true - @State private var writing = false + @State private var writing = true @State private var composerHeight: CGFloat = 48 @State private var sheetSize = CGSize(width: 840, height: 680) @AppStorage("aesel.uiScale") private var uiScale = 1.0 @@ -45,7 +45,9 @@ struct ContentView: View { private var previewSize: CGSize { CGSize(width: previewBounds.width, height: previewBounds.height) } private var previewInset: CGFloat { previewBounds.right } private var previewVisible: Bool { session.previewURL != nil && !previewHidden && sheetSize.width >= 420 && sheetSize.height >= 300 } - private var previewBlockHeight: CGFloat { paperTop + row * 2 } + private var previewBlockHeight: CGFloat { paperTop } + private var hasNotebook: Bool { session.entries.contains { $0.kind != .edit } || session.fatal != nil } + private var transcriptHeight: CGFloat { hasNotebook ? max(row, notebookHeight) : 0 } private var notebookExclusion: [String: CGFloat] { guard previewVisible else { return [:] } let width = previewBounds.width + 8 + previewBounds.right + 16 - edgeInset @@ -72,9 +74,6 @@ struct ContentView: View { AeselHomeView(session: session, host: host) { showHome = false } } } - .overlay(alignment: .bottomTrailing) { - if !expandedPreview && !showHome && !showSettings { versionLabel } - } .overlay { if showSettings { settingsPane } } .font(Paint.font()) .buttonStyle(AeselButtonStyle()) @@ -165,60 +164,81 @@ struct ContentView: View { // MARK: - The sheet - /// The desktop's notebook: one scrolling ruled sheet holding the preview, - /// the prose and the draft, with the title fixed on the first row and the - /// version label parked at the bottom-right corner. + /// A fixed title strip above one scrolling document, starting on its first row. private var sheet: some View { - GeometryReader { geometry in - ScrollViewReader { proxy in - ScrollView { - VStack(alignment: .leading, spacing: 0) { - Color.clear.frame(height: previewBlockHeight) - AeselNotebook(session: session, automation: host.automation, paint: paint, exclusion: notebookExclusion, height: $notebookHeight) { openURL($0) } - .frame(height: max(row, notebookHeight)) - .onGeometryChange(for: CGFloat.self) { $0.frame(in: .named("notebook-scroll")).minY } action: { notebookTop = $0 } - if session.fatal != nil { - Button("/retry") { host.restore() } - .foregroundStyle(paint.you).frame(height: row).padding(.horizontal, edgeInset) + VStack(spacing: 0) { + notebookHeader + GeometryReader { geometry in + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Color.clear.frame(height: previewBlockHeight) + AeselNotebook(session: session, automation: host.automation, paint: paint, exclusion: notebookExclusion, height: $notebookHeight) { openURL($0) } + .frame(height: transcriptHeight) + .clipped() + .allowsHitTesting(hasNotebook) + .onGeometryChange(for: CGFloat.self) { $0.frame(in: .named("notebook-scroll")).minY } action: { notebookTop = $0 } + if session.fatal != nil { + Button("/retry") { host.restore() } + .foregroundStyle(paint.you).frame(height: row).padding(.horizontal, edgeInset) + } + prompt(minHeight: max(48, geometry.size.height - paperTop - transcriptHeight - row)).id("prompt") } - prompt.id("prompt") + .frame(maxWidth: .infinity, minHeight: geometry.size.height, alignment: .topLeading) + .background(alignment: .top) { AeselRuling(spacing: row, topInset: paperTop) } } - .frame(maxWidth: .infinity, minHeight: geometry.size.height, alignment: .topLeading) - .background(alignment: .top) { AeselRuling(spacing: row, topInset: paperTop) } - } - .coordinateSpace(name: "notebook-scroll") - .aeselKeyboardScrolling() - .overlay(alignment: .topTrailing) { - if previewVisible { previewBox(container: geometry.size) } - } - .onGeometryChange(for: CGSize.self) { $0.size } action: { sheetSize = $0 } - .overlay(alignment: .topLeading) { - let clear = previewVisible ? previewSize.width + 8 + 2 + previewInset + 16 : 0 - if !session.route.isEmpty { - title(availableWidth: max(0, geometry.size.width - edgeInset - clear)) + .coordinateSpace(name: "notebook-scroll") + .aeselKeyboardScrolling() + .overlay(alignment: .topTrailing) { + if previewVisible { previewBox(container: geometry.size) } } + .onGeometryChange(for: CGSize.self) { $0.size } action: { sheetSize = $0 } + .onChange(of: notebookHeight) { if session.busy || writing { proxy.scrollTo("prompt", anchor: .bottom) } } + .onChange(of: writing) { if writing { withAnimation { proxy.scrollTo("prompt", anchor: .bottom) } } } + .onChange(of: session.busy) { if session.busy { withAnimation { proxy.scrollTo("prompt", anchor: .bottom) } } } } - .onChange(of: notebookHeight) { if session.busy || writing { proxy.scrollTo("prompt", anchor: .bottom) } } - .onChange(of: writing) { if writing { withAnimation { proxy.scrollTo("prompt", anchor: .bottom) } } } - .onChange(of: session.busy) { if session.busy { withAnimation { proxy.scrollTo("prompt", anchor: .bottom) } } } } } } + private var headerReservedWidth: CGFloat { + #if os(macOS) + 72 / uiScale + edgeInset * 2 + 36 + 48 + #else + edgeInset * 2 + 24 + 48 + #endif + } + + private var notebookHeader: some View { + GeometryReader { geometry in + HStack(spacing: 12) { + #if os(macOS) + Color.clear.frame(width: 72 / uiScale) + #endif + title(availableWidth: max(0, geometry.size.width - headerReservedWidth)) + Spacer(minLength: 8) + versionLabel + } + .padding(.horizontal, edgeInset) + .frame(height: 44) + .background { AeselWindowDragArea() } + } + .frame(height: 44) + } + private var titleURL: URL? { session.shareURL ?? (session.route.isEmpty ? nil : URL(string: "https://aesthetic.computer/")?.appendingPathComponent(session.route)) } private func title(availableWidth: CGFloat) -> some View { Button { if let url = titleURL { openURL(url) } } label: { - AeselTitle(text: session.route, colors: session.handleColors, size: compact ? 16 : 20, + AeselTitle(text: session.route.isEmpty ? "aesel" : session.route, colors: session.handleColors, size: compact ? 12 : 16, maximumWidth: availableWidth, horizontalInset: 0, hoverSound: { AeselHoverSound.play(project: session.route, revision: session.currentRevision, control: "title") }) } .buttonStyle(AeselButtonStyle()) .accessibilityLabel("Open piece in browser") .frame(height: row) - .padding(.leading, edgeInset).padding(.top, edgeInset) } /// The corner piece keeps the original desktop wood grain and resize edges. @@ -260,13 +280,12 @@ struct ContentView: View { } } - /// The draft, written on the sheet's last rows in the user's ink like the - /// desktop's prose prompt; the trailing 100pt stays clear for the version. - private var prompt: some View { + /// The empty paper remains editable below the conversation. + private func prompt(minHeight: CGFloat) -> some View { HStack(alignment: .top, spacing: 8) { AeselComposer(text: Binding(get: { draft }, set: { draft = $0 }), height: $composerHeight, focused: $writing, color: paint.userInk, submit: send) - .frame(height: composerHeight) + .frame(height: max(composerHeight, minHeight)) if session.busy { Button { host.stop() } label: { Text("■").font(Paint.font(16)).foregroundStyle(paint.you).frame(width: row, height: row) @@ -281,18 +300,20 @@ struct ContentView: View { .accessibilityLabel("Send") } } - .padding(.leading, edgeInset).padding(.trailing, compact ? 54 : 80).padding(.bottom, row) + .padding(.leading, edgeInset) + .padding(.trailing, previewVisible && notebookTop + transcriptHeight < previewBounds.top + previewBounds.height + 8 + ? previewSize.width + previewInset + 24 : edgeInset) + .padding(.bottom, row) } - /// The desktop's #credit-label: the version in Prox lettering, 10pt from - /// the right and 8pt up, and the way into settings. + /// The piece version opens settings from the fixed title strip. private var versionLabel: some View { Button { openSettings() } label: { - AeselTitle(text: "v\(session.currentRevision)", size: compact ? 12 : 16, horizontalInset: 0, hoverAnchor: .bottomTrailing, + AeselTitle(text: "v\(session.currentRevision)", size: compact ? 11 : 13, horizontalInset: 0, hoverAnchor: .bottomTrailing, hoverSound: { AeselHoverSound.play(project: session.route, revision: session.currentRevision, control: "version") }) } .buttonStyle(AeselButtonStyle()) - .padding(.trailing, edgeInset).padding(.bottom, edgeInset) + .frame(height: 32) .accessibilityLabel("Piece version \(session.currentRevision). Settings") } diff --git a/apple/aesel/Tests/ComposerFocusChecks.swift b/apple/aesel/Tests/ComposerFocusChecks.swift new file mode 100644 index 0000000000..fa88a9c6ac --- /dev/null +++ b/apple/aesel/Tests/ComposerFocusChecks.swift @@ -0,0 +1,62 @@ +// Runs without showing a window or touching saved notebooks. +// swiftc -parse-as-library Sources/AeselComposer.swift Tests/ComposerFocusChecks.swift -o /tmp/aesel-composer-check +import AppKit +import SwiftUI +typealias AeselFont = NSFont + +@Observable final class Draft { + var text = "" + var height: CGFloat = 48 + var focused = false + var ink = Color.purple +} +struct Paper: View { + @Bindable var draft: Draft + var body: some View { + ScrollView { + AeselComposer(text: $draft.text, height: $draft.height, focused: $draft.focused, + color: draft.ink, submit: {}) + .frame(height: max(480, draft.height)) + .padding(14) + } + } +} +@main struct Checks { + @MainActor static func main() async { + NSApplication.shared.setActivationPolicy(.prohibited) + let draft = Draft() + let host = NSHostingView(rootView: Paper(draft: draft)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 540), + styleMask: [.titled], backing: .buffered, defer: false) + window.contentView = host + window.layoutIfNeeded() + try! await Task.sleep(for: .milliseconds(150)) + func editor(_ view: NSView) -> NSTextView? { + if let text = view as? NSTextView { return text } + return view.subviews.compactMap { editor($0) }.first + } + let text = editor(host)! + precondition(text.isEditable && text.isSelectable) + // The first line and blank paper both route to the native editor. + for y in [8.0, 350.0] { + let point = text.convert(NSPoint(x: 20, y: y), to: host.superview) + precondition(host.hitTest(point) === text, "Paper did not hit editor at \(y)") + } + precondition(window.makeFirstResponder(text)) + // Reproduce a redraw between AppKit focus and the async binding report. + draft.ink = .blue + host.layoutSubtreeIfNeeded() + try! await Task.sleep(for: .milliseconds(100)) + precondition(window.firstResponder === text && draft.focused, "Redraw stole focus") + text.insertText("First line", replacementRange: NSRange(location: NSNotFound, length: 0)) + try! await Task.sleep(for: .milliseconds(100)) + precondition(draft.text == "First line", "Typing did not update draft") + draft.focused = false + try! await Task.sleep(for: .milliseconds(100)) + precondition(window.firstResponder !== text, "Explicit blur was ignored") + draft.focused = true + try! await Task.sleep(for: .milliseconds(100)) + precondition(window.firstResponder === text, "Refocus failed") + print("PASS: paper hit targets, focus through redraw, typing, blur and refocus") + } +} -- knotmirror 0.0.1