From 611ecf2f98ef1a926f8c9711df2defb74ee317d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 08:37:34 +0000 Subject: [PATCH 1/4] Lead Figma with desktop navigation, keyboard and mouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrubbing past the screen edge was the first thing the app said about Figma. Now the first Figma tip is how to navigate as on a computer (Space + drag, ⌘ + scroll, Shift + scroll, right-click, shortcuts), with scrubbing second; the Create card on Welcome reads 'Pan, zoom and use shortcuts in design tools.' The README and the TestFlight description and test steps follow the same order. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W5tzx8GXgMz9DMzc5BF4ND --- .../PointerLocker/Services/FigmaProfile.swift | 10 +++++++-- .../Services/ServiceCategory.swift | 2 +- README.md | 11 ++++++---- docs/app-store/review-notes.md | 21 ++++++++++++------- .../ServicePresentationTests.swift | 4 ++++ 5 files changed, 33 insertions(+), 15 deletions(-) diff --git a/Apps/PointerLocker/Services/FigmaProfile.swift b/Apps/PointerLocker/Services/FigmaProfile.swift index 4836cf9..af3755c 100644 --- a/Apps/PointerLocker/Services/FigmaProfile.swift +++ b/Apps/PointerLocker/Services/FigmaProfile.swift @@ -22,11 +22,17 @@ extension ServiceProfile { colors: (UIColor(red: 0.55, green: 0.36, blue: 0.96, alpha: 1), UIColor(red: 0.13, green: 0.07, blue: 0.27, alpha: 1)) ), setupTips: [ + SetupTip( + id: "navigate", + symbol: "arrow.up.and.down.and.arrow.left.and.right", + title: "Use Figma as on a computer", + detail: "Space + drag pans, ⌘ + scroll zooms and Shift + scroll moves sideways. Right-click and keyboard shortcuts work too." + ), SetupTip( id: "scrub", symbol: "arrow.left.and.right", title: "Drag a number's label to change it", - detail: "The pointer locks while you drag, so the value keeps changing past the edge of the screen. Space + drag moves the canvas." + detail: "The pointer locks while you drag, so the value keeps changing past the edge of the screen." ), SetupTip( id: "session", @@ -36,7 +42,7 @@ extension ServiceProfile { ), ], wording: ServiceWording( - mouseNeeded: "Figma needs a mouse or trackpad to drag and scrub values. Touch alone won't work.", + mouseNeeded: "Figma needs a mouse or trackpad to pan, right-click and drag values. Touch alone won't work.", mouseTest: "Move it and watch the check mark. If it reacts, Figma can see the mouse too.", keyboardReady: "Ready for shortcuts.", lockReady: "The pointer can lock while you drag.", diff --git a/Apps/PointerLocker/Services/ServiceCategory.swift b/Apps/PointerLocker/Services/ServiceCategory.swift index a602eaf..f48d4e6 100644 --- a/Apps/PointerLocker/Services/ServiceCategory.swift +++ b/Apps/PointerLocker/Services/ServiceCategory.swift @@ -14,7 +14,7 @@ enum ServiceCategory: CaseIterable { var summary: String { switch self { case .play: "Aim and look around in cloud games." - case .create: "Drag, scrub and pan in design tools." + case .create: "Pan, zoom and use shortcuts in design tools." } } diff --git a/README.md b/README.md index a6cd3db..0bea7fd 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,13 @@ An iPad browser, built on the system WKWebView, that gives web pages a working **Pointer Lock API**, so a mouse works the way it does on a computer. Safari and WebKit on iPadOS don't implement pointer lock. Two kinds of service use it: -- **Create:** design tools such as Figma, for dragging number fields past the - screen edge. Figma sees a desktop browser, so its keyboard shortcuts work - too: Space + drag pans the canvas, ⌘ + scroll zooms. More browser-based - design tools will follow as they're tested. +- **Create:** design tools such as Figma, used the way you use them on a + computer. Figma sees a desktop browser, and Mouselook passes along the + modifier keys iPadOS drops from scrolling, so Space + drag pans, ⌘ + scroll + zooms, Shift + scroll moves sideways, right-click opens context menus, and + keyboard shortcuts and modifier clicks (Shift + click, ⌥ + drag) work. + Pointer lock lets you drag number fields past the screen edge. More + browser-based design tools will follow as they're tested. - **Play:** cloud games such as GeForce NOW (`play.geforcenow.com`), for mouse-look: aiming and looking around. Other browser-based services that use pointer lock, such as Xbox Cloud Gaming, should work through ⋯ ▸ Open diff --git a/docs/app-store/review-notes.md b/docs/app-store/review-notes.md index 610b6d8..c0bc87b 100644 --- a/docs/app-store/review-notes.md +++ b/docs/app-store/review-notes.md @@ -20,21 +20,26 @@ Apple ID emails from those messages as external testers. **Beta App Description** -Mouselook lets web apps on iPad use the mouse the way they do on a desktop: -dragging number fields in Figma keeps going past the screen edge, and games in -GeForce NOW can capture the mouse for camera control. Needs an iPad with a +Mouselook lets you use Figma on iPad the way you do on a computer: Space + drag +pans, ⌘ + scroll zooms, right-click and keyboard shortcuts work, and dragging a +number field keeps going past the screen edge. Games in GeForce NOW can capture +the mouse for camera control. Needs an iPad with a mouse or trackpad and a keyboard. **What to Test** 1. Pick Figma in onboarding, sign in, open one of your files. -2. Drag a number field label (X, Y, W, rotation) past the screen edge. The +2. Hold Space and drag to pan, hold ⌘ and scroll to zoom, and hold Shift and + scroll to move sideways. +3. Use the keyboard and mouse as on a computer: V, R, T, ⌘D, ⌘G, right-click + for the context menu, Shift + click to select several layers, ⌥ + drag to + duplicate. +4. Drag a number field label (X, Y, W, rotation) past the screen edge. The value keeps changing instead of stopping at the edge. -3. Hold ⌘ and scroll to zoom. Switch to GeForce NOW from the ⋯ menu and back; - the Figma file reopens. -4. In GeForce NOW, start a game and click into it. The mouse turns the camera +5. Switch to GeForce NOW from the ⋯ menu and back; the Figma file reopens. +6. In GeForce NOW, start a game and click into it. The mouse turns the camera until you hold Esc for a second. -5. If something doesn't work: ⋯ ▸ Settings ▸ Copy diagnostics, and send it +7. If something doesn't work: ⋯ ▸ Settings ▸ Copy diagnostics, and send it with your feedback. **Feedback email:** mouselook@icebear.sk diff --git a/tests/PointerLockerTests/ServicePresentationTests.swift b/tests/PointerLockerTests/ServicePresentationTests.swift index a3c1f2b..40b5324 100644 --- a/tests/PointerLockerTests/ServicePresentationTests.swift +++ b/tests/PointerLockerTests/ServicePresentationTests.swift @@ -27,6 +27,10 @@ final class ServicePresentationTests: XCTestCase { XCTAssertEqual(tip?.title, "Set the stream to 1920×1080") } + func testFigmaLeadsWithDesktopNavigation() { + XCTAssertEqual(ServiceProfile.figma.setupTips.first?.id, "navigate") + } + func testFigmaTellsYouHowToScrub() { let tip = ServiceProfile.figma.setupTips.first { $0.id == "scrub" } XCTAssertEqual(tip?.title, "Drag a number's label to change it") From b7789938bf5261deb8c7f3318b5971abb824e374 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:02:49 +0000 Subject: [PATCH 2/4] Build 2 for the Figma navigation text The in-app Figma tip and Welcome text changed, so the next upload needs a new build number. Version stays 1.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W5tzx8GXgMz9DMzc5BF4ND --- project.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project.yml b/project.yml index 95c5a22..9995614 100644 --- a/project.yml +++ b/project.yml @@ -13,7 +13,7 @@ settings: # Change to your own team to build elsewhere. DEVELOPMENT_TEAM: AR9ZHABG8Y # IceBear s.r.o. MARKETING_VERSION: "1.0" - CURRENT_PROJECT_VERSION: "1" + CURRENT_PROJECT_VERSION: "2" targets: PointerLocker: From 4f812003ccf91b31e06f287fc8f8a1b0a35c9304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Pu=C5=A1k=C3=A1=C4=8D?= Date: Wed, 30 Sep 2026 12:38:50 +0200 Subject: [PATCH 3/4] Welcome: Create first, cards side by side on a full iPad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create leads: the Welcome cards, the chooser's groups and the ⋯ menu list Figma/Create before GeForce NOW/Play, matching the Figma-first positioning. - The cards stacked on a full-size iPad: ViewThatFits only put them side by side if both summaries fit on one line, and the new Create summary is longer. They now stack only at accessibility text sizes or in a narrow window (WelcomeLayout, with tests). Co-Authored-By: Claude Opus 5.5 --- .../Onboarding/WelcomeStep.swift | 25 ++++++++++++++----- .../Services/ServiceCategory.swift | 6 ++--- .../Services/ServiceProfile.swift | 2 +- .../ServiceCategoryTests.swift | 9 ++++--- .../PointerLockerTests/ServiceMenuTests.swift | 8 +++--- .../ServicePresentationTests.swift | 4 +-- .../WelcomeLayoutTests.swift | 18 +++++++++++++ 7 files changed, 52 insertions(+), 20 deletions(-) create mode 100644 tests/PointerLockerTests/WelcomeLayoutTests.swift diff --git a/Apps/PointerLocker/Onboarding/WelcomeStep.swift b/Apps/PointerLocker/Onboarding/WelcomeStep.swift index f69bc2c..791ef9d 100644 --- a/Apps/PointerLocker/Onboarding/WelcomeStep.swift +++ b/Apps/PointerLocker/Onboarding/WelcomeStep.swift @@ -1,8 +1,20 @@ import SwiftUI +/// When Welcome's two cards stack instead of sitting side by side: only for +/// accessibility text sizes and narrow windows. Measuring whether the text +/// fits on one line (ViewThatFits) stacked them on a full iPad as soon as a +/// summary grew longer than half the width. +enum WelcomeLayout { + static func stacksCards(dynamicTypeSize: DynamicTypeSize, horizontalSizeClass: UserInterfaceSizeClass?) -> Bool { + dynamicTypeSize.isAccessibilitySize || horizontalSizeClass == .compact + } +} + struct WelcomeStep: View { let services: [ServiceProfile] let onContinue: () -> Void + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.horizontalSizeClass) private var horizontalSizeClass var body: some View { // Scrolls when the text doesn't fit (a small window at a large text @@ -23,12 +35,13 @@ struct WelcomeStep: View { .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 520) - // Play and Create side by side, stacked when the text is large. - // Cards as tall as their text, and as tall as each other. - ViewThatFits(in: .horizontal) { - HStack(alignment: .top, spacing: 16) { worldCards } - VStack(spacing: 16) { worldCards } - } + // Create and Play side by side, stacked when the text is large + // or the window narrow. Cards as tall as their text, and as + // tall as each other. + let layout = WelcomeLayout.stacksCards(dynamicTypeSize: dynamicTypeSize, horizontalSizeClass: horizontalSizeClass) + ? AnyLayout(VStackLayout(spacing: 16)) + : AnyLayout(HStackLayout(alignment: .top, spacing: 16)) + layout { worldCards } .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: 640) } diff --git a/Apps/PointerLocker/Services/ServiceCategory.swift b/Apps/PointerLocker/Services/ServiceCategory.swift index f48d4e6..b20d7e9 100644 --- a/Apps/PointerLocker/Services/ServiceCategory.swift +++ b/Apps/PointerLocker/Services/ServiceCategory.swift @@ -1,7 +1,7 @@ /// The two worlds Mouselook serves, shown side by side on Welcome and as -/// headings in the chooser: cloud games, and design and 3D tools. +/// headings in the chooser: design and 3D tools first, then cloud games. enum ServiceCategory: CaseIterable { - case play, create + case create, play var title: String { switch self { @@ -42,7 +42,7 @@ enum ServiceCategory: CaseIterable { services.filter { $0.category == self }.map(\.name).joined(separator: ", ") } - /// Services grouped by world, Play first; worlds without services are left out. + /// Services grouped by world, Create first; worlds without services are left out. static func groups(of services: [ServiceProfile]) -> [(category: ServiceCategory, services: [ServiceProfile])] { allCases.compactMap { category in let members = services.filter { $0.category == category } diff --git a/Apps/PointerLocker/Services/ServiceProfile.swift b/Apps/PointerLocker/Services/ServiceProfile.swift index d8a6a70..4975eab 100644 --- a/Apps/PointerLocker/Services/ServiceProfile.swift +++ b/Apps/PointerLocker/Services/ServiceProfile.swift @@ -68,7 +68,7 @@ struct ServiceProfile { static let all: [ServiceProfile] = [.geforceNow, .figma, .generic] /// What onboarding and Settings offer. `generic` stays internal. - static let selectable: [ServiceProfile] = [.geforceNow, .figma] + static let selectable: [ServiceProfile] = [.figma, .geforceNow] static func profile(id: String) -> ServiceProfile? { all.first { $0.id == id } diff --git a/tests/PointerLockerTests/ServiceCategoryTests.swift b/tests/PointerLockerTests/ServiceCategoryTests.swift index 7120060..3bf4989 100644 --- a/tests/PointerLockerTests/ServiceCategoryTests.swift +++ b/tests/PointerLockerTests/ServiceCategoryTests.swift @@ -7,10 +7,11 @@ final class ServiceCategoryTests: XCTestCase { XCTAssertEqual(ServiceProfile.figma.category, .create) } - func testTheChooserGroupsPlayBeforeCreateAndHidesEmptyGroups() { - let groups = ServiceCategory.groups(of: [.figma, .geforceNow]) - XCTAssertEqual(groups.map(\.category), [.play, .create]) - XCTAssertEqual(groups.map { $0.services.map(\.id) }, [["geforcenow"], ["figma"]]) + func testCreateLeadsOnWelcomeAndInTheChooser() { + XCTAssertEqual(ServiceCategory.allCases, [.create, .play]) + let groups = ServiceCategory.groups(of: [.geforceNow, .figma]) + XCTAssertEqual(groups.map(\.category), [.create, .play]) + XCTAssertEqual(groups.map { $0.services.map(\.id) }, [["figma"], ["geforcenow"]]) XCTAssertEqual(ServiceCategory.groups(of: [.figma]).map(\.category), [.create], "no empty Play heading") } diff --git a/tests/PointerLockerTests/ServiceMenuTests.swift b/tests/PointerLockerTests/ServiceMenuTests.swift index 4d93d0f..10dac7e 100644 --- a/tests/PointerLockerTests/ServiceMenuTests.swift +++ b/tests/PointerLockerTests/ServiceMenuTests.swift @@ -45,10 +45,10 @@ final class ServiceMenuTests: XCTestCase { func testTheMenuListsTheServicesAndMarksTheCurrentOne() { let entries = ServiceSwitch.menuEntries(current: "figma") - XCTAssertEqual(entries.map(\.name), ["GeForce NOW", "Figma"]) - XCTAssertEqual(entries.map(\.subtitle), ["Play", "Create"]) - XCTAssertEqual(entries.map(\.isCurrent), [false, true]) - XCTAssertEqual(entries.map(\.symbol), [ServiceProfile.geforceNow.artwork.symbol, ServiceProfile.figma.artwork.symbol]) + XCTAssertEqual(entries.map(\.name), ["Figma", "GeForce NOW"]) + XCTAssertEqual(entries.map(\.subtitle), ["Create", "Play"]) + XCTAssertEqual(entries.map(\.isCurrent), [true, false]) + XCTAssertEqual(entries.map(\.symbol), [ServiceProfile.figma.artwork.symbol, ServiceProfile.geforceNow.artwork.symbol]) } func testNoServicesSectionWithOnlyOneService() { diff --git a/tests/PointerLockerTests/ServicePresentationTests.swift b/tests/PointerLockerTests/ServicePresentationTests.swift index 40b5324..3bae9eb 100644 --- a/tests/PointerLockerTests/ServicePresentationTests.swift +++ b/tests/PointerLockerTests/ServicePresentationTests.swift @@ -3,8 +3,8 @@ import XCTest @testable import PointerLocker final class ServicePresentationTests: XCTestCase { - func testOnboardingOffersGeForceNowAndFigmaButNotTheGenericProfile() { - XCTAssertEqual(ServiceProfile.selectable.map(\.id), ["geforcenow", "figma"]) + func testOnboardingOffersFigmaThenGeForceNowButNotTheGenericProfile() { + XCTAssertEqual(ServiceProfile.selectable.map(\.id), ["figma", "geforcenow"]) } func testEverySelectableServiceCanBePresented() { diff --git a/tests/PointerLockerTests/WelcomeLayoutTests.swift b/tests/PointerLockerTests/WelcomeLayoutTests.swift new file mode 100644 index 0000000..70f8083 --- /dev/null +++ b/tests/PointerLockerTests/WelcomeLayoutTests.swift @@ -0,0 +1,18 @@ +import SwiftUI +import XCTest +@testable import PointerLocker + +final class WelcomeLayoutTests: XCTestCase { + func testCardsSitSideBySideOnAFullIPadAtLargeTextSizes() { + XCTAssertFalse(WelcomeLayout.stacksCards(dynamicTypeSize: .large, horizontalSizeClass: .regular)) + XCTAssertFalse(WelcomeLayout.stacksCards(dynamicTypeSize: .xxxLarge, horizontalSizeClass: .regular)) + } + + func testCardsStackAtAccessibilityTextSizes() { + XCTAssertTrue(WelcomeLayout.stacksCards(dynamicTypeSize: .accessibility1, horizontalSizeClass: .regular)) + } + + func testCardsStackInANarrowWindow() { + XCTAssertTrue(WelcomeLayout.stacksCards(dynamicTypeSize: .large, horizontalSizeClass: .compact)) + } +} From fa00509d17958c34eefbdc258d1a5ffad483e1c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Pu=C5=A1k=C3=A1=C4=8D?= Date: Wed, 30 Sep 2026 12:38:50 +0200 Subject: [PATCH 4/4] Build 3: build 2 is already uploaded to App Store Connect Co-Authored-By: Claude Opus 5.5 --- project.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project.yml b/project.yml index 9995614..7365787 100644 --- a/project.yml +++ b/project.yml @@ -13,7 +13,7 @@ settings: # Change to your own team to build elsewhere. DEVELOPMENT_TEAM: AR9ZHABG8Y # IceBear s.r.o. MARKETING_VERSION: "1.0" - CURRENT_PROJECT_VERSION: "2" + CURRENT_PROJECT_VERSION: "3" targets: PointerLocker: