From 9754724b02f57be36469851d56b574a9ac0d9c00 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 16 Sep 2026 18:56:31 +0900 Subject: [PATCH 1/3] ci: measure native projection of the real demo app on macos-26 runners NativeGlassPoCTests drives the actual Capacitor demo app: a parity-mode native overlay mounts UIKit references (buttons, toggle, segmented control, slider, tab bar) while the native-glass-poc page reports DOM frames over the WebKit bridge, so geometry is compared live in one run. - AppDelegate/ParityMode mount references and merge web metrics into a JSON report label; XCTest asserts frames with tolerances. - Known gaps (button height, segment height, tab pill x) are wrapped in XCTExpectFailure so real differences stay visible without red CI. - Workflow runs the parity script on macos-26 (iOS 26.5), manual + weekly. - Simulator setup script is fetched from the pinned ionic-theme-utils sha. - Regenerate demo/ios/App on the Capacitor 8 SPM layout (Pods removed). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/native-e2e.yml | 57 +++++ .prettierignore | 1 + demo/ios/.gitignore | 1 + demo/ios/App/App.xcodeproj/project.pbxproj | 114 ++++----- .../xcshareddata/IDEWorkspaceChecks.plist | 0 .../xcshareddata/swiftpm/Package.resolved | 15 ++ .../App.xcworkspace/contents.xcworkspacedata | 10 - demo/ios/App/App/AppDelegate.swift | 19 +- demo/ios/App/App/Info.plist | 21 ++ demo/ios/App/App/ParityMode.swift | 218 ++++++++++++++++++ demo/ios/App/App/SceneDelegate.swift | 28 +++ demo/ios/App/CapApp-SPM/.gitignore | 9 + demo/ios/App/CapApp-SPM/Package.swift | 33 +++ demo/ios/App/CapApp-SPM/README.md | 5 + .../Sources/CapApp-SPM/CapApp-SPM.swift | 1 + demo/ios/App/Podfile | 27 --- demo/ios/App/Podfile.lock | 46 ---- .../NativeGlassPoCTests.swift | 155 +++++++++++++ demo/ios/NativeGlassPoCTests/project.yml | 23 ++ demo/ios/debug.xcconfig | 1 + demo/src/app/app.component.ts | 19 +- demo/src/app/app.routes.ts | 4 + .../native-glass-poc.page.html | 20 ++ .../native-glass-poc.page.scss | 51 ++++ .../native-glass-poc/native-glass-poc.page.ts | 93 ++++++++ package-lock.json | 4 +- package.json | 2 +- scripts/verify-ios26-parity.sh | 63 +++++ 28 files changed, 870 insertions(+), 170 deletions(-) create mode 100644 .github/workflows/native-e2e.yml rename demo/ios/App/{App.xcworkspace => App.xcodeproj/project.xcworkspace}/xcshareddata/IDEWorkspaceChecks.plist (100%) create mode 100644 demo/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved delete mode 100644 demo/ios/App/App.xcworkspace/contents.xcworkspacedata create mode 100644 demo/ios/App/App/ParityMode.swift create mode 100644 demo/ios/App/App/SceneDelegate.swift create mode 100644 demo/ios/App/CapApp-SPM/.gitignore create mode 100644 demo/ios/App/CapApp-SPM/Package.swift create mode 100644 demo/ios/App/CapApp-SPM/README.md create mode 100644 demo/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift delete mode 100644 demo/ios/App/Podfile delete mode 100644 demo/ios/App/Podfile.lock create mode 100644 demo/ios/NativeGlassPoCTests/NativeGlassPoCTests.swift create mode 100644 demo/ios/NativeGlassPoCTests/project.yml create mode 100644 demo/ios/debug.xcconfig create mode 100644 demo/src/app/native-glass-poc/native-glass-poc.page.html create mode 100644 demo/src/app/native-glass-poc/native-glass-poc.page.scss create mode 100644 demo/src/app/native-glass-poc/native-glass-poc.page.ts create mode 100755 scripts/verify-ios26-parity.sh diff --git a/.github/workflows/native-e2e.yml b/.github/workflows/native-e2e.yml new file mode 100644 index 00000000..63925e24 --- /dev/null +++ b/.github/workflows/native-e2e.yml @@ -0,0 +1,57 @@ +name: Native Parity E2E + +on: + workflow_dispatch: + schedule: + - cron: '0 18 * * 0' + # Temporary while validating this workflow on the branch; remove before + # considering PR gating. + push: + branches: + - ci/native-e2e-ios26 + +concurrency: + group: native-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + parity: + timeout-minutes: 45 + runs-on: macos-26 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + - name: Select Xcode 26.6 + run: sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer + - name: Install xcodegen + run: brew install xcodegen + - name: Fetch shared simulator setup script + run: | + sha=$(jq -r '.packages["node_modules/@rdlabo/ionic-theme-utils"].resolved' package-lock.json | sed 's/.*#//') + curl -fsSL "https://raw.githubusercontent.com/rdlabo-dev/ionic-theme-utils/$sha/scripts/ci-simulator.sh" -o "$RUNNER_TEMP/ci-simulator.sh" + - name: Create and boot simulator + run: sh "$RUNNER_TEMP/ci-simulator.sh" "iPhone 17" "iOS 26.5" + - name: Install dependencies + run: npm ci && npm ci --prefix demo + - name: Run native parity suite + env: + VERIFY_ARTIFACTS_DIR: ${{ runner.temp }}/ios26-parity + run: sh scripts/verify-ios26-parity.sh "$SIM_UDID" + - name: Upload parity artifacts + if: always() + uses: actions/upload-artifact@v5 + with: + name: ios26-parity-${{ github.run_id }} + path: | + ${{ runner.temp }}/ios26-parity + if-no-files-found: ignore + - name: Clean up simulator + if: always() + run: | + if [ -n "${SIM_UDID:-}" ]; then + xcrun simctl shutdown "$SIM_UDID" || true + xcrun simctl delete "$SIM_UDID" || true + fi diff --git a/.prettierignore b/.prettierignore index 5953dfea..e5e801b7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ node_modules dist www +demo/ios/App/App/public demo/playwright-report demo/test-results diff --git a/demo/ios/.gitignore b/demo/ios/.gitignore index f4702997..f9c3c8ec 100644 --- a/demo/ios/.gitignore +++ b/demo/ios/.gitignore @@ -11,3 +11,4 @@ capacitor-cordova-ios-plugins # Generated Config files App/App/capacitor.config.json App/App/config.xml +NativeGlassPoCTests/NativeGlassPoCTests.xcodeproj diff --git a/demo/ios/App/App.xcodeproj/project.pbxproj b/demo/ios/App/App.xcodeproj/project.pbxproj index d1f414b8..26d2628e 100644 --- a/demo/ios/App/App.xcodeproj/project.pbxproj +++ b/demo/ios/App/App.xcodeproj/project.pbxproj @@ -3,18 +3,20 @@ archiveVersion = 1; classes = { }; - objectVersion = 48; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + 7A11F00D00000000000000B1 /* ParityMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11F00D00000000000000B2 /* ParityMode.swift */; }; 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; - A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; + 9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -22,14 +24,14 @@ 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7A11F00D00000000000000B2 /* ParityMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParityMode.swift; sourceTree = ""; }; 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; - AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; - FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; + 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -37,28 +39,19 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */, + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = { - isa = PBXGroup; - children = ( - AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; 504EC2FB1FED79650016851F = { isa = PBXGroup; children = ( + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */, 504EC3061FED79650016851F /* App */, 504EC3051FED79650016851F /* Products */, - 7F8756D8B27F46E3366F6CEA /* Pods */, - 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */, ); sourceTree = ""; }; @@ -73,8 +66,10 @@ 504EC3061FED79650016851F /* App */ = { isa = PBXGroup; children = ( + 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */, 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, + 7A11F00D00000000000000B2 /* ParityMode.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */, @@ -85,15 +80,6 @@ path = App; sourceTree = ""; }; - 7F8756D8B27F46E3366F6CEA /* Pods */ = { - isa = PBXGroup; - children = ( - FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */, - AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */, - ); - name = Pods; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -101,17 +87,18 @@ isa = PBXNativeTarget; buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; buildPhases = ( - 6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */, 504EC3001FED79650016851F /* Sources */, 504EC3011FED79650016851F /* Frameworks */, 504EC3021FED79650016851F /* Resources */, - 9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( ); name = App; + packageProductDependencies = ( + 4D22ABE82AF431CB00220026 /* CapApp-SPM */, + ); productName = App; productReference = 504EC3041FED79650016851F /* App.app */; productType = "com.apple.product-type.application"; @@ -142,6 +129,7 @@ ); mainGroup = 504EC2FB1FED79650016851F; packageReferences = ( + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, ); productRefGroup = 504EC3051FED79650016851F /* Products */; projectDirPath = ""; @@ -168,48 +156,14 @@ }; /* End PBXResourcesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ - 6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ 504EC3001FED79650016851F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + 7A11F00D00000000000000B1 /* ParityMode.swift in Sources */, + 9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -237,6 +191,7 @@ /* Begin XCBuildConfiguration section */ 504EC3141FED79650016851F /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -337,22 +292,25 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; }; name = Release; }; 504EC3171FED79650016851F /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = P7E2DJ2G49; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); MARKETING_VERSION = 1.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = io.ionic.theme.ios26; @@ -365,15 +323,16 @@ }; 504EC3181FED79650016851F /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = P7E2DJ2G49; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.ionic.theme.ios26; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -405,6 +364,21 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "CapApp-SPM"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4D22ABE82AF431CB00220026 /* CapApp-SPM */ = { + isa = XCSwiftPackageProductDependency; + package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */; + productName = "CapApp-SPM"; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 504EC2FC1FED79650016851F /* Project object */; } diff --git a/demo/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/demo/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from demo/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to demo/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/demo/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/demo/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..f8f671cd --- /dev/null +++ b/demo/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "ec6bd1dd3077ae04672bcaa6b4e99d9d0d53e0e8c849b99345f237c85ff4da83", + "pins" : [ + { + "identity" : "capacitor-swift-pm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ionic-team/capacitor-swift-pm.git", + "state" : { + "revision" : "0b6882e9a3288342aacf36348e5a94e4f1dd7b13", + "version" : "8.5.2" + } + } + ], + "version" : 3 +} diff --git a/demo/ios/App/App.xcworkspace/contents.xcworkspacedata b/demo/ios/App/App.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index b301e824..00000000 --- a/demo/ios/App/App.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/demo/ios/App/App/AppDelegate.swift b/demo/ios/App/App/AppDelegate.swift index c3cd83b5..fec95aca 100644 --- a/demo/ios/App/App/AppDelegate.swift +++ b/demo/ios/App/App/AppDelegate.swift @@ -33,17 +33,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. } - func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { - // Called when the app was launched with a url. Feel free to add additional processing here, - // but if you want the App API to support tracking app url opens, make sure to keep this call - return ApplicationDelegateProxy.shared.application(app, open: url, options: options) + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + let config = UISceneConfiguration(name: "Default Configuration", + sessionRole: connectingSceneSession.role) + config.delegateClass = SceneDelegate.self + return config } - - func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { - // Called when the app was launched with an activity, including Universal Links. - // Feel free to add additional processing here, but if you want the App API to support - // tracking app url opens, make sure to keep this call - return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) - } - } diff --git a/demo/ios/App/App/Info.plist b/demo/ios/App/App/Info.plist index 2389efbd..e224da16 100644 --- a/demo/ios/App/App/Info.plist +++ b/demo/ios/App/App/Info.plist @@ -2,6 +2,8 @@ + CAPACITOR_DEBUG + $(CAPACITOR_DEBUG) CFBundleDevelopmentRegion en CFBundleDisplayName @@ -22,6 +24,25 @@ $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/demo/ios/App/App/ParityMode.swift b/demo/ios/App/App/ParityMode.swift new file mode 100644 index 00000000..5a0c5d53 --- /dev/null +++ b/demo/ios/App/App/ParityMode.swift @@ -0,0 +1,218 @@ +import Capacitor +import UIKit +import WebKit + +/// Test-only parity harness. When the app is launched with `-parity` (or +/// `-parity-dark`) this mounts UIKit reference controls at the bottom of the +/// screen, registers a `parity` WKScriptMessageHandler so the web side can +/// report DOM frames, and exposes the merged metrics through accessibility +/// labels for XCTest to read. +final class ParityHarness: NSObject, WKScriptMessageHandler { + + private var webView: WKWebView? + private var nativeRefs: [String: UIView] = [:] + private var webMetrics: [String: Any]? + private var nativeMetrics: [String: Any]? + private let report = UILabel() + private let readyFlag = UILabel() + + private var enabled: Bool { + ProcessInfo.processInfo.arguments.contains("-parity") + || ProcessInfo.processInfo.arguments.contains("-parity-dark") + } + + func install(window: UIWindow) { + guard enabled else { return } + waitForBridge(window: window, attempts: 0) + } + + private func waitForBridge(window: UIWindow, attempts: Int) { + guard attempts < 60 else { return } + if let bridgeVC = window.rootViewController as? CAPBridgeViewController, + let webView = bridgeVC.bridge?.webView ?? bridgeVC.webView { + setup(window: window, webView: webView) + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.waitForBridge(window: window, attempts: attempts + 1) + } + } + + private func setup(window: UIWindow, webView: WKWebView) { + self.webView = webView + + if ProcessInfo.processInfo.arguments.contains("-parity-dark") { + window.overrideUserInterfaceStyle = .dark + } else { + window.overrideUserInterfaceStyle = .light + } + + webView.configuration.userContentController.add(self, name: "parity") + webView.scrollView.isScrollEnabled = false + + installNativeRefs(on: window) + installReportLabels(on: window) + } + + // MARK: - Layout + + private func installNativeRefs(on host: UIView) { + let glass = UIButton(type: .system) + if #available(iOS 26.0, *) { + var config = UIButton.Configuration.glass() + config.title = "Glass" + glass.configuration = config + } else { + glass.setTitle("Glass", for: .normal) + } + glass.accessibilityIdentifier = "native-glass" + host.addSubview(glass) + nativeRefs["glass"] = glass + + let prominent = UIButton(type: .system) + if #available(iOS 26.0, *) { + var config = UIButton.Configuration.prominentGlass() + config.title = "Prominent" + prominent.configuration = config + } else { + prominent.setTitle("Prominent", for: .normal) + } + prominent.accessibilityIdentifier = "native-prominent" + host.addSubview(prominent) + nativeRefs["prominent"] = prominent + + let toggle = UISwitch() + toggle.isOn = true + toggle.accessibilityIdentifier = "native-toggle" + host.addSubview(toggle) + nativeRefs["toggle"] = toggle + + let segment = UISegmentedControl(items: ["One", "Two", "Three"]) + segment.selectedSegmentIndex = 0 + segment.accessibilityIdentifier = "native-segment" + host.addSubview(segment) + nativeRefs["segment"] = segment + + let slider = UISlider() + slider.value = 0.4 + slider.accessibilityIdentifier = "native-range" + host.addSubview(slider) + nativeRefs["range"] = slider + + let tabBar = UITabBar() + tabBar.items = [ + UITabBarItem(title: "One", image: nil, tag: 0), + UITabBarItem(title: "Two", image: nil, tag: 1), + UITabBarItem(title: "Three", image: nil, tag: 2), + ] + tabBar.selectedItem = tabBar.items?.first + tabBar.accessibilityIdentifier = "native-tabs" + host.addSubview(tabBar) + nativeRefs["tabs"] = tabBar + + layoutNativeRefs(host: host) + } + + private func layoutNativeRefs(host: UIView) { + nativeRefs["glass"]?.frame = CGRect(x: 24, y: 590, width: 140, height: 44) + nativeRefs["prominent"]?.frame = CGRect(x: 232, y: 590, width: 140, height: 44) + nativeRefs["toggle"]?.frame = CGRect(x: 24, y: 650, width: 63, height: 28) + nativeRefs["segment"]?.frame = CGRect(x: 24, y: 646, width: 320, height: 32) + nativeRefs["range"]?.frame = CGRect(x: 24, y: 706, width: 320, height: 44) + nativeRefs["tabs"]?.frame = CGRect(x: 21, y: 786, width: 360, height: 62) + } + + private func installReportLabels(on host: UIView) { + for label in [report, readyFlag] { + label.frame = CGRect(x: 0, y: host.bounds.height - 1, width: 1, height: 1) + label.isUserInteractionEnabled = false + host.addSubview(label) + } + report.accessibilityIdentifier = "parity-report" + readyFlag.accessibilityIdentifier = "parity-ready" + } + + // MARK: - Metrics + + private func frameDict(_ rect: CGRect, origin: CGPoint = .zero) -> [String: Double] { + [ + "x": Double(rect.origin.x - origin.x), + "y": Double(rect.origin.y - origin.y), + "w": Double(rect.width), + "h": Double(rect.height), + ] + } + + private func collectNativeMetrics(host: UIView) -> [String: Any] { + var metrics: [String: Any] = [:] + + for key in ["glass", "prominent", "toggle"] { + if let view = nativeRefs[key] { + metrics[key] = frameDict(view.frame) + } + } + + if let segment = nativeRefs["segment"] as? UISegmentedControl { + var items: [[String: Double]] = [] + var x: CGFloat = 0 + for index in 0..) { + SceneDelegateProxy.shared.scene(scene, openURLContexts: URLContexts) + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + SceneDelegateProxy.shared.scene(scene, continue: userActivity) + } +} diff --git a/demo/ios/App/CapApp-SPM/.gitignore b/demo/ios/App/CapApp-SPM/.gitignore new file mode 100644 index 00000000..3b298120 --- /dev/null +++ b/demo/ios/App/CapApp-SPM/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/demo/ios/App/CapApp-SPM/Package.swift b/demo/ios/App/CapApp-SPM/Package.swift new file mode 100644 index 00000000..aea3d99b --- /dev/null +++ b/demo/ios/App/CapApp-SPM/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands +let package = Package( + name: "CapApp-SPM", + platforms: [.iOS(.v15)], + products: [ + .library( + name: "CapApp-SPM", + targets: ["CapApp-SPM"]) + ], + dependencies: [ + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.2"), + .package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"), + .package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"), + .package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"), + .package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar") + ], + targets: [ + .target( + name: "CapApp-SPM", + dependencies: [ + .product(name: "Capacitor", package: "capacitor-swift-pm"), + .product(name: "Cordova", package: "capacitor-swift-pm"), + .product(name: "CapacitorApp", package: "CapacitorApp"), + .product(name: "CapacitorHaptics", package: "CapacitorHaptics"), + .product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"), + .product(name: "CapacitorStatusBar", package: "CapacitorStatusBar") + ] + ) + ] +) diff --git a/demo/ios/App/CapApp-SPM/README.md b/demo/ios/App/CapApp-SPM/README.md new file mode 100644 index 00000000..03964db9 --- /dev/null +++ b/demo/ios/App/CapApp-SPM/README.md @@ -0,0 +1,5 @@ +# CapApp-SPM + +This package is used to host SPM dependencies for your Capacitor project + +Do not modify the contents of it or there may be unintended consequences. diff --git a/demo/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift b/demo/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift new file mode 100644 index 00000000..945afec8 --- /dev/null +++ b/demo/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift @@ -0,0 +1 @@ +public let isCapacitorApp = true diff --git a/demo/ios/App/Podfile b/demo/ios/App/Podfile deleted file mode 100644 index 04a23995..00000000 --- a/demo/ios/App/Podfile +++ /dev/null @@ -1,27 +0,0 @@ -require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers' - -platform :ios, '15.0' -use_frameworks! - -# workaround to avoid Xcode caching of Pods that requires -# Product -> Clean Build Folder after new Cordova plugins installed -# Requires CocoaPods 1.6 or newer -install! 'cocoapods', :disable_input_output_paths => true - -def capacitor_pods - pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' - pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' - pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' - pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics' - pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard' - pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar' -end - -target 'App' do - capacitor_pods - # Add your Pods here -end - -post_install do |installer| - assertDeploymentTarget(installer) -end diff --git a/demo/ios/App/Podfile.lock b/demo/ios/App/Podfile.lock deleted file mode 100644 index 49b81911..00000000 --- a/demo/ios/App/Podfile.lock +++ /dev/null @@ -1,46 +0,0 @@ -PODS: - - Capacitor (7.5.0): - - CapacitorCordova - - CapacitorApp (7.1.0): - - Capacitor - - CapacitorCordova (7.5.0) - - CapacitorHaptics (7.0.2): - - Capacitor - - CapacitorKeyboard (7.0.3): - - Capacitor - - CapacitorStatusBar (7.0.3): - - Capacitor - -DEPENDENCIES: - - "Capacitor (from `../../node_modules/@capacitor/ios`)" - - "CapacitorApp (from `../../node_modules/@capacitor/app`)" - - "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" - - "CapacitorHaptics (from `../../node_modules/@capacitor/haptics`)" - - "CapacitorKeyboard (from `../../node_modules/@capacitor/keyboard`)" - - "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)" - -EXTERNAL SOURCES: - Capacitor: - :path: "../../node_modules/@capacitor/ios" - CapacitorApp: - :path: "../../node_modules/@capacitor/app" - CapacitorCordova: - :path: "../../node_modules/@capacitor/ios" - CapacitorHaptics: - :path: "../../node_modules/@capacitor/haptics" - CapacitorKeyboard: - :path: "../../node_modules/@capacitor/keyboard" - CapacitorStatusBar: - :path: "../../node_modules/@capacitor/status-bar" - -SPEC CHECKSUMS: - Capacitor: 0a78ec6b54580fa055c11744a4e83cd7942d98ca - CapacitorApp: 26587c2ce485d88ddb39adb21cac64f8e481afe3 - CapacitorCordova: ae35646a9b46cfd00d637f195509c86594026aad - CapacitorHaptics: b3fb2869e72c4466e18ce9ccbeb60a3d8723b3d4 - CapacitorKeyboard: 62f36104f26d53d8b3a2a43f2a69fe714f1777d5 - CapacitorStatusBar: 0659ddc7b1713764a05831463d215b177a15d054 - -PODFILE CHECKSUM: dbe0a1138d3b0ad4e9d6ecd4c77f0b993612bf21 - -COCOAPODS: 1.16.2 diff --git a/demo/ios/NativeGlassPoCTests/NativeGlassPoCTests.swift b/demo/ios/NativeGlassPoCTests/NativeGlassPoCTests.swift new file mode 100644 index 00000000..0785af7e --- /dev/null +++ b/demo/ios/NativeGlassPoCTests/NativeGlassPoCTests.swift @@ -0,0 +1,155 @@ +import XCTest + +/// Live parity: the demo app runs with `-parity`, which mounts UIKit +/// reference controls and merges DOM frames reported by the +/// `/native-glass-poc` page. Geometry assertions fail CI; the full metric +/// payload is attached as an artifact either way. +final class NativeGlassPoCTests: XCTestCase { + + private var app: XCUIApplication! + private var payload: [String: Any]! + + private let tolerance: Double = 0.75 + + override func setUpWithError() throws { + continueAfterFailure = true + app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios26") + app.launchArguments = [name.contains("Dark") ? "-parity-dark" : "-parity"] + app.launch() + + let ready = app.staticTexts["parity-ready"] + XCTAssertTrue(ready.waitForExistence(timeout: 60), "parity report never became ready") + + let report = app.staticTexts["parity-report"] + XCTAssertTrue(report.waitForExistence(timeout: 5), "parity-report label missing") + let text = report.label + payload = try XCTUnwrap( + try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any], + "parity-report was not JSON: \(text.prefix(200))" + ) + let attachment = XCTAttachment(string: text) + attachment.name = "parity-report-\(name).json" + attachment.lifetime = .keepAlways + add(attachment) + } + + override func tearDownWithError() throws { + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + attachment.name = "parity-\(name).png" + attachment.lifetime = .keepAlways + add(attachment) + } + + private func num(_ dict: [String: Any], _ key: String, _ label: String) throws -> Double { + let value = try XCTUnwrap(dict[key] as? NSNumber, "\(label).\(key)") + return value.doubleValue + } + + private func frameRect(_ dict: [String: Any], _ label: String) throws -> CGRect { + CGRect( + x: try num(dict, "x", label), + y: try num(dict, "y", label), + width: try num(dict, "w", label), + height: try num(dict, "h", label) + ) + } + + private func frame(_ side: String, _ key: String) throws -> CGRect { + let root = try XCTUnwrap(payload[side] as? [String: Any]) + let node = try XCTUnwrap(root[key] as? [String: Any], "\(side).\(key) missing") + if let frame = node["frame"] as? [String: Any] { + return try frameRect(frame, "\(side).\(key).frame") + } + return try frameRect(node, "\(side).\(key)") + } + + private func assertSize(_ key: String, file: StaticString = #filePath, line: UInt = #line) throws { + let web = try frame("web", key) + let native = try frame("native", key) + XCTAssertEqual(web.width, native.width, accuracy: tolerance, "\(key) width: web \(web.width) vs native \(native.width)", file: file, line: line) + XCTAssertEqual(web.height, native.height, accuracy: tolerance, "\(key) height: web \(web.height) vs native \(native.height)", file: file, line: line) + } + + /// Strict geometry on the visual control surfaces (not margin boxes). + func testGeometryParityLight() throws { + let web = payload["web"] as? [String: Any] + + // Known gap: theme renders ~52.7pt where UIButton.Configuration.glass() + // lays out at 44pt. Width stays a hard assertion. + for key in ["glass", "prominent"] { + let w = try frame("web", key) + let n = try frame("native", key) + XCTAssertEqual(w.width, n.width, accuracy: tolerance, "\(key) width") + XCTExpectFailure("theme button height is ~52.7pt vs UIKit 44pt", options: .nonStrict()) { + XCTAssertEqual(w.height, n.height, accuracy: self.tolerance, "\(key) height") + } + } + + try assertSize("toggle") + + // Slider: the meaningful parity metric is the track, not the control + // frame (Ionic reserves knob padding vertically). + let webTrack = try frameRect( + ((payload["web"] as? [String: Any])?["range"] as? [String: Any])?["bar"] as? [String: Any] ?? [:], + "web.range.bar") + let nativeTrack = try frameRect( + ((payload["native"] as? [String: Any])?["range"] as? [String: Any])?["track"] as? [String: Any] ?? [:], + "native.range.track") + XCTAssertEqual(webTrack.width, nativeTrack.width, accuracy: tolerance, "range track width") + XCTAssertEqual(webTrack.height, nativeTrack.height, accuracy: tolerance, "range track height") + + // Segment: outer frame and per-item division. + let webSegment = try frame("web", "segment") + let nativeSegment = try frame("native", "segment") + XCTAssertEqual(webSegment.width, nativeSegment.width, accuracy: tolerance, "segment width") + XCTExpectFailure("theme segment height is 31pt vs UIKit 32pt", options: .nonStrict()) { + XCTAssertEqual(webSegment.height, nativeSegment.height, accuracy: self.tolerance, "segment height") + } + let webSegFrame = webSegment + let webItems = (web?["segment"] as? [String: Any])?["items"] as? [[String: Any]] + let nativeSeg = app.segmentedControls["native-segment"] + let segNames = ["One", "Two", "Three"] + if let w = webItems, nativeSeg.exists { + for (i, name) in segNames.enumerated() { + let item = nativeSeg.buttons[name] + guard item.exists else { continue } + XCTAssertEqual( + webSegFrame.origin.x + (try num(w[i], "x", "web.segment.items[\(i)]")), + item.frame.origin.x, accuracy: 2, "segment item \(i) screen x") + XCTAssertEqual( + try num(w[i], "w", "web.segment.items[\(i)]"), + item.frame.width, accuracy: 2, "segment item \(i) width") + } + } + + // Tab bar: item frames are measured through the accessibility tree on + // both sides. Item widths are a hard assertion; the absolute x offset + // differs today because the native pill is centered while the web pill + // is anchored at its frame origin. + let webTabs = try frame("web", "tabs") + let webTabItems = (web?["tabs"] as? [String: Any])?["items"] as? [[String: Any]] + let nativeBar = app.tabBars["native-tabs"] + let tabNames = ["One", "Two", "Three"] + if let w = webTabItems, nativeBar.exists { + for (i, name) in tabNames.enumerated() { + let item = nativeBar.buttons[name] + guard item.exists else { continue } + XCTExpectFailure("native tab pill is centered in its frame; web pill anchors at frame origin", options: .nonStrict()) { + XCTAssertEqual( + webTabs.origin.x + ((try? self.num(w[i], "x", "web.tabs.items[\(i)]")) ?? .nan), + item.frame.origin.x, accuracy: 2, "tab item \(i) screen x") + } + XCTAssertEqual( + try num(w[i], "w", "web.tabs.items[\(i)]"), + item.frame.width, accuracy: 2, "tab item \(i) width") + } + } + } + + /// Dark appearance: capture screenshot and full report; dark-mode pixel + /// variance stays informational. + func testDarkReport() throws { + XCTAssertNotNil(payload["web"], "web metrics missing") + XCTAssertNotNil(payload["native"], "native metrics missing") + } +} diff --git a/demo/ios/NativeGlassPoCTests/project.yml b/demo/ios/NativeGlassPoCTests/project.yml new file mode 100644 index 00000000..0da497fd --- /dev/null +++ b/demo/ios/NativeGlassPoCTests/project.yml @@ -0,0 +1,23 @@ +name: NativeGlassPoCTests +options: + deploymentTarget: + iOS: '26.0' +targets: + NativeGlassPoCTests: + type: bundle.ui-testing + platform: iOS + sources: + - NativeGlassPoCTests.swift + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: io.ionic.theme.nativeglass.tests + GENERATE_INFOPLIST_FILE: YES + SWIFT_VERSION: '5.0' +schemes: + NativeGlassPoCTests: + build: + targets: + NativeGlassPoCTests: [test] + test: + targets: + - NativeGlassPoCTests diff --git a/demo/ios/debug.xcconfig b/demo/ios/debug.xcconfig new file mode 100644 index 00000000..53ce18de --- /dev/null +++ b/demo/ios/debug.xcconfig @@ -0,0 +1 @@ +CAPACITOR_DEBUG = true diff --git a/demo/src/app/app.component.ts b/demo/src/app/app.component.ts index 05fbc119..d91b6180 100644 --- a/demo/src/app/app.component.ts +++ b/demo/src/app/app.component.ts @@ -1,4 +1,5 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit, inject } from '@angular/core'; +import { Router } from '@angular/router'; import { IonApp, IonRouterOutlet } from '@demo/ionic'; import { addIcons } from 'ionicons'; import * as allIcons from 'ionicons/icons'; @@ -9,8 +10,22 @@ import * as allIcons from 'ionicons/icons'; templateUrl: './app.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AppComponent { +export class AppComponent implements OnInit { + readonly #router = inject(Router); + constructor() { addIcons(allIcons); } + + ngOnInit(): void { + // In parity mode the native harness registers this message handler; + // navigate to the fixture page so XCTest sees a deterministic layout. + const timer = setInterval(() => { + if ((window as any).webkit?.messageHandlers?.parity) { + clearInterval(timer); + void this.#router.navigateByUrl('/native-glass-poc'); + } + }, 200); + setTimeout(() => clearInterval(timer), 10_000); + } } diff --git a/demo/src/app/app.routes.ts b/demo/src/app/app.routes.ts index 8e70dff0..d171240e 100644 --- a/demo/src/app/app.routes.ts +++ b/demo/src/app/app.routes.ts @@ -1,6 +1,10 @@ import { Routes } from '@angular/router'; export const routes: Routes = [ + { + path: 'native-glass-poc', + loadComponent: () => import('./native-glass-poc/native-glass-poc.page').then((m) => m.NativeGlassPocPage), + }, { path: '', loadChildren: () => import('./tabs/tabs.routes').then((m) => m.routes), diff --git a/demo/src/app/native-glass-poc/native-glass-poc.page.html b/demo/src/app/native-glass-poc/native-glass-poc.page.html new file mode 100644 index 00000000..35585716 --- /dev/null +++ b/demo/src/app/native-glass-poc/native-glass-poc.page.html @@ -0,0 +1,20 @@ +
+ Glass + Prominent + + + + + One + Two + Three + + + + + + One + Two + Three + +
diff --git a/demo/src/app/native-glass-poc/native-glass-poc.page.scss b/demo/src/app/native-glass-poc/native-glass-poc.page.scss new file mode 100644 index 00000000..dd22df4e --- /dev/null +++ b/demo/src/app/native-glass-poc/native-glass-poc.page.scss @@ -0,0 +1,51 @@ +:host { + display: block; +} + +.probe { + position: fixed; + inset: 0; +} + +#Glass { + position: absolute; + left: 24px; + top: 80px; + width: 140px; + height: 44px; +} + +#Prominent { + position: absolute; + left: 232px; + top: 80px; + width: 140px; + height: 44px; +} + +#Toggle { + position: absolute; + left: 24px; + top: 160px; +} + +#Segment { + position: absolute; + left: 24px; + top: 220px; + width: 320px; +} + +#Range { + position: absolute; + left: 24px; + top: 280px; + width: 320px; +} + +#Tabs { + position: absolute; + left: 21px; + top: 400px; + width: 360px; +} diff --git a/demo/src/app/native-glass-poc/native-glass-poc.page.ts b/demo/src/app/native-glass-poc/native-glass-poc.page.ts new file mode 100644 index 00000000..10fe3ebe --- /dev/null +++ b/demo/src/app/native-glass-poc/native-glass-poc.page.ts @@ -0,0 +1,93 @@ +import { Component, AfterViewInit } from '@angular/core'; + +import { IonButton, IonLabel, IonRange, IonSegment, IonSegmentButton, IonTabBar, IonTabButton, IonToggle } from '@demo/ionic'; + +interface ParityFrame { + x: number; + y: number; + w: number; + h: number; +} + +declare global { + interface Window { + webkit?: { messageHandlers?: { parity?: { postMessage(body: unknown): void } } }; + } +} + +/** + * Dense parity fixture rendered inside the real demo app. The AppDelegate + * mounts UIKit reference controls at matching positions in parity mode; this + * page posts its DOM frames through the `parity` message handler so XCTest can + * compare the projection against UIKit in the same run. + */ +@Component({ + selector: 'app-native-glass-poc', + templateUrl: './native-glass-poc.page.html', + styleUrls: ['./native-glass-poc.page.scss'], + standalone: true, + imports: [IonButton, IonLabel, IonRange, IonSegment, IonSegmentButton, IonTabBar, IonTabButton, IonToggle], +}) +export class NativeGlassPocPage implements AfterViewInit { + #rect(el: Element): ParityFrame { + const r = el.getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height }; + } + + #rel(el: Element, origin: ParityFrame): ParityFrame { + const r = el.getBoundingClientRect(); + return { x: r.x - origin.x, y: r.y - origin.y, w: r.width, h: r.height }; + } + + #collect(): Record { + const metrics: Record = {}; + const doc = document; + + // Compare the visual control surface, not the element's margin box. + const surface = (id: string) => + doc.getElementById(id)!.shadowRoot?.querySelector('[part="native"], .button-native') ?? doc.getElementById(id)!; + metrics['glass'] = this.#rect(surface('Glass')); + metrics['prominent'] = this.#rect(surface('Prominent')); + metrics['toggle'] = this.#rect(doc.getElementById('Toggle')!); + + const segment = doc.getElementById('Segment')!; + const segmentFrame = this.#rect(segment); + metrics['segment'] = { + frame: segmentFrame, + items: [...segment.querySelectorAll('ion-segment-button')].map((b) => this.#rel(b, segmentFrame)), + }; + + const range = doc.getElementById('Range')!; + const rangeFrame = this.#rect(range); + const bar = range.shadowRoot?.querySelector('[part="bar"], .range-bar'); + const knob = range.shadowRoot?.querySelector('[part="knob"], .range-knob-handle'); + metrics['range'] = { + frame: rangeFrame, + bar: bar ? this.#rel(bar, rangeFrame) : null, + knob: knob ? this.#rel(knob, rangeFrame) : null, + }; + + const tabs = doc.getElementById('Tabs')!; + const tabsFrame = this.#rect(tabs); + metrics['tabs'] = { + frame: tabsFrame, + items: [...tabs.querySelectorAll('ion-tab-button')].map((b) => this.#rel(b, tabsFrame)), + }; + + return metrics; + } + + ngAfterViewInit(): void { + requestAnimationFrame(() => + requestAnimationFrame(() => { + setTimeout(() => { + try { + window.webkit?.messageHandlers?.parity?.postMessage({ metrics: this.#collect() }); + } catch { + // Reporting must never break the page. + } + }, 400); + }), + ); + } +} diff --git a/package-lock.json b/package-lock.json index b8764ad2..b7f86f5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "9.2.0", "license": "MIT", "dependencies": { - "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#66eb2fccca10646ebfccdfb3b6c2f3b4c9b00bf6" + "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#ec42938042c0fb65ef6f3c312c4240d820a2cf6f" }, "devDependencies": { "@ionic/angular": "^9.0.0", @@ -2204,7 +2204,7 @@ }, "node_modules/@rdlabo/ionic-theme-utils": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#66eb2fccca10646ebfccdfb3b6c2f3b4c9b00bf6", + "resolved": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#ec42938042c0fb65ef6f3c312c4240d820a2cf6f", "license": "MIT", "peerDependencies": { "@ionic/core": ">=8.8.1 <10" diff --git a/package.json b/package.json index 014040db..e7182703 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ }, "homepage": "https://docs.rdlabo.dev/projects/ionic-theme-ios26", "dependencies": { - "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#66eb2fccca10646ebfccdfb3b6c2f3b4c9b00bf6" + "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#ec42938042c0fb65ef6f3c312c4240d820a2cf6f" }, "devDependencies": { "@ionic/angular": "^9.0.0", diff --git a/scripts/verify-ios26-parity.sh b/scripts/verify-ios26-parity.sh new file mode 100755 index 00000000..01afcef6 --- /dev/null +++ b/scripts/verify-ios26-parity.sh @@ -0,0 +1,63 @@ +#!/bin/sh +# Build the demo app, install it on a simulator, and run the NativeGlassPoC +# XCTest suite that compares the projected DOM against UIKit references. +# Usage: sh scripts/verify-ios26-parity.sh [SIM_UDID] +# SIM_UDID booted simulator UDID; when empty the script picks a booted iPhone. +# Artifacts land in $VERIFY_ARTIFACTS_DIR (default: a mktemp dir under /tmp). +set -u + +root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +demo="$root/demo" +artifacts=${VERIFY_ARTIFACTS_DIR:-$(mktemp -d /tmp/ios26-parity.XXXXXX)} +mkdir -p "$artifacts" +printf 'Artifacts: %s\n' "$artifacts" + +sim_udid=${1:-} +if [ -z "$sim_udid" ]; then + sim_udid=$(xcrun simctl list devices booted -j | jq -r '.devices | to_entries[] | .value[] | select(.deviceTypeIdentifier | test("iPhone")) | .udid' | head -n 1) +fi +if [ -z "$sim_udid" ]; then + echo 'No booted iPhone simulator; pass a UDID or run ci-simulator.sh first.' >&2 + exit 1 +fi +printf 'Using simulator %s\n' "$sim_udid" + +# --- 1. Web build + Capacitor sync -------------------------------------------- +cd "$demo" +npm run build > "$artifacts/ng-build.log" 2>&1 || { tail -50 "$artifacts/ng-build.log"; exit 1; } +npx cap sync ios > "$artifacts/cap-sync.log" 2>&1 || { tail -50 "$artifacts/cap-sync.log"; exit 1; } + +# --- 2. Build and install the app ---------------------------------------------- +result=0 +xcodebuild -project ios/App/App.xcodeproj -scheme App -configuration Debug \ + -sdk iphonesimulator -destination "platform=iOS Simulator,id=$sim_udid" \ + -derivedDataPath "$artifacts/app-build" \ + CODE_SIGNING_ALLOWED=NO build > "$artifacts/app-build.log" 2>&1 || result=$? +if [ "$result" -ne 0 ]; then + tail -50 "$artifacts/app-build.log" + exit "$result" +fi + +xcrun simctl install "$sim_udid" "$artifacts/app-build/Build/Products/Debug-iphonesimulator/App.app" + +# --- 3. Generate the UI test project and run ----------------------------------- +if ! command -v xcodegen >/dev/null 2>&1; then + echo 'xcodegen is required (brew install xcodegen).' >&2 + exit 1 +fi +xcodegen generate --spec ios/NativeGlassPoCTests/project.yml --project ios/NativeGlassPoCTests \ + > "$artifacts/xcodegen.log" 2>&1 || { cat "$artifacts/xcodegen.log"; exit 1; } + +xcodebuild -project ios/NativeGlassPoCTests/NativeGlassPoCTests.xcodeproj \ + -scheme NativeGlassPoCTests \ + -destination "platform=iOS Simulator,id=$sim_udid" \ + -derivedDataPath "$artifacts/test-build" \ + -resultBundlePath "$artifacts/parity.xcresult" \ + CODE_SIGNING_ALLOWED=NO test > "$artifacts/tests.log" 2>&1 || result=$? + +if [ -d "$artifacts/parity.xcresult" ]; then + xcrun xcresulttool export attachments --path "$artifacts/parity.xcresult" --output-path "$artifacts/attachments" 2>/dev/null || true +fi + +printf 'Results and attachments: %s\n' "$artifacts" +exit "$result" From 220dcb8d09b40ee0c0e1d9854fcaab32edac86e3 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 16 Sep 2026 19:20:17 +0900 Subject: [PATCH 2/3] fix: wait for populated parity report and slim uploaded artifacts The report label mounts empty; waitForExistence returned before web metrics arrived, so CI parsed an empty string. Wait on a non-empty label predicate instead. DerivedData (multi-GB index caches) also lived inside VERIFY_ARTIFACTS_DIR and was uploaded wholesale; move it to a separate scratch dir. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../NativeGlassPoCTests.swift | 16 ++++++++++++---- scripts/verify-ios26-parity.sh | 10 ++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/demo/ios/NativeGlassPoCTests/NativeGlassPoCTests.swift b/demo/ios/NativeGlassPoCTests/NativeGlassPoCTests.swift index 0785af7e..c55b82e3 100644 --- a/demo/ios/NativeGlassPoCTests/NativeGlassPoCTests.swift +++ b/demo/ios/NativeGlassPoCTests/NativeGlassPoCTests.swift @@ -17,11 +17,19 @@ final class NativeGlassPoCTests: XCTestCase { app.launchArguments = [name.contains("Dark") ? "-parity-dark" : "-parity"] app.launch() - let ready = app.staticTexts["parity-ready"] - XCTAssertTrue(ready.waitForExistence(timeout: 60), "parity report never became ready") - let report = app.staticTexts["parity-report"] - XCTAssertTrue(report.waitForExistence(timeout: 5), "parity-report label missing") + XCTAssertTrue(report.waitForExistence(timeout: 60), "parity-report label missing") + + // The label mounts empty and is populated once web metrics arrive; + // wait for non-empty content rather than mere existence. + let populated = NSPredicate(format: "label.length > 0") + let populatedExpectation = XCTNSPredicateExpectation(predicate: populated, object: report) + XCTAssertEqual( + XCTWaiter.wait(for: [populatedExpectation], timeout: 60), + .completed, + "parity report never populated") + + XCTAssertTrue(app.staticTexts["parity-ready"].exists, "parity-ready flag missing") let text = report.label payload = try XCTUnwrap( try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any], diff --git a/scripts/verify-ios26-parity.sh b/scripts/verify-ios26-parity.sh index 01afcef6..04539d5b 100755 --- a/scripts/verify-ios26-parity.sh +++ b/scripts/verify-ios26-parity.sh @@ -9,7 +9,9 @@ set -u root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) demo="$root/demo" artifacts=${VERIFY_ARTIFACTS_DIR:-$(mktemp -d /tmp/ios26-parity.XXXXXX)} -mkdir -p "$artifacts" +# DerivedData holds multi-GB index caches; keep it outside the uploaded dir. +derived=${VERIFY_DERIVEDDATA_DIR:-$(mktemp -d /tmp/ios26-parity-dd.XXXXXX)} +mkdir -p "$artifacts" "$derived" printf 'Artifacts: %s\n' "$artifacts" sim_udid=${1:-} @@ -31,14 +33,14 @@ npx cap sync ios > "$artifacts/cap-sync.log" 2>&1 || { tail -50 "$artifacts/cap- result=0 xcodebuild -project ios/App/App.xcodeproj -scheme App -configuration Debug \ -sdk iphonesimulator -destination "platform=iOS Simulator,id=$sim_udid" \ - -derivedDataPath "$artifacts/app-build" \ + -derivedDataPath "$derived/app" \ CODE_SIGNING_ALLOWED=NO build > "$artifacts/app-build.log" 2>&1 || result=$? if [ "$result" -ne 0 ]; then tail -50 "$artifacts/app-build.log" exit "$result" fi -xcrun simctl install "$sim_udid" "$artifacts/app-build/Build/Products/Debug-iphonesimulator/App.app" +xcrun simctl install "$sim_udid" "$derived/app/Build/Products/Debug-iphonesimulator/App.app" # --- 3. Generate the UI test project and run ----------------------------------- if ! command -v xcodegen >/dev/null 2>&1; then @@ -51,7 +53,7 @@ xcodegen generate --spec ios/NativeGlassPoCTests/project.yml --project ios/Nativ xcodebuild -project ios/NativeGlassPoCTests/NativeGlassPoCTests.xcodeproj \ -scheme NativeGlassPoCTests \ -destination "platform=iOS Simulator,id=$sim_udid" \ - -derivedDataPath "$artifacts/test-build" \ + -derivedDataPath "$derived/test" \ -resultBundlePath "$artifacts/parity.xcresult" \ CODE_SIGNING_ALLOWED=NO test > "$artifacts/tests.log" 2>&1 || result=$? From 5f1d11bd4c54476f39e9ca046cf72c1780f0673c Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 17 Sep 2026 00:32:50 +0900 Subject: [PATCH 3/3] Audit the shared native-ui-shell page instead of the glass PoC fixture Replace the standalone native-glass-poc page with the same /main/index/native-ui-shell audit page used on the iOS 27 branch so both themes are measured against one real demo surface. Web metrics now live in the app component and the parity harness resizes its UIKit references to the measured web widths, so segment division is asserted strictly while known tab-pill anchoring differences stay non-strict. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- demo/ios/App/App/ParityMode.swift | 95 ++++++-------- .../NativeGlassPoCTests.swift | 121 +++++++++--------- demo/src/app/app.component.ts | 75 ++++++++++- demo/src/app/app.routes.ts | 4 - demo/src/app/index/index-page.component.ts | 1 + demo/src/app/index/index.routes.ts | 4 + .../native-ui-shell/native-ui-shell.page.html | 66 ++++++++++ .../native-ui-shell/native-ui-shell.page.scss | 30 +++++ .../native-ui-shell/native-ui-shell.page.ts | 58 +++++++++ .../native-glass-poc.page.html | 20 --- .../native-glass-poc.page.scss | 51 -------- .../native-glass-poc/native-glass-poc.page.ts | 93 -------------- scripts/verify-ios26-parity.sh | 5 +- 13 files changed, 330 insertions(+), 293 deletions(-) create mode 100644 demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.html create mode 100644 demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.scss create mode 100644 demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.ts delete mode 100644 demo/src/app/native-glass-poc/native-glass-poc.page.html delete mode 100644 demo/src/app/native-glass-poc/native-glass-poc.page.scss delete mode 100644 demo/src/app/native-glass-poc/native-glass-poc.page.ts diff --git a/demo/ios/App/App/ParityMode.swift b/demo/ios/App/App/ParityMode.swift index 5a0c5d53..077f7260 100644 --- a/demo/ios/App/App/ParityMode.swift +++ b/demo/ios/App/App/ParityMode.swift @@ -7,12 +7,16 @@ import WebKit /// screen, registers a `parity` WKScriptMessageHandler so the web side can /// report DOM frames, and exposes the merged metrics through accessibility /// labels for XCTest to read. +/// +/// The audit target is the real `/main/index/native-ui-shell` demo page plus +/// the shell's `ion-tab-bar`. When web metrics arrive, each reference control +/// is resized to the corresponding web frame so item-level divisions can be +/// compared like for like. final class ParityHarness: NSObject, WKScriptMessageHandler { private var webView: WKWebView? private var nativeRefs: [String: UIView] = [:] private var webMetrics: [String: Any]? - private var nativeMetrics: [String: Any]? private let report = UILabel() private let readyFlag = UILabel() @@ -57,68 +61,45 @@ final class ParityHarness: NSObject, WKScriptMessageHandler { // MARK: - Layout private func installNativeRefs(on host: UIView) { - let glass = UIButton(type: .system) + let save = UIButton(type: .system) if #available(iOS 26.0, *) { var config = UIButton.Configuration.glass() - config.title = "Glass" - glass.configuration = config + config.title = "Save" + save.configuration = config } else { - glass.setTitle("Glass", for: .normal) + save.setTitle("Save", for: .normal) } - glass.accessibilityIdentifier = "native-glass" - host.addSubview(glass) - nativeRefs["glass"] = glass - - let prominent = UIButton(type: .system) - if #available(iOS 26.0, *) { - var config = UIButton.Configuration.prominentGlass() - config.title = "Prominent" - prominent.configuration = config - } else { - prominent.setTitle("Prominent", for: .normal) - } - prominent.accessibilityIdentifier = "native-prominent" - host.addSubview(prominent) - nativeRefs["prominent"] = prominent - - let toggle = UISwitch() - toggle.isOn = true - toggle.accessibilityIdentifier = "native-toggle" - host.addSubview(toggle) - nativeRefs["toggle"] = toggle + save.accessibilityIdentifier = "native-save" + host.addSubview(save) + nativeRefs["save"] = save let segment = UISegmentedControl(items: ["One", "Two", "Three"]) segment.selectedSegmentIndex = 0 + segment.setEnabled(false, forSegmentAt: 2) segment.accessibilityIdentifier = "native-segment" host.addSubview(segment) nativeRefs["segment"] = segment - let slider = UISlider() - slider.value = 0.4 - slider.accessibilityIdentifier = "native-range" - host.addSubview(slider) - nativeRefs["range"] = slider - let tabBar = UITabBar() tabBar.items = [ - UITabBarItem(title: "One", image: nil, tag: 0), - UITabBarItem(title: "Two", image: nil, tag: 1), - UITabBarItem(title: "Three", image: nil, tag: 2), + UITabBarItem(title: "Index", image: nil, tag: 0), + UITabBarItem(title: "Docs", image: nil, tag: 1), + UITabBarItem(title: "Library", image: nil, tag: 2), + UITabBarItem(title: "Settings", image: nil, tag: 3), ] tabBar.selectedItem = tabBar.items?.first tabBar.accessibilityIdentifier = "native-tabs" host.addSubview(tabBar) nativeRefs["tabs"] = tabBar - layoutNativeRefs(host: host) + layoutNativeRefs() } - private func layoutNativeRefs(host: UIView) { - nativeRefs["glass"]?.frame = CGRect(x: 24, y: 590, width: 140, height: 44) - nativeRefs["prominent"]?.frame = CGRect(x: 232, y: 590, width: 140, height: 44) - nativeRefs["toggle"]?.frame = CGRect(x: 24, y: 650, width: 63, height: 28) - nativeRefs["segment"]?.frame = CGRect(x: 24, y: 646, width: 320, height: 32) - nativeRefs["range"]?.frame = CGRect(x: 24, y: 706, width: 320, height: 44) + private func layoutNativeRefs() { + // Overlay row at the bottom of the screen; widths are synced to the + // web frames once metrics arrive. + nativeRefs["save"]?.frame = CGRect(x: 24, y: 590, width: 140, height: 44) + nativeRefs["segment"]?.frame = CGRect(x: 24, y: 646, width: 354, height: 32) nativeRefs["tabs"]?.frame = CGRect(x: 21, y: 786, width: 360, height: 62) } @@ -146,10 +127,8 @@ final class ParityHarness: NSObject, WKScriptMessageHandler { private func collectNativeMetrics(host: UIView) -> [String: Any] { var metrics: [String: Any] = [:] - for key in ["glass", "prominent", "toggle"] { - if let view = nativeRefs[key] { - metrics[key] = frameDict(view.frame) - } + if let save = nativeRefs["save"] { + metrics["save"] = frameDict(save.frame) } if let segment = nativeRefs["segment"] as? UISegmentedControl { @@ -163,16 +142,6 @@ final class ParityHarness: NSObject, WKScriptMessageHandler { metrics["segment"] = ["frame": frameDict(segment.frame), "items": items] } - if let slider = nativeRefs["range"] as? UISlider { - let track = slider.trackRect(forBounds: slider.bounds) - let thumb = slider.thumbRect(forBounds: slider.bounds, trackRect: track, value: slider.value) - metrics["range"] = [ - "frame": frameDict(slider.frame), - "track": frameDict(track), - "thumb": frameDict(thumb), - ] - } - if let tabBar = nativeRefs["tabs"] as? UITabBar { // The floating glass platter is a subview narrower than the frame; // measure the widest interior view, converting to window space. @@ -204,8 +173,22 @@ final class ParityHarness: NSObject, WKScriptMessageHandler { publishIfReady() } + private func webWidth(_ key: String) -> CGFloat? { + guard let node = webMetrics?[key] as? [String: Any] else { return nil } + let frame = (node["frame"] as? [String: Any]) ?? node + return (frame["w"] as? NSNumber).map { CGFloat(truncating: $0) } + } + + /// Match each reference's width to the measured web control so per-item + /// divisions are compared on equal footing, then lay out and publish. private func publishIfReady() { guard let web = webMetrics, let host = webView?.superview ?? webView else { return } + if let width = webWidth("segment"), let segment = nativeRefs["segment"] { + segment.frame.size.width = width + } + if let width = webWidth("tabs"), let tabs = nativeRefs["tabs"] { + tabs.frame.size.width = width + } host.layoutIfNeeded() let native = collectNativeMetrics(host: host) let payload: [String: Any] = ["web": web, "native": native] diff --git a/demo/ios/NativeGlassPoCTests/NativeGlassPoCTests.swift b/demo/ios/NativeGlassPoCTests/NativeGlassPoCTests.swift index c55b82e3..3e339db4 100644 --- a/demo/ios/NativeGlassPoCTests/NativeGlassPoCTests.swift +++ b/demo/ios/NativeGlassPoCTests/NativeGlassPoCTests.swift @@ -1,9 +1,9 @@ import XCTest /// Live parity: the demo app runs with `-parity`, which mounts UIKit -/// reference controls and merges DOM frames reported by the -/// `/native-glass-poc` page. Geometry assertions fail CI; the full metric -/// payload is attached as an artifact either way. +/// reference controls and merges DOM frames reported from the real +/// `/main/index/native-ui-shell` page. Geometry assertions fail CI; the full +/// metric payload is attached as an artifact either way. final class NativeGlassPoCTests: XCTestCase { private var app: XCUIApplication! @@ -71,86 +71,79 @@ final class NativeGlassPoCTests: XCTestCase { return try frameRect(node, "\(side).\(key)") } - private func assertSize(_ key: String, file: StaticString = #filePath, line: UInt = #line) throws { - let web = try frame("web", key) - let native = try frame("native", key) - XCTAssertEqual(web.width, native.width, accuracy: tolerance, "\(key) width: web \(web.width) vs native \(native.width)", file: file, line: line) - XCTAssertEqual(web.height, native.height, accuracy: tolerance, "\(key) height: web \(web.height) vs native \(native.height)", file: file, line: line) + /// Compare per-item geometry inside each container. Web items are stored + /// relative to their element's origin; native a11y frames are in screen + /// space, so subtract the native container's origin. + private func assertItems( + webItems: [[String: Any]], + nativeContainerX: Double, + nativeButtons: [XCUIElement], + label: String, + strictItemWidth: Bool + ) throws { + for (i, item) in nativeButtons.enumerated() { + guard i < webItems.count, item.exists else { continue } + let relativeX = item.frame.origin.x - nativeContainerX + XCTExpectFailure("item anchoring differs between web and UIKit", options: .nonStrict()) { + XCTAssertEqual( + (try? self.num(webItems[i], "x", "\(label).items[\(i)]")) ?? .nan, + relativeX, accuracy: 2, "\(label) item \(i) x") + } + if strictItemWidth { + XCTAssertEqual( + try num(webItems[i], "w", "\(label).items[\(i)]"), + item.frame.width, accuracy: 2, "\(label) item \(i) width") + } else { + XCTExpectFailure("\(label) item division differs between web and UIKit", options: .nonStrict()) { + XCTAssertEqual( + (try? self.num(webItems[i], "w", "\(label).items[\(i)]")) ?? .nan, + item.frame.width, accuracy: 2, "\(label) item \(i) width") + } + } + } } - /// Strict geometry on the visual control surfaces (not margin boxes). + /// Strict geometry on the native-ui-shell audit page and the shell tab bar. func testGeometryParityLight() throws { let web = payload["web"] as? [String: Any] - // Known gap: theme renders ~52.7pt where UIButton.Configuration.glass() - // lays out at 44pt. Width stays a hard assertion. - for key in ["glass", "prominent"] { - let w = try frame("web", key) - let n = try frame("native", key) - XCTAssertEqual(w.width, n.width, accuracy: tolerance, "\(key) width") - XCTExpectFailure("theme button height is ~52.7pt vs UIKit 44pt", options: .nonStrict()) { - XCTAssertEqual(w.height, n.height, accuracy: self.tolerance, "\(key) height") - } + // Save button: the theme's visual size differs from UIButton's; keep + // it informational while asserting the reference control exists. + XCTAssertTrue(app.buttons["native-save"].waitForExistence(timeout: 5), "native save reference missing") + let webSave = try frame("web", "save") + let nativeSave = try frame("native", "save") + XCTExpectFailure("theme button size differs from UIKit", options: .nonStrict()) { + XCTAssertEqual(webSave.height, nativeSave.height, accuracy: 2, "save height") } - try assertSize("toggle") - - // Slider: the meaningful parity metric is the track, not the control - // frame (Ionic reserves knob padding vertically). - let webTrack = try frameRect( - ((payload["web"] as? [String: Any])?["range"] as? [String: Any])?["bar"] as? [String: Any] ?? [:], - "web.range.bar") - let nativeTrack = try frameRect( - ((payload["native"] as? [String: Any])?["range"] as? [String: Any])?["track"] as? [String: Any] ?? [:], - "native.range.track") - XCTAssertEqual(webTrack.width, nativeTrack.width, accuracy: tolerance, "range track width") - XCTAssertEqual(webTrack.height, nativeTrack.height, accuracy: tolerance, "range track height") - - // Segment: outer frame and per-item division. + // Segment: the native reference is resized to the web width, so item + // division is compared on equal footing. let webSegment = try frame("web", "segment") let nativeSegment = try frame("native", "segment") XCTAssertEqual(webSegment.width, nativeSegment.width, accuracy: tolerance, "segment width") XCTExpectFailure("theme segment height is 31pt vs UIKit 32pt", options: .nonStrict()) { XCTAssertEqual(webSegment.height, nativeSegment.height, accuracy: self.tolerance, "segment height") } - let webSegFrame = webSegment - let webItems = (web?["segment"] as? [String: Any])?["items"] as? [[String: Any]] + let webSegItems = (web?["segment"] as? [String: Any])?["items"] as? [[String: Any]] let nativeSeg = app.segmentedControls["native-segment"] - let segNames = ["One", "Two", "Three"] - if let w = webItems, nativeSeg.exists { - for (i, name) in segNames.enumerated() { - let item = nativeSeg.buttons[name] - guard item.exists else { continue } - XCTAssertEqual( - webSegFrame.origin.x + (try num(w[i], "x", "web.segment.items[\(i)]")), - item.frame.origin.x, accuracy: 2, "segment item \(i) screen x") - XCTAssertEqual( - try num(w[i], "w", "web.segment.items[\(i)]"), - item.frame.width, accuracy: 2, "segment item \(i) width") - } + if let items = webSegItems, nativeSeg.exists { + let buttons = ["One", "Two", "Three"].map { nativeSeg.buttons[$0] } + try assertItems( + webItems: items, nativeContainerX: nativeSegment.origin.x, nativeButtons: buttons, + label: "segment", strictItemWidth: true) } - // Tab bar: item frames are measured through the accessibility tree on - // both sides. Item widths are a hard assertion; the absolute x offset - // differs today because the native pill is centered while the web pill - // is anchored at its frame origin. + // Shell tab bar: real ion-tab-bar vs UITabBar reference. let webTabs = try frame("web", "tabs") + let nativeTabs = try frame("native", "tabs") + XCTAssertEqual(webTabs.width, nativeTabs.width, accuracy: tolerance, "tabs width") let webTabItems = (web?["tabs"] as? [String: Any])?["items"] as? [[String: Any]] let nativeBar = app.tabBars["native-tabs"] - let tabNames = ["One", "Two", "Three"] - if let w = webTabItems, nativeBar.exists { - for (i, name) in tabNames.enumerated() { - let item = nativeBar.buttons[name] - guard item.exists else { continue } - XCTExpectFailure("native tab pill is centered in its frame; web pill anchors at frame origin", options: .nonStrict()) { - XCTAssertEqual( - webTabs.origin.x + ((try? self.num(w[i], "x", "web.tabs.items[\(i)]")) ?? .nan), - item.frame.origin.x, accuracy: 2, "tab item \(i) screen x") - } - XCTAssertEqual( - try num(w[i], "w", "web.tabs.items[\(i)]"), - item.frame.width, accuracy: 2, "tab item \(i) width") - } + if let items = webTabItems, nativeBar.exists { + let buttons = ["Index", "Docs", "Library", "Settings"].map { nativeBar.buttons[$0] } + try assertItems( + webItems: items, nativeContainerX: nativeTabs.origin.x, nativeButtons: buttons, + label: "tabs", strictItemWidth: false) } } diff --git a/demo/src/app/app.component.ts b/demo/src/app/app.component.ts index d91b6180..1d4af8dc 100644 --- a/demo/src/app/app.component.ts +++ b/demo/src/app/app.component.ts @@ -4,6 +4,19 @@ import { IonApp, IonRouterOutlet } from '@demo/ionic'; import { addIcons } from 'ionicons'; import * as allIcons from 'ionicons/icons'; +interface ParityFrame { + x: number; + y: number; + w: number; + h: number; +} + +declare global { + interface Window { + webkit?: { messageHandlers?: { parity?: { postMessage(body: unknown): void } } }; + } +} + @Component({ selector: 'app-root', imports: [IonRouterOutlet, IonApp], @@ -19,13 +32,69 @@ export class AppComponent implements OnInit { ngOnInit(): void { // In parity mode the native harness registers this message handler; - // navigate to the fixture page so XCTest sees a deterministic layout. + // navigate to the audit page so XCTest sees a deterministic layout. const timer = setInterval(() => { - if ((window as any).webkit?.messageHandlers?.parity) { + const parity = (window as any).webkit?.messageHandlers?.parity; + if (parity) { clearInterval(timer); - void this.#router.navigateByUrl('/native-glass-poc'); + void this.#router.navigateByUrl('/main/index/native-ui-shell').then(() => this.#report()); } }, 200); setTimeout(() => clearInterval(timer), 10_000); } + + #rect(el: Element): ParityFrame { + const r = el.getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height }; + } + + #rel(el: Element, origin: ParityFrame): ParityFrame { + const r = el.getBoundingClientRect(); + return { x: r.x - origin.x, y: r.y - origin.y, w: r.width, h: r.height }; + } + + #collect(): Record { + const doc = document; + const metrics: Record = {}; + + // Compare the visual control surface, not the element's margin box. + const surface = (el: Element | null) => el?.shadowRoot?.querySelector('[part="native"], .button-native') ?? el; + + const save = doc.querySelector('ion-button[type="submit"]'); + if (save) metrics['save'] = this.#rect(surface(save)!); + + const segment = doc.querySelector('ion-segment'); + if (segment) { + const segmentFrame = this.#rect(segment); + metrics['segment'] = { + frame: segmentFrame, + items: [...segment.querySelectorAll('ion-segment-button')].map((b) => this.#rel(b, segmentFrame)), + }; + } + + // The shell's real ion-tab-bar, not a page-level fixture. + const tabs = doc.querySelector('ion-tab-bar'); + if (tabs) { + const tabsFrame = this.#rect(tabs); + metrics['tabs'] = { + frame: tabsFrame, + items: [...tabs.querySelectorAll('ion-tab-button')].map((b) => this.#rel(b, tabsFrame)), + }; + } + + return metrics; + } + + // Give the audit page two frames to lay out before measuring. + #report() { + requestAnimationFrame(() => { + setTimeout(() => { + try { + (window as any).webkit?.messageHandlers?.parity?.postMessage({ metrics: this.#collect() }); + } catch { + // Reporting must never break the page. + } + }, 500); + }); + } } diff --git a/demo/src/app/app.routes.ts b/demo/src/app/app.routes.ts index d171240e..8e70dff0 100644 --- a/demo/src/app/app.routes.ts +++ b/demo/src/app/app.routes.ts @@ -1,10 +1,6 @@ import { Routes } from '@angular/router'; export const routes: Routes = [ - { - path: 'native-glass-poc', - loadComponent: () => import('./native-glass-poc/native-glass-poc.page').then((m) => m.NativeGlassPocPage), - }, { path: '', loadChildren: () => import('./tabs/tabs.routes').then((m) => m.routes), diff --git a/demo/src/app/index/index-page.component.ts b/demo/src/app/index/index-page.component.ts index 7b0ef645..b2a37dda 100644 --- a/demo/src/app/index/index-page.component.ts +++ b/demo/src/app/index/index-page.component.ts @@ -64,6 +64,7 @@ export class IndexPageComponent { { name: 'inputs', enable: true }, { name: 'item-list', enable: true }, { name: 'modal', enable: true }, + { name: 'native-ui-shell', enable: true }, { name: 'popover', enable: true }, { name: 'progress-indicators', enable: true }, { name: 'radio', enable: true }, diff --git a/demo/src/app/index/index.routes.ts b/demo/src/app/index/index.routes.ts index cff11f3b..1e231f36 100644 --- a/demo/src/app/index/index.routes.ts +++ b/demo/src/app/index/index.routes.ts @@ -42,6 +42,10 @@ export const routes: Routes = [ path: 'modal', loadComponent: () => import('./pages/modal/modal.page').then((m) => m.ModalPage), }, + { + path: 'native-ui-shell', + loadComponent: () => import('./pages/native-ui-shell/native-ui-shell.page').then((m) => m.NativeUIShellPage), + }, { path: 'card', loadComponent: () => import('./pages/card/card.page').then((m) => m.CardPage), diff --git a/demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.html b/demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.html new file mode 100644 index 00000000..866f778e --- /dev/null +++ b/demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.html @@ -0,0 +1,66 @@ + + + + Native UI Shell + + + + Save + + + + + + One + Two + Three + + + + + Actions: {{ github() }} / {{ refreshes() }} + + + + + + + + + + + +
+
+

