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 @@
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;
+ }
+}