diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd24a44..c99ac5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,12 @@ jobs: with: node-version: lts/* cache: "pnpm" + # capacitor-hinge is a private git dependency; HINGE_DEPLOY_KEY is a + # read-only deploy key on that repo. + - name: Load capacitor-hinge deploy key + uses: webfactory/ssh-agent@v0.9.1 + with: + ssh-private-key: ${{ secrets.HINGE_DEPLOY_KEY }} - name: Install dependencies run: pnpm install --frozen-lockfile - name: Lint diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f28e222..448d314 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -37,6 +37,12 @@ jobs: with: node-version: lts/* cache: "pnpm" + # capacitor-hinge is a private git dependency; HINGE_DEPLOY_KEY is a + # read-only deploy key on that repo. + - name: Load capacitor-hinge deploy key + uses: webfactory/ssh-agent@v0.9.1 + with: + ssh-private-key: ${{ secrets.HINGE_DEPLOY_KEY }} - name: Install dependencies run: pnpm install --frozen-lockfile - name: Verify PostHog configuration diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 293deb0..f6cb511 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -36,6 +36,12 @@ jobs: uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} + # capacitor-hinge is a private git dependency; HINGE_DEPLOY_KEY is a + # read-only deploy key on that repo. + - name: Load capacitor-hinge deploy key + uses: webfactory/ssh-agent@v0.9.1 + with: + ssh-private-key: ${{ secrets.HINGE_DEPLOY_KEY }} - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build Tauri app bundle diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b3aa07..00b040e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,8 @@ on: jobs: ci: uses: ./.github/workflows/ci.yml + # Passes HINGE_DEPLOY_KEY through for the private capacitor-hinge git dependency. + secrets: inherit permissions: contents: read diff --git a/BUILDING.md b/BUILDING.md index 5a69b90..65bc076 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -117,7 +117,6 @@ Store record — it does **not** share the macOS bundle identifier. | Artifact | sandboxed `.pkg` | `.ipa` | | Script | `scripts/build-appstore.sh` | `scripts/build-ios.sh` | - ### Build and upload ```sh diff --git a/index.html b/index.html index 96fb2d6..80ddf38 100644 --- a/index.html +++ b/index.html @@ -227,8 +227,22 @@

Settings

aria-label="Close" > @@ -513,8 +527,22 @@

Info

aria-label="Close" > diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 9d9765c..92c00c8 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -312,7 +312,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1.0; + MARKETING_VERSION = 1.0.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = eu.maxwase.kami.ios; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -335,7 +335,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = eu.maxwase.kami.ios; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json index b1a8483..5e294d5 100644 --- a/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -11,4 +11,4 @@ "author": "xcode", "version": 1 } -} \ No newline at end of file +} diff --git a/ios/App/App/Assets.xcassets/Contents.json b/ios/App/App/Assets.xcassets/Contents.json index da4a164..97a8662 100644 --- a/ios/App/App/Assets.xcassets/Contents.json +++ b/ios/App/App/Assets.xcassets/Contents.json @@ -1,6 +1,6 @@ { - "info" : { - "version" : 1, - "author" : "xcode" + "info": { + "version": 1, + "author": "xcode" } -} \ No newline at end of file +} diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json b/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json index 84a416d..57ba80d 100644 --- a/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json +++ b/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json @@ -53,4 +53,4 @@ "version": 1, "author": "xcode" } -} \ No newline at end of file +} diff --git a/ios/App/App/PrivacyInfo.xcprivacy b/ios/App/App/PrivacyInfo.xcprivacy index 2914d9f..e133353 100644 --- a/ios/App/App/PrivacyInfo.xcprivacy +++ b/ios/App/App/PrivacyInfo.xcprivacy @@ -24,6 +24,34 @@ NSPrivacyCollectedDataTypePurposeAnalytics + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeCrashData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeCoarseLocation + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + NSPrivacyAccessedAPITypes diff --git a/ios/App/CapApp-SPM/Package.resolved b/ios/App/CapApp-SPM/Package.resolved new file mode 100644 index 0000000..6d0e615 --- /dev/null +++ b/ios/App/CapApp-SPM/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "capacitor-swift-pm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ionic-team/capacitor-swift-pm.git", + "state" : { + "revision" : "6afa7424fd2fcd8ca1e577478e8a00af284b7e82", + "version" : "8.5.1" + } + } + ], + "version" : 2 +} diff --git a/ios/App/CapApp-SPM/Package.swift b/ios/App/CapApp-SPM/Package.swift index a82d17d..0987507 100644 --- a/ios/App/CapApp-SPM/Package.swift +++ b/ios/App/CapApp-SPM/Package.swift @@ -12,7 +12,8 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.1"), - .package(name: "CapacitorBrowser", path: "../../../node_modules/.pnpm/@capacitor+browser@8.0.4_@capacitor+core@8.5.1/node_modules/@capacitor/browser") + .package(name: "CapacitorBrowser", path: "../../../node_modules/.pnpm/@capacitor+browser@8.0.4_@capacitor+core@8.5.1/node_modules/@capacitor/browser"), + .package(name: "MaxwaseCapacitorHinge", path: "../../../node_modules/.pnpm/@maxwase+capacitor-hinge@git+ssh+++git@github.com+maxwase+capacitor-hinge.git+276b55415_91485003438d561f256d12448cd1b2de/node_modules/@maxwase/capacitor-hinge") ], targets: [ .target( @@ -20,7 +21,8 @@ let package = Package( dependencies: [ .product(name: "Capacitor", package: "capacitor-swift-pm"), .product(name: "Cordova", package: "capacitor-swift-pm"), - .product(name: "CapacitorBrowser", package: "CapacitorBrowser") + .product(name: "CapacitorBrowser", package: "CapacitorBrowser"), + .product(name: "MaxwaseCapacitorHinge", package: "MaxwaseCapacitorHinge") ] ) ] diff --git a/package.json b/package.json index deffb48..353b7f9 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "build:ios": "KAMI_TARGET=capacitor pnpm build", "ios:sync": "pnpm build:ios && cap sync ios", "ios:open": "cap open ios", - "ios:run": "pnpm ios:sync && cap run ios", + "ios:run": "scripts/run-ios-sim.sh", "ios:assets": "capacitor-assets generate --ios --assetPath public/icons/capacitor --iosProject ios/App", "build:ios:appstore": "scripts/build-ios.sh", "build:ios:appstore:upload": "scripts/build-ios.sh --upload" @@ -47,6 +47,7 @@ "dependencies": { "@capacitor/browser": "^8.0.4", "@capacitor/core": "^8.5.1", + "@maxwase/capacitor-hinge": "git+ssh://git@github.com/maxwase/capacitor-hinge.git#master", "@capacitor/ios": "^8.5.1", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-opener": "^2.5.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9a4765..26dd3b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@capacitor/ios': specifier: ^8.5.1 version: 8.5.1(@capacitor/core@8.5.1) + '@maxwase/capacitor-hinge': + specifier: git+ssh://git@github.com/maxwase/capacitor-hinge.git#master + version: git+ssh://git@github.com/maxwase/capacitor-hinge.git#276b55415d4b9a6c655f8c44c9274c29b01bbcdf(@capacitor/core@8.5.1) '@tauri-apps/api': specifier: ^2.11.1 version: 2.11.1 @@ -975,6 +978,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@maxwase/capacitor-hinge@git+ssh://git@github.com/maxwase/capacitor-hinge.git#276b55415d4b9a6c655f8c44c9274c29b01bbcdf': + resolution: {commit: 276b55415d4b9a6c655f8c44c9274c29b01bbcdf, repo: git@github.com:maxwase/capacitor-hinge.git, type: git} + version: 0.1.0 + peerDependencies: + '@capacitor/core': ^8.0.0 + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -4426,6 +4435,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@maxwase/capacitor-hinge@git+ssh://git@github.com/maxwase/capacitor-hinge.git#276b55415d4b9a6c655f8c44c9274c29b01bbcdf(@capacitor/core@8.5.1)': + dependencies: + '@capacitor/core': 8.5.1 + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 86e832b..148ab50 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,3 +3,6 @@ allowBuilds: esbuild: true # Native image codecs, used by @capacitor/assets to generate iOS app icons. sharp: true + # Git dependency; its prepare script builds dist/, which is gitignored upstream. + # Pinned to the locked commit, so bumping capacitor-hinge means updating this too. + "@maxwase/capacitor-hinge@git+ssh://git@github.com/maxwase/capacitor-hinge.git#276b55415d4b9a6c655f8c44c9274c29b01bbcdf": true diff --git a/privacy/index.html b/privacy/index.html index 49faf26..1782c45 100644 --- a/privacy/index.html +++ b/privacy/index.html @@ -134,6 +134,10 @@

4. Analytics (only with your consent)

Standard technical data PostHog records: approximate (coarse) region derived from IP address, device/browser type, and screen size. +
  • + Error reports — when the app hits an unexpected error, the error message and + stack trace (no personal content) are sent so the bug can be fixed. +
  • PostHog acts as our data processor. Your consent choice is remembered using your diff --git a/scripts/run-ios-sim.sh b/scripts/run-ios-sim.sh new file mode 100755 index 0000000..4835242 --- /dev/null +++ b/scripts/run-ios-sim.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Build kami and run it on an iOS simulator. +# +# Usage: +# scripts/run-ios-sim.sh # first booted simulator +# scripts/run-ios-sim.sh "iPhone Duo" # by device name +# scripts/run-ios-sim.sh # by UDID +# +# This replaces `cap run ios`, which is broken on Xcode 27: the Capacitor CLI +# opens the simulator through a hardcoded Developer/Applications/Simulator.app +# path, and Xcode 27 renamed that app to Device Hub and moved it to +# Contents/Applications. Everything here goes through `xcrun simctl` instead, +# which is unaffected by the rename. +set -euo pipefail + +cd "$(dirname "$0")/.." + +APP_ID="eu.maxwase.kami.ios" +DERIVED="build/ios-sim" + +target="${1:-}" +if [[ -z "$target" ]]; then + udid="$(xcrun simctl list devices booted -j | node -e ' + const d = JSON.parse(require("fs").readFileSync(0, "utf8")).devices; + const all = Object.values(d).flat(); + process.stdout.write(all.length ? all[0].udid : ""); + ')" + [[ -n "$udid" ]] || { echo "No booted simulator. Pass a device name or UDID." >&2; exit 1; } +else + udid="$(xcrun simctl list devices available -j | node -e ' + const arg = process.argv[1]; + const d = JSON.parse(require("fs").readFileSync(0, "utf8")).devices; + const all = Object.values(d).flat(); + const hit = all.find((x) => x.udid === arg) || all.find((x) => x.name === arg); + process.stdout.write(hit ? hit.udid : ""); + ' "$target")" + [[ -n "$udid" ]] || { echo "No simulator matching: $target" >&2; exit 1; } +fi + +echo "==> Simulator $udid" +pnpm run ios:sync + +echo "==> Booting" +xcrun simctl bootstatus "$udid" -b +open -a "$(xcode-select -p)/../Applications/DeviceHub.app" || true + +echo "==> Building" +xcodebuild build \ + -project ios/App/App.xcodeproj \ + -scheme App \ + -configuration Debug \ + -destination "id=$udid" \ + -derivedDataPath "$DERIVED" \ + -quiet + +echo "==> Installing" +xcrun simctl install "$udid" "$DERIVED/Build/Products/Debug-iphonesimulator/App.app" + +echo "==> Launching" +xcrun simctl launch "$udid" "$APP_ID" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e4cfccb..c7ec2f1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -32,4 +32,4 @@ "../public/icons/icon.ico" ] } -} \ No newline at end of file +} diff --git a/src/analytics.ts b/src/analytics.ts index 6a304cb..3adefc9 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -1,5 +1,5 @@ import posthog from "posthog-js"; -import { resolveRuntimeInfo } from "./device/runtime"; +import { Platform, resolveRuntimeInfo } from "./device/runtime"; const apiKey = import.meta.env.VITE_POSTHOG_KEY as string | undefined; const apiHost = import.meta.env.VITE_POSTHOG_HOST as string | undefined; @@ -89,7 +89,7 @@ interface EventMap { outbound_link: { link_type: string; link_url: string }; app_open: { launch_context: "ios" | "twa" | "pwa" | "browser" }; analytics_consent_changed: { granted: boolean }; - playstore_banner_tapped: Record; + playstore_banner_tapped: { banner: "playstore" | "appstore" | "mac" }; paper_texture_changed: { side: "front" | "back"; texture: "color" | "paper" | "banner" | "custom"; @@ -103,7 +103,17 @@ interface EventMap { let initialized = false; +/** + * Start analytics at boot, but only for a user who already opted in. PostHog + * is not even initialized before consent: `init()` itself writes `ph_*` + * storage and fetches remote config, which the privacy policy promises not to + * do until the user taps "Allow". + */ export function initAnalytics(): void { + if (getAnalyticsConsent() === "granted") startPostHog(); +} + +function startPostHog(): void { if (initialized) return; if (!apiKey || !apiHost) { @@ -112,6 +122,9 @@ export function initAnalytics(): void { posthog.init(apiKey, { api_host: apiHost, + // Only ever reached after consent; opted out by default so a stale or + // cleared SDK consent record can never make it capture on its own. + opt_out_capturing_by_default: true, defaults: "2026-05-30", capture_exceptions: { capture_unhandled_errors: true, @@ -122,12 +135,12 @@ export function initAnalytics(): void { // it doesn't record pixels by default) balloons a session to tens of MB // here — even at 4fps/0.4 quality one session hit ~74MB and stalled // processing. Left off; session replay will show a blank canvas. + // The iOS build never records sessions: the App Store privacy manifest + // declares product interaction only, and replay would add more data types. + disable_session_recording: resolveRuntimeInfo().platform === Platform.Capacitor, }); initialized = true; - - if (getAnalyticsConsent() === "denied") { - posthog.opt_out_capturing(); - } + posthog.opt_in_capturing({ captureEventName: false }); } /** @@ -157,10 +170,27 @@ export function getAnalyticsConsent(): "granted" | "denied" | "unset" { /** Persists the user's consent choice and toggles PostHog capturing accordingly. */ export function setAnalyticsConsent(granted: boolean): void { localStorage.setItem(CONSENT_STORAGE_KEY, granted ? "granted" : "denied"); - if (!initialized) return; if (granted) { - posthog.opt_in_capturing({ captureEventName: false }); - } else { - posthog.opt_out_capturing(); + // First grant starts (and opts in) PostHog; a re-grant after withdrawal + // finds it already running and only needs the opt-in. + if (initialized) { + posthog.set_config({ disable_persistence: false }); + posthog.opt_in_capturing({ captureEventName: false }); + } else { + startPostHog(); + } + return; + } + if (!initialized) return; + // Withdrawal also wipes the SDK's stored ID and `ph_*` keys. reset() first: + // it restores the default (opted-out) consent state, then make it explicit. + posthog.reset(); + posthog.opt_out_capturing(); + // Opting out leaves what was already written (the random ID, cached flags) + // and the SDK keeps saving to it, so switch persistence off before dropping + // it. Next boot won't init without consent, so nothing brings it back. + posthog.set_config({ disable_persistence: true }); + for (const key of Object.keys(localStorage)) { + if (key.startsWith(`ph_${apiKey}`)) localStorage.removeItem(key); } } diff --git a/src/device/capacitor.ts b/src/device/capacitor.ts index f46397f..b85fadc 100644 --- a/src/device/capacitor.ts +++ b/src/device/capacitor.ts @@ -1,22 +1,158 @@ /** - * iOS hinge bridge — stubbed. + * iOS hinge bridge. * - * Apple ships no fold/hinge/posture API as of iOS SDK 26.5 (Xcode 27.0): there - * is no such symbol anywhere in UIKit or CoreMotion. When one lands, this file - * is the only place that changes — most likely by forwarding to a small - * Capacitor plugin, the way `tauri.ts` forwards to a Rust command. Call sites - * (`posture.ts`) stay as they are. + * Apple shipped `UIHingeInteraction` / `UIHinge` in iOS 27.1 (Xcode 27.1) — + * the first public fold API on the platform, and the thing this file was + * always waiting for. It is wrapped by the standalone + * `@maxwase/capacitor-hinge` plugin, which reports a Chrome-compatible + * posture string, the raw hinge angle, and crease geometry. This module is + * the only place in Kami that knows the plugin exists: call sites + * (`posture.ts`, `hinge.ts`) see plain strings and `SegmentRect`s. * - * Shape mirrors `getTauriPostureType()`: a synchronous read of a cached value, - * safe to call every animation frame. + * Shape mirrors `getTauriPostureType()`: every getter is a synchronous read + * of a cached value, safe to call every animation frame. The cache is filled + * once at boot by `initCapacitorHinge()` and kept fresh by a `hingeChange` + * subscription. + * + * The plugin is loaded with a dynamic import (same pattern as + * `links.ts`) so that the web and Tauri bundles never pull in + * `@capacitor/core`. + */ + +import type { HingeCrease, HingeState } from "@maxwase/capacitor-hinge"; +import type { Vec2 } from "../math/vec2"; +import type { SegmentRect } from "./hinge"; + +let available = false; +let postureType = "unknown"; +let angleDegrees: number | null = null; +let segments: SegmentRect[] = []; +let creaseDir: Vec2 | null = null; +let initialized = false; + +/** + * The iPhone Duo's inner display is seamless across the fold, so the plugin + * reports a zero-width crease. `hingeFromSegments()` finds the crease as the + * *gap* between two segments and ignores a zero gap, so give it a hairline to + * measure. Sub-pixel, so it cannot shift the derived crease point. + */ +const MIN_CREASE_HALF_SPAN_PX = 0.5; + +/** + * Fold the plugin's tagged union down to the flat cached values this module + * exposes. Narrowing on `kind` is what makes the fields legal to read: the + * union only carries `crease`/`flat` on the `open` variant, so there is no + * "meaningful but null" case left to guess about. + * + * `posture` and `crease` helpers exist in the plugin, but importing them + * would be a *value* import of the package root, pulling `@capacitor/core` + * into the web and Tauri bundles. The union is small enough to match by hand. + */ +function applyState(state: HingeState): void { + available = state.kind !== "unavailable"; + + switch (state.kind) { + case "unavailable": + case "indeterminate": + postureType = "unknown"; + angleDegrees = null; + creaseDir = null; + segments = []; + return; + + case "closed": + postureType = "folded"; + angleDegrees = state.angle?.degrees ?? null; + creaseDir = null; + segments = []; + return; + + case "open": + postureType = state.flat ? "continuous" : "folded"; + angleDegrees = state.angle?.degrees ?? null; + // The crease axis is the real fold line even before anything is bent, + // so report the direction flat or not. + creaseDir = state.crease ? creaseDirection(state.crease) : null; + // Segments only while the device is actually bent: `resolveHingeState()` + // reads two segments as book mode, and a flat Duo is not in book mode. + segments = !state.flat && state.crease ? segmentsForCrease(state.crease) : []; + return; + } +} + +function creaseDirection(crease: HingeCrease): Vec2 { + return crease.axis === "vertical" ? { x: 0, y: 1 } : { x: 1, y: 0 }; +} + +function segmentsForCrease(crease: HingeCrease): SegmentRect[] { + const width = window.innerWidth; + const height = window.innerHeight; + const halfSpan = Math.max(crease.halfSpan, MIN_CREASE_HALF_SPAN_PX); + const lo = crease.center - halfSpan; + const hi = crease.center + halfSpan; + + if (crease.axis === "vertical") { + return [rect(0, 0, lo, height), rect(hi, 0, width, height)]; + } + return [rect(0, 0, width, lo), rect(0, hi, width, height)]; +} + +function rect(left: number, top: number, right: number, bottom: number): SegmentRect { + return { left, top, right, bottom, width: right - left, height: bottom - top }; +} + +function clearState(): void { + available = false; + postureType = "unknown"; + angleDegrees = null; + segments = []; + creaseDir = null; +} + +/** + * Seed the cache and subscribe to hinge updates. Must be awaited before + * `resolvePostureSupport()` runs, or support latches to `Unavailable` for the + * lifetime of the page. */ +export async function initCapacitorHinge(): Promise { + if (initialized) return; + initialized = true; + + try { + const { Hinge } = await import("@maxwase/capacitor-hinge"); + await Hinge.addListener("hingeChange", applyState); + applyState(await Hinge.getState()); + } catch (err) { + console.warn("Capacitor hinge plugin unavailable", err); + clearState(); + } +} /** Current posture string, or `"unknown"` when no hinge is readable. */ export function getCapacitorPostureType(): string { - return "unknown"; + return postureType; } /** Whether a real hinge sensor is readable on this device. */ export function isCapacitorHingeAvailable(): boolean { - return false; + return available; +} + +/** Two synthesized segments while the device is creased; empty otherwise. */ +export function getCapacitorSegments(): SegmentRect[] { + return segments; +} + +/** + * Direction the hardware fold line runs, or `null` when unknown. Reported + * even while the device is flat, so the fold line can be drawn along the real + * crease instead of being guessed from the viewport aspect ratio. + */ +export function getCapacitorCreaseDir(): Vec2 | null { + return creaseDir; +} + +/** Live hinge angle in degrees, or `null` when no hinge is readable. */ +export function getCapacitorHingeAngle(): number | null { + return angleDegrees; } diff --git a/src/device/hinge.ts b/src/device/hinge.ts index e527bbd..9b955bb 100644 --- a/src/device/hinge.ts +++ b/src/device/hinge.ts @@ -1,6 +1,10 @@ import type { Vec2 } from "../math/vec2"; +import { getCapacitorCreaseDir, getCapacitorSegments } from "./capacitor"; +import { Platform, resolveRuntimeInfo } from "./runtime"; import { getScreenAngleDeg } from "./screen"; +const runtime = resolveRuntimeInfo(); + export interface SegmentRect { left: number; top: number; @@ -27,6 +31,23 @@ export function computeHingePoint(canvasCssW: number, canvasCssH: number): Hinge const wAny = window as WindowWithSegments; try { + // iOS has no `viewport.segments`: the hinge plugin is the only source, and + // it synthesizes the crease from the hinge angle. Checked first because on + // Capacitor every API below is guaranteed absent. + if (runtime.platform === Platform.Capacitor) { + const capacitorSegments = getCapacitorSegments(); + if (capacitorSegments.length > 0) { + return buildHingeInfo(capacitorSegments, canvasCssW, canvasCssH); + } + // Flat, but the hardware still has a fold line: use its real direction + // rather than the aspect-ratio guess below, which reads the Duo's + // landscape inner display as a horizontal hinge. + const capacitorCreaseDir = getCapacitorCreaseDir(); + if (capacitorCreaseDir) { + return { segments: [], hingeDir: capacitorCreaseDir }; + } + } + const viewportSegmentsRaw = wAny.viewport?.segments; if (viewportSegmentsRaw !== undefined) { if (!Array.isArray(viewportSegmentsRaw)) { diff --git a/src/device/posture.ts b/src/device/posture.ts index abf13fd..c4defc7 100644 --- a/src/device/posture.ts +++ b/src/device/posture.ts @@ -32,8 +32,10 @@ export function helpCopyForSupport( if (support === PostureSupport.Available) { if (device === Device.Phone) { return { - controls: "Fold: fold a half of the phone over the other, tap the paper.", - gesture: "Move: one finger.
    Rotate: two fingers.", + controls: + "Fold: fold a half of the phone over the other, tap the paper.", + gesture: + "Move: one finger.
    Rotate: two fingers.
    Flip: quick two-finger swipe.", }; } // Laptop with a readable hinge sensor (MacBook lid angle) — fold is @@ -51,7 +53,8 @@ export function helpCopyForSupport( return { controls: "Fold: button, tap the paper.
    Flip: button.
    Reset: button.
    Undo: button.", - gesture: "Move: one finger.
    Rotate: two fingers.", + gesture: + "Move: one finger.
    Rotate: two fingers.
    Flip: quick two-finger swipe.", }; } return { @@ -79,8 +82,10 @@ export function readDevicePostureType(): string { /** * Detect whether a posture source is present. On Capacitor this is gated on - * the hinge bridge actually working (it does not yet on iOS), which keeps the - * manual-controls help copy and the Fold button as the primary interaction. + * the hinge bridge actually reporting a hinge — true on an iPhone Duo running + * iOS 27.1 or later, false on every other iPhone, which keeps the + * manual-controls help copy and the Fold button as the primary interaction + * there. */ export function resolvePostureSupport(): PostureSupport { const navAny = navigator as Navigator & { devicePosture?: { type?: string } }; diff --git a/src/input/gestures.ts b/src/input/gestures.ts index 9fc668a..5c80b44 100644 --- a/src/input/gestures.ts +++ b/src/input/gestures.ts @@ -35,6 +35,11 @@ const SWIPE_SEGMENT_QUIET_MS = 100; // tail arriving in dense sub-100ms bursts can't starve it from ever firing // (that starvation was the "stops working after a couple of swipes" bug). const SWIPE_LOCK_MS = 700; +// Touch two-finger flick: fingers travel together at least this far, fast, +// without twisting. Slower or twisting two-finger motion stays a move/rotate. +const TOUCH_FLICK_MIN_PX = 60; +const TOUCH_FLICK_MAX_MS = 350; +const TOUCH_FLICK_MAX_ROT_RAD = 0.35; // Each active gesture carries its own state as a discriminated union rather // than a pile of independently-optional variables, so a variant's fields @@ -42,7 +47,16 @@ const SWIPE_LOCK_MS = 700; type Gesture = | { type: "idle" } | { type: "drag"; startTime: number; offset: Vec2 } - | { type: "pinch_rotate"; startTime: number; lastMid: Vec2; lastAngle: number } + | { + type: "pinch_rotate"; + startTime: number; + lastMid: Vec2; + lastAngle: number; + startMid: Vec2; + startAngle: number; + startPos: Vec2; + startRot: number; + } | { type: "alt_rotate"; startTime: number; @@ -84,7 +98,8 @@ export function attachGestureHandlers(opts: GestureOptions): () => void { let gesture: Gesture = { type: "idle" }; - let tapCandidate: { pointerId: number; startPos: Vec2; startTime: number } | undefined; + let tapCandidate: + { pointerId: number; startPos: Vec2; startTime: number } | undefined; let swipeAccumX = 0; let swipeAccumY = 0; @@ -138,11 +153,16 @@ export function attachGestureHandlers(opts: GestureOptions): () => void { tapCandidate = undefined; const pts = Array.from(pointers.values()).map((s) => s.pos); const mid = mul2(add2(pts[0], pts[1]), 0.5); + const angle = Math.atan2(pts[1].y - pts[0].y, pts[1].x - pts[0].x); gesture = { type: "pinch_rotate", startTime: performance.now(), lastMid: mid, - lastAngle: Math.atan2(pts[1].y - pts[0].y, pts[1].x - pts[0].x), + lastAngle: angle, + startMid: mid, + startAngle: angle, + startPos: paper.pos, + startRot: paper.rot, }; return; } @@ -162,7 +182,11 @@ export function attachGestureHandlers(opts: GestureOptions): () => void { return; } - gesture = { type: "drag", startTime: performance.now(), offset: sub2(pos, paper.pos) }; + gesture = { + type: "drag", + startTime: performance.now(), + offset: sub2(pos, paper.pos), + }; }; const onPointerMove = (e: PointerEvent) => { @@ -203,7 +227,10 @@ export function attachGestureHandlers(opts: GestureOptions): () => void { } if (gesture.type === "alt_rotate" && gesture.pointerId === e.pointerId) { - const ang = Math.atan2(pos.y - gesture.anchorScreen.y, pos.x - gesture.anchorScreen.x); + const ang = Math.atan2( + pos.y - gesture.anchorScreen.y, + pos.x - gesture.anchorScreen.x, + ); paper.rot = gesture.startRot + (ang - gesture.startAngle); const anchorOffset = rotate2(mul2(gesture.anchorLocal, paper.scale), paper.rot); paper.pos = sub2(gesture.anchorScreen, anchorOffset); @@ -225,7 +252,51 @@ export function attachGestureHandlers(opts: GestureOptions): () => void { } }; + /** + * Touch counterpart of the trackpad swipe: a quick two-finger flick flips + * the paper. The flick also moved the paper through pinch_rotate, so that + * motion is undone first — the flip should happen in place. + */ + const tryTouchFlick = (): boolean => { + if (!onFlip || gesture.type !== "pinch_rotate") return false; + if (getLockState() === InputLock.Locked) return false; + if (performance.now() - gesture.startTime > TOUCH_FLICK_MAX_MS) return false; + + let twist = gesture.lastAngle - gesture.startAngle; + twist = Math.atan2(Math.sin(twist), Math.cos(twist)); + if (Math.abs(twist) > TOUCH_FLICK_MAX_ROT_RAD) return false; + + const d = sub2(gesture.lastMid, gesture.startMid); + const horizontalDominant = Math.abs(d.x) > Math.abs(d.y); + const travel = horizontalDominant ? d.x : d.y; + if (Math.abs(travel) < TOUCH_FLICK_MIN_PX) return false; + + const paper = getActivePaper(); + paper.pos = gesture.startPos; + paper.rot = gesture.startRot; + + // Same mapping as the trackpad path: wheel deltas run opposite to finger + // motion horizontally and with it vertically. + const sign = travel > 0 ? 1 : -1; + const direction: FlipDirection = horizontalDominant + ? (-sign as FlipDirection) + : sign; + onFlip(direction, horizontalDominant ? "horizontal" : "vertical"); + trackEvent("gesture_used", { + gesture_type: "touch_flick_flip", + duration_ms: Math.round(performance.now() - gesture.startTime), + }); + return true; + }; + const onPointerUp = (e: PointerEvent) => { + // Checked while both fingers are still counted: the first lift ends it. + if (pointers.size === 2 && tryTouchFlick()) { + pointers.delete(e.pointerId); + tapCandidate = undefined; + gesture = { type: "idle" }; + return; + } pointers.delete(e.pointerId); if (tapCandidate && tapCandidate.pointerId === e.pointerId) { @@ -315,7 +386,9 @@ export function attachGestureHandlers(opts: GestureOptions): () => void { const accum = horizontalDominant ? swipeAccumX : swipeAccumY; if (Math.abs(accum) >= SWIPE_FLIP_THRESHOLD_PX) { const sign = accum > 0 ? 1 : -1; - const direction: FlipDirection = horizontalDominant ? sign : (-sign as FlipDirection); + const direction: FlipDirection = horizontalDominant + ? sign + : (-sign as FlipDirection); const axis: FlipAxis = horizontalDominant ? "horizontal" : "vertical"; swipeAccumX = 0; swipeAccumY = 0; diff --git a/src/main.ts b/src/main.ts index 6fd9f61..f7b4d8f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -52,7 +52,12 @@ import { import { drawTable } from "./render/background"; import { drawHingeCrosshair } from "./render/hinge"; import { createPaperRenderer, type PaperMotion } from "./render/backend"; -import { PLAY_STORE_URL } from "./render/paper"; +import { + type BannerName, + bannerUrl, + loadBanner, + resolveDefaultBannerName, +} from "./render/banners"; import { type FrontImageSource, loadTextures, @@ -71,6 +76,18 @@ if (platform === Platform.Web) { /** Native iOS shell: a few links and controls behave differently there. */ const isIosNative = platform === Platform.Capacitor; +// Seed the iOS hinge cache before `resolvePostureSupport()` runs below — +// support is computed once, so a late answer would latch it to Unavailable. +let readCapacitorHingeAngle: () => number | null = () => null; +let isFoldable = false; +if (isIosNative) { + const { initCapacitorHinge, getCapacitorHingeAngle, isCapacitorHingeAvailable } = + await import("./device/capacitor"); + await initCapacitorHinge(); + readCapacitorHingeAngle = getCapacitorHingeAngle; + isFoldable = isCapacitorHingeAvailable(); +} + const PRIVACY_POLICY_URL = "https://kami.maxwase.eu/privacy/"; /** @@ -192,6 +209,15 @@ trackEvent("app_open", { launch_context: getLaunchContext() }); const isBrowserVisit = platform === Platform.Web && getLaunchContext() === "browser"; let twaInstalled = false; +// Which banner (Play Store / App Store / Mac) a browser visitor gets, and +// its lazily-loaded image once loadBanner() resolves. bannerImage stays +// null until the fetch completes (or fails), so bootstrap only switches the +// active paper's front material to "banner" once there's something to draw. +const activeBannerName: BannerName | null = isBrowserVisit + ? resolveDefaultBannerName() + : null; +let bannerImage: FrontImageSource | null = null; + // App Store guideline 3.1.1 forbids collecting money through a link out of an // app distributed via the App Store (iOS or the Mac App Store build), so the // tip jar only exists on web and TWA. @@ -261,6 +287,23 @@ function resize() { hingeInfo = computeHingePoint(cssW, cssH); updateFoldFallbackIcon(); } +/* Foldables (Duo cover display) leave a tall unused column on the right of + the portrait canvas, while a bottom bar eats height the paper wants. On + those devices the control footer becomes a vertical rail in that column. + `isFoldable` is the test: only hardware with a real hinge reports through + the Capacitor hinge plugin. */ +const syncSideRail = () => { + const portrait = window.innerHeight >= window.innerWidth; + document.body.classList.toggle( + "side-rail", + portrait && isFoldable, + ); +}; +syncSideRail(); + +window.addEventListener("resize", syncSideRail, { passive: true }); +window.addEventListener("orientationchange", syncSideRail, { passive: true }); + window.addEventListener("resize", resize, { passive: true }); window.addEventListener("orientationchange", resize, { passive: true }); if (window.visualViewport) { @@ -450,7 +493,9 @@ type FlipRuntime = { phase: "idle" } | { phase: "animating"; anim: FlipAnim }; let foldRuntime: FoldRuntime = { phase: "idle" }; let flipRuntime: FlipRuntime = { phase: "idle" }; -let deviceFolded = false; +// Null until the first frame seeds it with the hinge state at launch, so +// opening the app already folded (Duo outer screen) is not a fold edge. +let deviceFolded: boolean | null = null; function normalizeScreenAngle(angle: number): number { return ((Math.round(angle) % 360) + 360) % 360; @@ -609,6 +654,7 @@ canvasEl.addEventListener("pointerup", (e) => { if ( !isBrowserVisit || twaInstalled || + !activeBannerName || foldRuntime.phase !== "idle" || flipRuntime.phase !== "idle" || !bannerTappable(getActivePaper()) @@ -619,8 +665,8 @@ canvasEl.addEventListener("pointerup", (e) => { const pos = { x: e.clientX - rect.left, y: e.clientY - rect.top }; for (let i = papers.length - 1; i >= 0; i--) { if (hitTestPaper(papers[i], pos)) { - window.open(PLAY_STORE_URL, "_blank", "noopener"); - trackEvent("playstore_banner_tapped"); + window.open(bannerUrl(activeBannerName), "_blank", "noopener"); + trackEvent("playstore_banner_tapped", { banner: activeBannerName }); break; } } @@ -1048,7 +1094,7 @@ function imageForMaterial( side: SheetSide, material: PaperMaterial, ): FrontImageSource | undefined { - if (material === "banner") return textures.banner ?? undefined; + if (material === "banner") return bannerImage ?? undefined; if (material === "paper") return textures.paperImg; if (material === "custom") return pickedImages[side] ?? undefined; return undefined; @@ -1200,7 +1246,12 @@ function tick(now: number) { // Auto-fold on the fresh posture signal (folded-type = Creased or Closed), // not on segments alone: this device has a continuous screen that reports a // single segment, so a segment-only trigger lags or never fires. - const foldedNow = hingeState !== HingeState.Flat || manualFoldQueued; + // Physical and manual triggers are edge-detected separately: on the Duo's + // outer display the hinge reports Closed for as long as it is in use, so a + // combined flag would stay high and swallow every tap/button fold. + const physicallyFolded = hingeState !== HingeState.Flat; + deviceFolded ??= physicallyFolded; + const foldedNow = (physicallyFolded && !deviceFolded) || manualFoldQueued; const screenAngle = normalizeScreenAngle(getScreenAngleDeg()); const accel = motion.getAccel(); const accelMag = Math.hypot(accel.x, accel.y); @@ -1230,7 +1281,7 @@ function tick(now: number) { manualFoldQueued = false; } - if (foldRuntime.phase === "idle" && foldedNow && !deviceFolded) { + if (foldRuntime.phase === "idle" && foldedNow) { const buildResult = buildFoldAnim( { paper: getActivePaper(), @@ -1261,7 +1312,7 @@ function tick(now: number) { } } } - deviceFolded = foldedNow; + deviceFolded = physicallyFolded; const isAnimating = foldRuntime.phase === "animating" || flipRuntime.phase === "animating"; updateUndoBtn(isAnimating); @@ -1340,6 +1391,7 @@ function tick(now: number) { const bannerClickable = isBrowserVisit && !twaInstalled && + activeBannerName !== null && foldRuntime.phase === "idle" && flipRuntime.phase === "idle" && bannerTappable(getActivePaper()); @@ -1407,6 +1459,12 @@ function tick(now: number) { if (motionSupported) { debugLines.push(fmtRow({ accel: accelMag.toFixed(2) })); } + if (isIosNative) { + const hingeDegrees = readCapacitorHingeAngle(); + debugLines.push( + fmtRow({ hinge: hingeDegrees === null ? "-" : `${hingeDegrees.toFixed(0)}°` }), + ); + } const debugText = debugLines.join("\n"); if (debugStatusEl.textContent !== debugText) { debugStatusEl.textContent = debugText; @@ -1420,8 +1478,7 @@ void (async function bootstrap() { try { textures = await loadTextures(ctx); - if (isBrowserVisit) { - papers[0].materials.front = "banner"; + if (isBrowserVisit && activeBannerName) { // Progressive enhancement: most browsers lack this API, in which case // the banner default above just stands. const getInstalledRelatedApps = ( @@ -1434,12 +1491,26 @@ void (async function bootstrap() { const apps = await getInstalledRelatedApps.call(navigator); if (apps.length > 0) { twaInstalled = true; - papers[0].materials.front = "color"; } } catch { // Treat a lookup failure as "not installed" — the banner stays. } } + // Fetch the banner lazily so it can never delay first paint; only + // switch the sheet to the "banner" material once an image actually + // resolves. A failed/missing fetch (e.g. the App Store or Mac + // banner placeholders — see render/banners.ts) leaves the sheet on + // its plain paper default instead of a broken render. + if (!twaInstalled) { + const name = activeBannerName; + void loadBanner(name).then((img) => { + bannerImage = img; + if (img && activeBannerName === name && !twaInstalled) { + papers[0].materials.front = "banner"; + syncMaterialUi(); + } + }); + } } syncMaterialUi(); diff --git a/src/render/banners.ts b/src/render/banners.ts new file mode 100644 index 0000000..35f84d1 --- /dev/null +++ b/src/render/banners.ts @@ -0,0 +1,84 @@ +import { + LOAD_TIMEOUT_MS, + loadImage, + withTimeout, + type FrontImageSource, +} from "./textures"; + +/** + * The tappable ad banner exists only for a plain web-browser visit (see + * `isBrowserVisit` in main.ts) — never in the installed PWA, the Android + * TWA, the iOS Capacitor app, or the Tauri macOS app. Which banner a browser + * visitor sees depends on their device; every other platform gets an + * ordinary paper sheet. + */ +export type BannerName = "playstore" | "appstore" | "mac"; + +interface BannerEntry { + /** Path under /public, same convention as the other textures. */ + src: string; + url: string; +} + +export const PLAY_STORE_URL = + "https://play.google.com/store/apps/details?id=eu.maxwase.kami.twa"; + +// Placeholders — the user has not supplied the App Store / Mac App Store +// listing links yet. Replace these with the real URLs once the apps are +// published. +export const APP_STORE_URL = "https://apps.apple.com/app/id0000000000"; +export const MAC_APP_STORE_URL = "https://apps.apple.com/app/id0000000001"; + +const REGISTRY: Record = { + playstore: { src: "textures/kami-banner.jpg", url: PLAY_STORE_URL }, + // Placeholder asset — the user has not supplied App Store banner artwork + // yet. Drop the real image in at public/textures/kami-banner-appstore.jpg + // and update APP_STORE_URL above; until then loadBanner() resolves null + // here and the caller falls back to a plain paper sheet (see main.ts). + appstore: { src: "textures/kami-banner-appstore.jpg", url: APP_STORE_URL }, + // Placeholder asset — same story, for public/textures/kami-banner-mac.jpg + // and MAC_APP_STORE_URL. + mac: { src: "textures/kami-banner-mac.jpg", url: MAC_APP_STORE_URL }, +}; + +const cache = new Map>(); + +/** + * Fetch a banner image by name, lazily and memoized per name. Any banner can + * be requested regardless of platform — callers decide which name to ask + * for. A missing file or slow/failed load resolves to null rather than + * throwing, same contract as the old eager loader, so a caller can degrade + * to a plain paper sheet instead of a broken render. + */ +export function loadBanner(name: BannerName): Promise { + let promise = cache.get(name); + if (!promise) { + promise = withTimeout(loadImage(REGISTRY[name].src), LOAD_TIMEOUT_MS).catch( + () => null, + ); + cache.set(name, promise); + } + return promise; +} + +export function bannerUrl(name: BannerName): string { + return REGISTRY[name].url; +} + +/** + * Which banner a plain web-browser visitor defaults to, from device signals + * alone (platform/install-state gating happens in main.ts via + * isBrowserVisit). iPadOS 13+ reports as a Mac in the UA string but keeps + * touch support, hence the maxTouchPoints check. + */ +export function resolveDefaultBannerName(): BannerName { + if (typeof navigator === "undefined") return "playstore"; + const ua = navigator.userAgent; + const isIPad = + /iPad/.test(ua) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + const isIPhoneOrIPod = /iPhone|iPod/.test(ua); + if (isIPad || isIPhoneOrIPod) return "appstore"; + if (/Macintosh|Mac OS X/.test(ua)) return "mac"; + return "playstore"; +} diff --git a/src/render/paper.ts b/src/render/paper.ts index 6d0624f..8705fa4 100644 --- a/src/render/paper.ts +++ b/src/render/paper.ts @@ -717,9 +717,6 @@ function expandTriangle(a: Vec2, b: Vec2, c: Vec2, px: number): [Vec2, Vec2, Vec return [push(a), push(b), push(c)]; } -export const PLAY_STORE_URL = - "https://play.google.com/store/apps/details?id=eu.maxwase.kami.twa"; - function alignTextureToPaper(texture: CanvasPattern, paper: Paper): void { if (!("setTransform" in texture)) return; const m = new DOMMatrix(); diff --git a/src/render/textures.ts b/src/render/textures.ts index cbf0030..a3138f6 100644 --- a/src/render/textures.ts +++ b/src/render/textures.ts @@ -7,8 +7,6 @@ export interface TextureSet { paper: CanvasPattern; /** Same paper photo as an image, UV-mapped for material "paper". */ paperImg: FrontImageSource; - /** Kami Play Store banner, or null if it fails to load. */ - banner: FrontImageSource | null; } const LOAD_TIMEOUT_MS = 8000; @@ -19,25 +17,17 @@ const PAPER_FALLBACK_COLOR = "#f5f0e6"; * Load texture images and create repeating patterns when ready. Falls back * to a solid-color source if an image fails to load or takes too long, so a * flaky network request can never permanently block the render loop. The - * banner has no fallback color — a load failure just leaves it null and the - * banner material is skipped. + * banner is not loaded here — it's fetched lazily and on demand by + * `loadBanner()` in ./banners, so a slow or missing banner asset can never + * delay first paint. */ export async function loadTextures(ctx: CanvasRenderingContext2D): Promise { - const [wood, paperImg, banner] = await Promise.all([ + const [wood, paperImg] = await Promise.all([ loadImageWithFallback("textures/wood.jpg", WOOD_FALLBACK_COLOR), loadImageWithFallback("textures/paper.jpg", PAPER_FALLBACK_COLOR), - loadBanner(), ]); const paper = patternFromImage(ctx, paperImg, PAPER_FALLBACK_COLOR); - return { wood, paper, paperImg, banner }; -} - -async function loadBanner(): Promise { - try { - return await withTimeout(loadImage("textures/kami-banner.jpg"), LOAD_TIMEOUT_MS); - } catch { - return null; - } + return { wood, paper, paperImg }; } function patternFromImage( @@ -68,7 +58,9 @@ function solidCanvas(color: string, size = 64): HTMLCanvasElement { return canvas; } -function loadImage(src: string): Promise { +export { LOAD_TIMEOUT_MS }; + +export function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.src = src; @@ -77,7 +69,7 @@ function loadImage(src: string): Promise { }); } -function withTimeout(promise: Promise, ms: number): Promise { +export function withTimeout(promise: Promise, ms: number): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("Texture load timed out")), ms); promise.then( diff --git a/src/render/webgl/context.ts b/src/render/webgl/context.ts index 5a34fac..4a56bd0 100644 --- a/src/render/webgl/context.ts +++ b/src/render/webgl/context.ts @@ -18,5 +18,8 @@ export function createGLContext(canvas: HTMLCanvasElement): GL | null { } export function isWebGL2(gl: GL): gl is WebGL2RenderingContext { - return typeof WebGL2RenderingContext !== "undefined" && gl instanceof WebGL2RenderingContext; + return ( + typeof WebGL2RenderingContext !== "undefined" && + gl instanceof WebGL2RenderingContext + ); } diff --git a/src/render/webgl/renderer.ts b/src/render/webgl/renderer.ts index 1630cf2..5ad5252 100644 --- a/src/render/webgl/renderer.ts +++ b/src/render/webgl/renderer.ts @@ -133,7 +133,8 @@ export class WebGLPaperRenderer { drawFoldingPaper(paper: Paper, anim: FoldAnim, images: SideImages): void { const pc = this.paperContext(paper, images); const progress = easeInOutCubic(anim.progress); - const signedAngle = progress * Math.PI * (anim.foldSide === FoldSide.Front ? -1 : 1); + const signedAngle = + progress * Math.PI * (anim.foldSide === FoldSide.Front ? -1 : 1); const axisDir = norm2(anim.lineLocal.dir); const normalRot = rotateAroundAxis( v3(0, 0, 1), @@ -195,7 +196,8 @@ export class WebGLPaperRenderer { angle, normal: viewingBackSide ? mul3(normalRot, -1) : normalRot, }; - const renderLayer = (f: Face) => (viewingBackSide ? anim.maxLayer - f.layer : f.layer); + const renderLayer = (f: Face) => + viewingBackSide ? anim.maxLayer - f.layer : f.layer; // Canvas2D sorts by layer, then (stably) by render layer. const faces = [...anim.originalFaces] .sort((a, b) => a.layer - b.layer) @@ -224,7 +226,12 @@ export class WebGLPaperRenderer { }; } - private drawFace(pc: PaperContext, face: Face, side: PaperSide, pose: FacePose): void { + private drawFace( + pc: PaperContext, + face: Face, + side: PaperSide, + pose: FacePose, + ): void { const { verts } = face; if (verts.length < 3) return; const image = pc.images[side]; @@ -248,7 +255,9 @@ export class WebGLPaperRenderer { } const lighting = calculateLighting(pose.normal); - const color = parseColor(side === "front" ? pc.paper.style.front : pc.paper.style.back); + const color = parseColor( + side === "front" ? pc.paper.style.front : pc.paper.style.back, + ); const uniforms: Uniforms = { ...pc.uniforms, u_axisPoint: [pose.axisPoint.x, pose.axisPoint.y], @@ -283,14 +292,24 @@ export class WebGLPaperRenderer { } /** twgl attribute layout for one buffer interleaving ATTRIBUTE_SPEC in order. */ -function interleavedAttribs(buffer: WebGLBuffer): Record { +function interleavedAttribs( + buffer: WebGLBuffer, +): Record { let offset = 0; const entries = Object.entries(ATTRIBUTE_SPEC).map(([name, numComponents]) => { - const info: twgl.AttribInfo = { buffer, numComponents, offset, stride: VERTEX_STRIDE }; + const info: twgl.AttribInfo = { + buffer, + numComponents, + offset, + stride: VERTEX_STRIDE, + }; offset += numComponents * Float32Array.BYTES_PER_ELEMENT; return [name, info] as const; }); - return Object.fromEntries(entries) as Record; + return Object.fromEntries(entries) as Record< + keyof typeof ATTRIBUTE_SPEC, + twgl.AttribInfo + >; } interface ParsedColor { diff --git a/src/render/webgl/textures.ts b/src/render/webgl/textures.ts index 7413156..71e537c 100644 --- a/src/render/webgl/textures.ts +++ b/src/render/webgl/textures.ts @@ -44,7 +44,8 @@ export class TextureCache { hit.lastUsedFrame = this.frame; return hit.texture; } - const texture = kind === "image" ? this.createImage(source) : this.createPattern(source); + const texture = + kind === "image" ? this.createImage(source) : this.createPattern(source); map.set(source, { texture, lastUsedFrame: this.frame }); return texture; } @@ -125,7 +126,11 @@ function nextPowerOfTwo(n: number): number { return 2 ** Math.ceil(Math.log2(Math.max(1, n))); } -function resample(source: FrontImageSource, width: number, height: number): HTMLCanvasElement { +function resample( + source: FrontImageSource, + width: number, + height: number, +): HTMLCanvasElement { const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; diff --git a/src/style.css b/src/style.css index 00541c0..d07b49f 100644 --- a/src/style.css +++ b/src/style.css @@ -755,7 +755,11 @@ body { bottom: 12px; z-index: 20; display: inline-flex; - flex-wrap: wrap; + /* Never wrap: the Duo's cover display reserves an 84px right safe-area + strip for the sensor housing, leaving ~358px of usable width, and a + wrapped Info button was landing on a second row there. The buttons + shrink to fit instead (see the flex rules below). */ + flex-wrap: nowrap; align-items: center; justify-content: center; gap: 10px; @@ -793,6 +797,28 @@ body { display: contents; } + /* `display: contents` above makes every button a direct flex child of the + footer, so they all shrink together when the usable width is tight. The + 56px stays the preferred size; min-width: 0 is what actually lets a flex + item go below its content width. */ + #control-footer #action-island button, + #control-footer #toggleStyle, + #control-footer #toggleSettings, + #control-footer #toggleInfo { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + } + + /* Labels are the first thing to overflow a shrunken button; clip them to + the button rather than letting them push it wider. */ + #control-footer button span:not(.material-symbols-outlined) { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .settings-panel { --settings-top: 16px; left: calc(12px + var(--safe-left)); @@ -857,3 +883,48 @@ body { gap: 4px; } } + +/* --- Side Rail (foldable cover display) --------------------------------- + The Duo's cover display reserves a tall strip on the right for the sensor + housing, exposed as a large safe-area-inset-right. That strip can never + hold paper, so on foldables the control footer moves into it as a vertical + rail instead of eating canvas height at the bottom. `body.side-rail` is set + from main.ts. Only layout is overridden here: the panel surface, button + fill and type sizes stay the ones the bottom bar uses, so the rail reads as + the same control, rotated. */ +@media (orientation: portrait) { + body.side-rail #control-footer { + /* The housing is painted over the strip but is never reported as a top + inset on the Duo, so the rail clears it with a floor rather than + trusting --safe-top. */ + top: calc(max(var(--safe-top), 176px) + 10px); + bottom: auto; + height: max-content; + /* Centered in the inset column, which is where the status pill sits, so + the rail lines up under the Wi-Fi icon instead of hugging the edge, + then nudged 5px toward the edge to sit under the camera and pill. */ + left: auto; + right: calc(var(--safe-right) / 2 - var(--rail-width) / 2 - 3px); + --rail-width: 56px; + width: var(--rail-width); + max-width: none; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 8px; + padding-bottom: 8px; + border-radius: 20px; + transform: none; + } + + body.side-rail #control-footer #action-island button, + body.side-rail #control-footer #toggleStyle, + body.side-rail #control-footer #toggleSettings, + body.side-rail #control-footer #toggleInfo { + flex: 0 0 auto; + width: 56px; + height: 56px; + border-radius: 16px; + } +}