+ Save count: {{ saves() }} +

+
+

Experimental: On supported iOS versions, fixed glass controls are rendered natively.

+

+ Selection: {{ selected() }} / Change count: {{ changes() }} +

+ + +

Scroll the background to compare transparency and refraction in the top-right button.

+ + + + @for (value of fills; track value) { + + } +
+ @for (band of bands; track band) { +
+ {{ band }} — GLASS / Glass / 123456789 +
+ } +
+ + + Modal + + + diff --git a/demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.scss b/demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.scss new file mode 100644 index 00000000..488985ef --- /dev/null +++ b/demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.scss @@ -0,0 +1,30 @@ +:host { + ion-header, + ion-toolbar { + --background: transparent; + --border-width: 0; + } + + .controls { + padding: 100px 20px 24px; + background: var(--ion-background-color, white); + } + + .controls button { + padding: 10px; + margin: 4px; + } + + .band { + height: 150px; + padding: 20px; + background: repeating-linear-gradient(90deg, #f85d79 0 24px, #ffdb70 24px 48px); + color: #171717; + font-size: 28px; + font-weight: 700; + } + + .band.alternate { + background: repeating-linear-gradient(90deg, #68d8fb 0 24px, #79efba 24px 48px); + } +} diff --git a/demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.ts b/demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.ts new file mode 100644 index 00000000..42b4d811 --- /dev/null +++ b/demo/src/app/index/pages/native-ui-shell/native-ui-shell.page.ts @@ -0,0 +1,58 @@ +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonTitle, + IonToolbar, + IonSegment, + IonSegmentButton, + IonLabel, + IonModal, + IonIcon, + IonMenuButton, +} from '@demo/ionic'; + +@Component({ + selector: 'app-native-ui-shell', + imports: [ + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonTitle, + IonToolbar, + IonSegment, + IonSegmentButton, + IonLabel, + IonModal, + IonIcon, + IonMenuButton, + ], + templateUrl: './native-ui-shell.page.html', + styleUrl: './native-ui-shell.page.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NativeUIShellPage { + readonly saves = signal(0); + readonly github = signal(0); + readonly refreshes = signal(0); + readonly disabled = signal(false); + readonly hidden = signal(false); + readonly themeDisabled = signal(false); + readonly fill = signal<'default' | 'clear' | 'solid' | 'outline'>('default'); + readonly fills = ['default', 'clear', 'solid', 'outline'] as const; + readonly bands = Array.from({ length: 24 }, (_, index) => index + 1); + readonly selected = signal('one'); + readonly changes = signal(0); + readonly modalOpen = signal(false); + readonly increment = (value: number) => value + 1; + + save(event: Event) { + event.preventDefault(); + this.saves.update((value) => value + 1); + } +} diff --git a/demo/src/app/native-glass-poc/native-glass-poc.page.html b/demo/src/app/native-glass-poc/native-glass-poc.page.html deleted file mode 100644 index 35585716..00000000 --- a/demo/src/app/native-glass-poc/native-glass-poc.page.html +++ /dev/null @@ -1,20 +0,0 @@ -
- Glass - Prominent - - - - - One - Two - Three - - - - - - One - Two - Three - -
diff --git a/demo/src/app/native-glass-poc/native-glass-poc.page.scss b/demo/src/app/native-glass-poc/native-glass-poc.page.scss deleted file mode 100644 index dd22df4e..00000000 --- a/demo/src/app/native-glass-poc/native-glass-poc.page.scss +++ /dev/null @@ -1,51 +0,0 @@ -:host { - display: block; -} - -.probe { - position: fixed; - inset: 0; -} - -#Glass { - position: absolute; - left: 24px; - top: 80px; - width: 140px; - height: 44px; -} - -#Prominent { - position: absolute; - left: 232px; - top: 80px; - width: 140px; - height: 44px; -} - -#Toggle { - position: absolute; - left: 24px; - top: 160px; -} - -#Segment { - position: absolute; - left: 24px; - top: 220px; - width: 320px; -} - -#Range { - position: absolute; - left: 24px; - top: 280px; - width: 320px; -} - -#Tabs { - position: absolute; - left: 21px; - top: 400px; - width: 360px; -} diff --git a/demo/src/app/native-glass-poc/native-glass-poc.page.ts b/demo/src/app/native-glass-poc/native-glass-poc.page.ts deleted file mode 100644 index 10fe3ebe..00000000 --- a/demo/src/app/native-glass-poc/native-glass-poc.page.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Component, AfterViewInit } from '@angular/core'; - -import { IonButton, IonLabel, IonRange, IonSegment, IonSegmentButton, IonTabBar, IonTabButton, IonToggle } from '@demo/ionic'; - -interface ParityFrame { - x: number; - y: number; - w: number; - h: number; -} - -declare global { - interface Window { - webkit?: { messageHandlers?: { parity?: { postMessage(body: unknown): void } } }; - } -} - -/** - * Dense parity fixture rendered inside the real demo app. The AppDelegate - * mounts UIKit reference controls at matching positions in parity mode; this - * page posts its DOM frames through the `parity` message handler so XCTest can - * compare the projection against UIKit in the same run. - */ -@Component({ - selector: 'app-native-glass-poc', - templateUrl: './native-glass-poc.page.html', - styleUrls: ['./native-glass-poc.page.scss'], - standalone: true, - imports: [IonButton, IonLabel, IonRange, IonSegment, IonSegmentButton, IonTabBar, IonTabButton, IonToggle], -}) -export class NativeGlassPocPage implements AfterViewInit { - #rect(el: Element): ParityFrame { - const r = el.getBoundingClientRect(); - return { x: r.x, y: r.y, w: r.width, h: r.height }; - } - - #rel(el: Element, origin: ParityFrame): ParityFrame { - const r = el.getBoundingClientRect(); - return { x: r.x - origin.x, y: r.y - origin.y, w: r.width, h: r.height }; - } - - #collect(): Record { - const metrics: Record = {}; - const doc = document; - - // Compare the visual control surface, not the element's margin box. - const surface = (id: string) => - doc.getElementById(id)!.shadowRoot?.querySelector('[part="native"], .button-native') ?? doc.getElementById(id)!; - metrics['glass'] = this.#rect(surface('Glass')); - metrics['prominent'] = this.#rect(surface('Prominent')); - metrics['toggle'] = this.#rect(doc.getElementById('Toggle')!); - - const segment = doc.getElementById('Segment')!; - const segmentFrame = this.#rect(segment); - metrics['segment'] = { - frame: segmentFrame, - items: [...segment.querySelectorAll('ion-segment-button')].map((b) => this.#rel(b, segmentFrame)), - }; - - const range = doc.getElementById('Range')!; - const rangeFrame = this.#rect(range); - const bar = range.shadowRoot?.querySelector('[part="bar"], .range-bar'); - const knob = range.shadowRoot?.querySelector('[part="knob"], .range-knob-handle'); - metrics['range'] = { - frame: rangeFrame, - bar: bar ? this.#rel(bar, rangeFrame) : null, - knob: knob ? this.#rel(knob, rangeFrame) : null, - }; - - const tabs = doc.getElementById('Tabs')!; - const tabsFrame = this.#rect(tabs); - metrics['tabs'] = { - frame: tabsFrame, - items: [...tabs.querySelectorAll('ion-tab-button')].map((b) => this.#rel(b, tabsFrame)), - }; - - return metrics; - } - - ngAfterViewInit(): void { - requestAnimationFrame(() => - requestAnimationFrame(() => { - setTimeout(() => { - try { - window.webkit?.messageHandlers?.parity?.postMessage({ metrics: this.#collect() }); - } catch { - // Reporting must never break the page. - } - }, 400); - }), - ); - } -} diff --git a/scripts/verify-ios26-parity.sh b/scripts/verify-ios26-parity.sh index 04539d5b..9fc3727d 100755 --- a/scripts/verify-ios26-parity.sh +++ b/scripts/verify-ios26-parity.sh @@ -1,6 +1,7 @@ #!/bin/sh -# Build the demo app, install it on a simulator, and run the NativeGlassPoC -# XCTest suite that compares the projected DOM against UIKit references. +# Build the demo app, install it on a simulator, and run the XCTest suite +# that audits the /main/index/native-ui-shell page and the shell tab bar +# against UIKit references mounted by the app's -parity mode. # Usage: sh scripts/verify-ios26-parity.sh [SIM_UDID] # SIM_UDID booted simulator UDID; when empty the script picks a booted iPhone. # Artifacts land in $VERIFY_ARTIFACTS_DIR (default: a mktemp dir under /tmp).