From f1a3d7fa08931e35b6cbea76e3be13d3a8d31ef8 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Tue, 8 Sep 2026 16:23:39 +0800 Subject: [PATCH 1/2] fix(frame): render legacy iPhone chassis hardware --- CHANGELOG.md | 5 ++ packages/devices/package.json | 2 +- packages/devices/src/devices.ts | 28 +++++- packages/devices/src/index.ts | 2 + packages/devices/src/presets/ios.ts | 28 +++--- packages/devices/src/shell-defaults.test.ts | 43 +++++++++ packages/devices/src/validate.ts | 12 +++ packages/devices/type-tests/resolved-shell.ts | 8 ++ packages/devices/type-tests/tsconfig.json | 12 +++ packages/frame/src/device-frame.test.ts | 6 +- packages/frame/src/device-frame.ts | 17 +++- packages/frame/src/frame-size.ts | 10 ++- packages/frame/src/legacy-shell.test.ts | 90 +++++++++++++++++++ packages/frame/src/metrics.ts | 4 +- packages/frame/src/reflect.ts | 7 ++ packages/frame/src/status-bar-styles.ts | 4 + packages/frame/src/status-bar.ts | 17 ++-- packages/frame/src/styles.ts | 30 ++++++- 18 files changed, 297 insertions(+), 28 deletions(-) create mode 100644 packages/devices/src/shell-defaults.test.ts create mode 100644 packages/devices/type-tests/resolved-shell.ts create mode 100644 packages/devices/type-tests/tsconfig.json create mode 100644 packages/frame/src/legacy-shell.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c1db03..93e585a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `@devicekit/frame`: hiding status-bar text no longer hides a phone's physical cutout; Dynamic Island and notch devices retain the screen obstruction outside embedded and landscape frames. +- `@devicekit/devices` and `@devicekit/frame`: Home-button iPhones now describe their asymmetric chassis and physical button, so square LCDs sit inside thick top and bottom bezels instead of reaching the rounded body corners. + ## [0.2.2] - 2026-09-04 ### Fixed diff --git a/packages/devices/package.json b/packages/devices/package.json index 790ae57..20ed66c 100644 --- a/packages/devices/package.json +++ b/packages/devices/package.json @@ -45,7 +45,7 @@ "scripts": { "build": "tsc -p tsconfig.build.json", "generate:device-names": "pnpm run build && node scripts/generate-device-names.mjs", - "check-types": "tsc --noEmit", + "check-types": "tsc --noEmit && tsc --noEmit -p type-tests/tsconfig.json", "lint": "eslint . --max-warnings 0", "test": "vitest run --reporter=default --reporter=json --outputFile.json=test-report.json --coverage.enabled --coverage.reporter=json-summary --coverage.reportsDirectory=coverage", "test:dev": "vitest", diff --git a/packages/devices/src/devices.ts b/packages/devices/src/devices.ts index 6d40c35..bf44895 100644 --- a/packages/devices/src/devices.ts +++ b/packages/devices/src/devices.ts @@ -81,10 +81,23 @@ export interface DeviceShell { screenRadius: number /** Body thickness around the screen on every side. 0 = a bezel-less preview. */ bezel: number + /** Per-edge body thickness. Omitted edges keep the uniform `bezel` value. */ + bezelInsets?: Partial + /** A physical Home button centered in the bottom bezel. */ + homeButton?: HomeButtonSpec | null /** Body corner radius. Omitted = screenRadius + bezel, which keeps the two concentric. */ bodyRadius?: number } +export interface HomeButtonSpec { + diameter: number +} + +export interface ResolvedDeviceShell extends Required> { + bezelInsets: EdgeInsets + homeButton: HomeButtonSpec | null +} + /** * One device as the table stores it: everything measured, nothing derived. * Optional fields fall back to that platform's entry in PLATFORM_DEFAULTS — @@ -195,7 +208,7 @@ export interface ResolvedDevice { safeAreaInsets: EdgeInsets safeAreaInsetsLandscape: EdgeInsets cutout: CutoutSpec | null - shell: Required + shell: ResolvedDeviceShell } function withInsets(partial: Partial | undefined, fallback: EdgeInsets): EdgeInsets { @@ -222,6 +235,17 @@ export function resolveDevice(profile: DeviceProfile): ResolvedDevice { const statusBarHeightLandscape = profile.statusBarHeightLandscape ?? defaults.statusBarHeightLandscape const screenRadius = profile.shell?.screenRadius ?? defaults.shell.screenRadius const bezel = profile.shell?.bezel ?? defaults.shell.bezel + const uniformBezelInsets = { top: bezel, right: bezel, bottom: bezel, left: bezel } + const profileInsets = profile.shell?.bezelInsets + const bezelInsets = profileInsets !== undefined + ? withInsets(profileInsets, uniformBezelInsets) + : profile.shell?.bezel !== undefined + ? uniformBezelInsets + : withInsets(defaults.shell.bezelInsets, uniformBezelInsets) + const profileHomeButton = profile.shell?.homeButton + const homeButton = profileHomeButton !== undefined + ? profileHomeButton + : defaults.shell.homeButton ?? null return { name: profile.name, @@ -241,6 +265,8 @@ export function resolveDevice(profile: DeviceProfile): ResolvedDevice { shell: { screenRadius, bezel, + bezelInsets, + homeButton, bodyRadius: profile.shell?.bodyRadius ?? screenRadius + bezel, }, } diff --git a/packages/devices/src/index.ts b/packages/devices/src/index.ts index 7c9eb30..937e7f0 100644 --- a/packages/devices/src/index.ts +++ b/packages/devices/src/index.ts @@ -11,8 +11,10 @@ export { type DeviceProfile, type DeviceShell, type EdgeInsets, + type HomeButtonSpec, type Orientation, type ResolvedDevice, + type ResolvedDeviceShell, type ScreenSize, } from './devices.js' diff --git a/packages/devices/src/presets/ios.ts b/packages/devices/src/presets/ios.ts index cf57a8a..29c354e 100644 --- a/packages/devices/src/presets/ios.ts +++ b/packages/devices/src/presets/ios.ts @@ -6,9 +6,11 @@ */ import type { DeviceProfile } from '../devices.js' +const LEGACY_IPHONE_SHELL = { screenRadius: 0, bodyRadius: 38, bezelInsets: { top: 44, bottom: 48 }, homeButton: { diameter: 30 } } + /** iPhones and iPads. Part of DEVICES; listed here so a host can offer one platform alone. */ export const IOS_DEVICES: readonly DeviceProfile[] = [ - { name: 'iPhone SE', os: 'ios', screen: { width: 375, height: 667 }, pixelRatio: 2, system: 'iOS 15.0', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 20 }, shell: { screenRadius: 0, bodyRadius: 38 } }, + { name: 'iPhone SE', os: 'ios', screen: { width: 375, height: 667 }, pixelRatio: 2, system: 'iOS 15.0', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 20 }, shell: LEGACY_IPHONE_SHELL }, { name: 'iPhone XR', os: 'ios', screen: { width: 414, height: 896 }, pixelRatio: 2, system: 'iOS 18.5', statusBarHeight: 48, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 48, bottom: 34 }, safeAreaInsetsLandscape: { left: 48, right: 48, bottom: 21 }, cutout: { shape: 'notch', width: 231, height: 33, top: 0 } }, { name: 'iPhone 12 Pro', os: 'ios', screen: { width: 390, height: 844 }, pixelRatio: 3, system: 'iOS 18.5', statusBarHeight: 47, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 47, bottom: 34 }, safeAreaInsetsLandscape: { left: 47, right: 47, bottom: 21 }, cutout: { shape: 'notch', width: 210, height: 32, top: 0 } }, { name: 'iPhone 14', os: 'ios', screen: { width: 390, height: 844 }, pixelRatio: 3, system: 'iOS 18.5', statusBarHeight: 47, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 47, bottom: 34 }, safeAreaInsetsLandscape: { left: 47, right: 47, bottom: 21 }, cutout: { shape: 'notch', width: 162, height: 34, top: 0 }, shell: { screenRadius: 47 } }, @@ -26,9 +28,9 @@ export const IOS_DEVICES: readonly DeviceProfile[] = [ { name: 'iPhone 16 Pro Max', os: 'ios', screen: { width: 440, height: 956 }, pixelRatio: 3, system: 'iOS 18.5', statusBarHeight: 54, safeAreaInsets: { top: 62, bottom: 34 }, safeAreaInsetsLandscape: { left: 62, right: 62, bottom: 21 }, cutout: { shape: 'pill', width: 125, height: 37, top: 14 }, shell: { screenRadius: 62 } }, { name: 'iPad Mini', os: 'ios', formFactor: 'tablet', screen: { width: 744, height: 1133 }, pixelRatio: 2, system: 'iOS 18.5', statusBarHeight: 24, statusBarHeightLandscape: 24, safeAreaInsets: { top: 24, bottom: 25 }, safeAreaInsetsLandscape: { top: 24, bottom: 25 }, shell: { screenRadius: 18 } }, { name: 'iPad Pro 13', os: 'ios', formFactor: 'tablet', screen: { width: 1032, height: 1376 }, pixelRatio: 2, system: 'iPadOS 18.5', statusBarHeight: 24, statusBarHeightLandscape: 24, safeAreaInsets: { top: 24, bottom: 25 }, safeAreaInsetsLandscape: { top: 24, bottom: 25 }, shell: { screenRadius: 18 } }, - { name: 'iPhone 5', os: 'ios', screen: { width: 320, height: 568 }, pixelRatio: 2, system: 'iOS 10.3', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 20 }, shell: { screenRadius: 0, bodyRadius: 38 } }, - { name: 'iPhone 6/7/8', os: 'ios', screen: { width: 375, height: 667 }, pixelRatio: 2, system: 'iOS 11.0', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 20 }, shell: { screenRadius: 0, bodyRadius: 38 } }, - { name: 'iPhone 6/7/8 Plus', os: 'ios', screen: { width: 414, height: 736 }, pixelRatio: 3, system: 'iOS 11.0', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 44, safeAreaInsets: { top: 20 }, shell: { screenRadius: 0, bodyRadius: 38 } }, + { name: 'iPhone 5', os: 'ios', screen: { width: 320, height: 568 }, pixelRatio: 2, system: 'iOS 10.3', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 20 }, shell: LEGACY_IPHONE_SHELL }, + { name: 'iPhone 6/7/8', os: 'ios', screen: { width: 375, height: 667 }, pixelRatio: 2, system: 'iOS 11.0', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 20 }, shell: LEGACY_IPHONE_SHELL }, + { name: 'iPhone 6/7/8 Plus', os: 'ios', screen: { width: 414, height: 736 }, pixelRatio: 3, system: 'iOS 11.0', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 44, safeAreaInsets: { top: 20 }, shell: LEGACY_IPHONE_SHELL }, { name: 'iPhone X', os: 'ios', screen: { width: 375, height: 812 }, pixelRatio: 3, system: 'iOS 16.0', statusBarHeight: 44, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 44, bottom: 34 }, safeAreaInsetsLandscape: { left: 44, right: 44, bottom: 21 }, cutout: { shape: 'notch', width: 209, height: 30, top: 0 }, shell: { screenRadius: 39 } }, { name: 'iPhone XS Max', os: 'ios', screen: { width: 414, height: 896 }, pixelRatio: 3, system: 'iOS 11.0', statusBarHeight: 44, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 44, bottom: 34 }, safeAreaInsetsLandscape: { left: 44, right: 44, bottom: 21 }, cutout: { shape: 'notch', width: 209, height: 30, top: 0 } }, { name: 'iPhone 12/13 mini', os: 'ios', screen: { width: 375, height: 812 }, pixelRatio: 3, system: 'iOS 15.0', statusBarHeight: 50, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 50, bottom: 34 }, safeAreaInsetsLandscape: { left: 50, right: 50, bottom: 21 }, cutout: { shape: 'notch', width: 192, height: 30, top: 0 } }, @@ -42,13 +44,13 @@ export const IOS_DEVICES: readonly DeviceProfile[] = [ { name: 'iPad (gen 7)', os: 'ios', formFactor: 'tablet', screen: { width: 810, height: 1080 }, pixelRatio: 2, system: 'iOS 12.2', statusBarHeight: 20, statusBarHeightLandscape: 20, safeAreaInsets: { top: 20 }, safeAreaInsetsLandscape: { top: 20 }, shell: { screenRadius: 0 } }, { name: 'iPad (gen 11)', os: 'ios', formFactor: 'tablet', screen: { width: 820, height: 1180 }, pixelRatio: 2, system: 'iOS 18.5', statusBarHeight: 24, statusBarHeightLandscape: 24, safeAreaInsets: { top: 24, bottom: 25 }, safeAreaInsetsLandscape: { top: 24, bottom: 25 }, shell: { screenRadius: 18 } }, { name: 'iPad Pro 11', os: 'ios', formFactor: 'tablet', screen: { width: 834, height: 1194 }, pixelRatio: 2, system: 'iOS 12.2', statusBarHeight: 24, statusBarHeightLandscape: 24, safeAreaInsets: { top: 24, bottom: 25 }, safeAreaInsetsLandscape: { top: 24, bottom: 25 }, shell: { screenRadius: 18 } }, - { name: 'iPhone 6', os: 'ios', screen: { width: 375, height: 667 }, pixelRatio: 2, system: 'iOS 11.0', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 20 }, shell: { screenRadius: 0, bodyRadius: 38 } }, - { name: 'iPhone 6 Plus', os: 'ios', screen: { width: 414, height: 736 }, pixelRatio: 3, system: 'iOS 11.0', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 44, safeAreaInsets: { top: 20 }, shell: { screenRadius: 0, bodyRadius: 38 } }, - { name: 'iPhone 7', os: 'ios', screen: { width: 375, height: 667 }, pixelRatio: 2, system: 'iOS 11.0', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 20 }, shell: { screenRadius: 0, bodyRadius: 38 } }, - { name: 'iPhone 7 Plus', os: 'ios', screen: { width: 414, height: 736 }, pixelRatio: 3, system: 'iOS 11.0', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 44, safeAreaInsets: { top: 20 }, shell: { screenRadius: 0, bodyRadius: 38 } }, - { name: 'iPhone 8', os: 'ios', screen: { width: 375, height: 667 }, pixelRatio: 2, system: 'iOS 11.0', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 20 }, shell: { screenRadius: 0, bodyRadius: 38 } }, - { name: 'iPhone 8 Plus', os: 'ios', screen: { width: 414, height: 736 }, pixelRatio: 3, system: 'iOS 11.0', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 44, safeAreaInsets: { top: 20 }, shell: { screenRadius: 0, bodyRadius: 38 } }, - { name: 'iPhone SE (3rd gen)', os: 'ios', screen: { width: 375, height: 667 }, pixelRatio: 2, system: 'iOS 18.5', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 20 }, shell: { screenRadius: 0, bodyRadius: 38 } }, + { name: 'iPhone 6', os: 'ios', screen: { width: 375, height: 667 }, pixelRatio: 2, system: 'iOS 11.0', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 20 }, shell: LEGACY_IPHONE_SHELL }, + { name: 'iPhone 6 Plus', os: 'ios', screen: { width: 414, height: 736 }, pixelRatio: 3, system: 'iOS 11.0', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 44, safeAreaInsets: { top: 20 }, shell: LEGACY_IPHONE_SHELL }, + { name: 'iPhone 7', os: 'ios', screen: { width: 375, height: 667 }, pixelRatio: 2, system: 'iOS 11.0', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 20 }, shell: LEGACY_IPHONE_SHELL }, + { name: 'iPhone 7 Plus', os: 'ios', screen: { width: 414, height: 736 }, pixelRatio: 3, system: 'iOS 11.0', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 44, safeAreaInsets: { top: 20 }, shell: LEGACY_IPHONE_SHELL }, + { name: 'iPhone 8', os: 'ios', screen: { width: 375, height: 667 }, pixelRatio: 2, system: 'iOS 11.0', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 20 }, shell: LEGACY_IPHONE_SHELL }, + { name: 'iPhone 8 Plus', os: 'ios', screen: { width: 414, height: 736 }, pixelRatio: 3, system: 'iOS 11.0', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 44, safeAreaInsets: { top: 20 }, shell: LEGACY_IPHONE_SHELL }, + { name: 'iPhone SE (3rd gen)', os: 'ios', screen: { width: 375, height: 667 }, pixelRatio: 2, system: 'iOS 18.5', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 20 }, shell: LEGACY_IPHONE_SHELL }, { name: 'iPhone 11', os: 'ios', screen: { width: 414, height: 896 }, pixelRatio: 2, system: 'iOS 12.2', statusBarHeight: 48, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 48, bottom: 34 }, safeAreaInsetsLandscape: { left: 48, right: 48, bottom: 21 }, cutout: { shape: 'notch', width: 231, height: 33, top: 0 } }, { name: 'iPhone 11 Pro', os: 'ios', screen: { width: 375, height: 812 }, pixelRatio: 3, system: 'iOS 12.2', navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 44, bottom: 34 }, safeAreaInsetsLandscape: { left: 44, right: 44, bottom: 21 }, cutout: { shape: 'notch', width: 209, height: 30, top: 0 } }, { name: 'iPhone 11 Pro Max', os: 'ios', screen: { width: 414, height: 896 }, pixelRatio: 3, system: 'iOS 12.2', statusBarHeight: 44, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 44, bottom: 34 }, safeAreaInsetsLandscape: { left: 44, right: 44, bottom: 21 }, cutout: { shape: 'notch', width: 209, height: 30, top: 0 } }, @@ -64,8 +66,8 @@ export const IOS_DEVICES: readonly DeviceProfile[] = [ { name: 'iPhone 17 Pro', os: 'ios', screen: { width: 402, height: 874 }, pixelRatio: 3, system: 'iOS 26.0', statusBarHeight: 54, safeAreaInsets: { top: 62, bottom: 34 }, safeAreaInsetsLandscape: { top: 0, left: 62, right: 62, bottom: 20 }, cutout: { shape: 'pill', width: 125, height: 37, top: 14 }, shell: { screenRadius: 62 } }, { name: 'iPhone 17 Pro Max', os: 'ios', screen: { width: 440, height: 956 }, pixelRatio: 3, system: 'iOS 18.7', statusBarHeight: 54, safeAreaInsets: { top: 62, bottom: 34 }, safeAreaInsetsLandscape: { left: 62, right: 62, bottom: 20 }, cutout: { shape: 'pill', width: 125, height: 37, top: 14 } }, { name: 'iPhone 17e', os: 'ios', screen: { width: 390, height: 844 }, pixelRatio: 3, system: 'iOS 18.7', statusBarHeight: 47, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 47, bottom: 34 }, safeAreaInsetsLandscape: { left: 47, right: 47, bottom: 20 }, cutout: { shape: 'notch', width: 162, height: 34, top: 0 } }, - { name: 'iPhone 4', os: 'ios', screen: { width: 320, height: 480 }, pixelRatio: 2, system: 'iOS 7.1', statusBarHeight: 20, safeAreaInsets: { top: 20 }, shell: { screenRadius: 0, bodyRadius: 38 } }, - { name: 'iPhone 5/SE', os: 'ios', screen: { width: 320, height: 568 }, pixelRatio: 2, system: 'iOS 10.3', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 20 }, shell: { screenRadius: 0, bodyRadius: 38 } }, + { name: 'iPhone 4', os: 'ios', screen: { width: 320, height: 480 }, pixelRatio: 2, system: 'iOS 7.1', statusBarHeight: 20, safeAreaInsets: { top: 20 }, shell: LEGACY_IPHONE_SHELL }, + { name: 'iPhone 5/SE', os: 'ios', screen: { width: 320, height: 568 }, pixelRatio: 2, system: 'iOS 10.3', statusBarHeight: 20, navigationBarHeight: 44, navigationBarHeightLandscape: 32, safeAreaInsets: { top: 20 }, shell: LEGACY_IPHONE_SHELL }, { name: 'iPad Air', os: 'ios', formFactor: 'tablet', screen: { width: 820, height: 1180 }, pixelRatio: 2, system: 'iOS 13.3', statusBarHeight: 24, statusBarHeightLandscape: 24, safeAreaInsets: { top: 24, bottom: 25 }, safeAreaInsetsLandscape: { top: 24, bottom: 25 }, shell: { screenRadius: 18 } }, { name: 'iPad Pro', os: 'ios', formFactor: 'tablet', screen: { width: 1024, height: 1366 }, pixelRatio: 2, system: 'iOS 11.0', statusBarHeight: 24, statusBarHeightLandscape: 24, safeAreaInsets: { top: 24, bottom: 25 }, safeAreaInsetsLandscape: { top: 24, bottom: 25 }, shell: { screenRadius: 18 } }, { name: 'iPhone 17 Air', os: 'ios', screen: { width: 420, height: 912 }, pixelRatio: 3, system: 'iOS 18.0', statusBarHeight: 54, safeAreaInsets: { top: 68, bottom: 34 }, safeAreaInsetsLandscape: { left: 68, right: 68, bottom: 20 }, cutout: { shape: 'pill', width: 125, height: 37, top: 20 } }, diff --git a/packages/devices/src/shell-defaults.test.ts b/packages/devices/src/shell-defaults.test.ts new file mode 100644 index 0000000..b4f557c --- /dev/null +++ b/packages/devices/src/shell-defaults.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { PLATFORM_DEFAULTS, resolveDevice, type DeviceProfile, type DeviceShell } from './devices.js' +import { assertDeviceProfile } from './validate.js' + +const profile = (shell?: Partial): DeviceProfile => ({ + name: 'shell fixture', os: 'ios', screen: { width: 100, height: 200 }, pixelRatio: 1, shell, +}) + +const original = { ...PLATFORM_DEFAULTS.ios, shell: { ...PLATFORM_DEFAULTS.ios.shell } } + +afterEach(() => { + PLATFORM_DEFAULTS.ios = { ...original, shell: { ...original.shell } } +}) + +describe('resolved shell default precedence', () => { + it('inherits every platform inset and Home button when the profile has no shell geometry', () => { + PLATFORM_DEFAULTS.ios.shell = { screenRadius: 10, bezel: 3, bezelInsets: { top: 11, right: 12, bottom: 13, left: 14 }, homeButton: { diameter: 20 } } + expect(resolveDevice(profile()).shell).toMatchObject({ + bezel: 3, + bezelInsets: { top: 11, right: 12, bottom: 13, left: 14 }, + homeButton: { diameter: 20 }, + }) + }) + + it('uses an explicit uniform bezel for every edge instead of retaining platform insets', () => { + PLATFORM_DEFAULTS.ios.shell = { screenRadius: 10, bezel: 3, bezelInsets: { top: 11, right: 12, bottom: 13, left: 14 } } + expect(resolveDevice(profile({ bezel: 7 })).shell.bezelInsets).toEqual({ top: 7, right: 7, bottom: 7, left: 7 }) + }) + + it('uses partial profile insets including zero, with the resolved uniform bezel for missing edges', () => { + PLATFORM_DEFAULTS.ios.shell = { screenRadius: 10, bezel: 3, bezelInsets: { top: 11, right: 12, bottom: 13, left: 14 } } + expect(resolveDevice(profile({ bezel: 7, bezelInsets: { top: 0, left: 9 } })).shell.bezelInsets).toEqual({ top: 0, right: 7, bottom: 7, left: 9 }) + }) + + it('allows resolved shells to round-trip, treats undefined as omitted, and lets null disable a platform button', () => { + PLATFORM_DEFAULTS.ios.shell = { screenRadius: 10, bezel: 3, homeButton: { diameter: 20 } } + const resolved = resolveDevice(profile()) + const roundTrip: DeviceProfile = { ...profile(), shell: resolved.shell } + expect(() => assertDeviceProfile(roundTrip)).not.toThrow() + expect(resolveDevice(profile({ homeButton: undefined })).shell.homeButton).toEqual({ diameter: 20 }) + expect(resolveDevice(profile({ homeButton: null })).shell.homeButton).toBeNull() + }) +}) diff --git a/packages/devices/src/validate.ts b/packages/devices/src/validate.ts index 68b1f5c..fbdffda 100644 --- a/packages/devices/src/validate.ts +++ b/packages/devices/src/validate.ts @@ -141,5 +141,17 @@ export function assertDeviceProfile(value: unknown, label = 'deviceProfile'): as expectFiniteAtLeast(`${label}.shell.${field}`, shell[field], 0, false) } } + if (shell.bezelInsets !== undefined) { + if (!isPlainObject(shell.bezelInsets)) throw new TypeError(`${label}.shell.bezelInsets must be an object`) + for (const edge of EDGES) { + if (shell.bezelInsets[edge] !== undefined) { + expectFiniteAtLeast(`${label}.shell.bezelInsets.${edge}`, shell.bezelInsets[edge], 0, false) + } + } + } + if (shell.homeButton !== undefined && shell.homeButton !== null) { + if (!isPlainObject(shell.homeButton)) throw new TypeError(`${label}.shell.homeButton must be an object`) + expectFiniteAtLeast(`${label}.shell.homeButton.diameter`, shell.homeButton.diameter, 0, true) + } } } diff --git a/packages/devices/type-tests/resolved-shell.ts b/packages/devices/type-tests/resolved-shell.ts new file mode 100644 index 0000000..2df1563 --- /dev/null +++ b/packages/devices/type-tests/resolved-shell.ts @@ -0,0 +1,8 @@ +import { resolveDevice, type DeviceProfile } from '../src/index.js' + +const profile: DeviceProfile = { name: 'type fixture', os: 'ios', screen: { width: 100, height: 200 }, pixelRatio: 1 } +const resolved = resolveDevice(profile) + +// A caller can feed a resolved shell back into a profile without stripping its +// explicit `homeButton: null` representation. +const _roundTrip: DeviceProfile = { ...profile, shell: resolved.shell } diff --git a/packages/devices/type-tests/tsconfig.json b/packages/devices/type-tests/tsconfig.json new file mode 100644 index 0000000..3776ef3 --- /dev/null +++ b/packages/devices/type-tests/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "exactOptionalPropertyTypes": true, + "rootDir": "..", + "declaration": false, + "declarationMap": false + }, + "include": ["./*.ts", "../src/**/*.ts"], + "exclude": ["../src/**/*.test.ts"] +} diff --git a/packages/frame/src/device-frame.test.ts b/packages/frame/src/device-frame.test.ts index ae91702..e0ead34 100644 --- a/packages/frame/src/device-frame.test.ts +++ b/packages/frame/src/device-frame.test.ts @@ -357,9 +357,11 @@ describe('status bar time', () => { expect(vi.getTimerCount()).toBe(1) }) - it('hides the whole bar on request and runs no clock behind it', () => { + it('hides status-bar content on request and runs no clock behind it', () => { const el = mountFrame({ device: 'iPhone X', 'status-bar': 'hidden' }) - expect(statusBar(el).hidden).toBe(true) + expect(statusBar(el).hidden).toBe(false) + expect(el.shadowRoot!.querySelector('.status-bar__time')!.hidden).toBe(true) + expect(el.shadowRoot!.querySelector('.status-bar__icons')!.hidden).toBe(true) expect(vi.getTimerCount()).toBe(0) }) diff --git a/packages/frame/src/device-frame.ts b/packages/frame/src/device-frame.ts index f61b86e..11e0077 100644 --- a/packages/frame/src/device-frame.ts +++ b/packages/frame/src/device-frame.ts @@ -128,6 +128,7 @@ export class DeviceFrameElement extends HTMLElementBase { #deviceProfile: DeviceProfile | null = null #statusBar: StatusBar | null = null #homeIndicatorEl: HTMLElement | null = null + #homeButtonEl: HTMLElement | null = null #navigationBarEl: HTMLElement | null = null #navigationBarSlot: HTMLSlotElement | null = null #tabBarEl: HTMLElement | null = null @@ -323,6 +324,10 @@ export class DeviceFrameElement extends HTMLElementBase { this.#homeIndicatorEl.className = 'home-indicator' this.#homeIndicatorEl.setAttribute('aria-hidden', 'true') + this.#homeButtonEl = document.createElement('div') + this.#homeButtonEl.className = 'home-button' + this.#homeButtonEl.setAttribute('aria-hidden', 'true') + this.#statusBar = new StatusBar() this.#navigationBarEl = this.#buildSlottedBar('navigation-bar') this.#navigationBarSlot = this.#navigationBarEl.firstElementChild as HTMLSlotElement @@ -370,7 +375,7 @@ export class DeviceFrameElement extends HTMLElementBase { * platforms that keep theirs get one either way. */ #render(): void { - if (!this.#statusBar || !this.#homeIndicatorEl) return + if (!this.#statusBar || !this.#homeIndicatorEl || !this.#homeButtonEl) return const metrics = this.metrics reflectMetrics(this.style, metrics, this.embedded) @@ -388,12 +393,22 @@ export class DeviceFrameElement extends HTMLElementBase { mode, textStyle: this.getAttribute('status-bar-text-style'), background: this.getAttribute('status-bar-background'), + embedded: this.embedded, }) // Unconditional, not gated on showStatusBar: iOS landscape hides the status // bar but the home indicator still has to track status-bar-text-style. this.#homeIndicatorEl.style.color = statusBarTextColor(this.getAttribute('status-bar-text-style')) this.#homeIndicatorEl.hidden = this.embedded || metrics.safeAreaInsets.bottom <= 0 + const homeButton = metrics.shell.homeButton + if (homeButton && !this.embedded) { + if (!this.#homeButtonEl.isConnected) this.shadowRoot?.querySelector('.body')?.append(this.#homeButtonEl) + this.#homeButtonEl.hidden = false + this.#homeButtonEl.style.setProperty('--device-home-button-diameter', `${homeButton.diameter}px`) + this.#homeButtonEl.dataset.edge = metrics.orientation === 'portrait' ? 'bottom' : 'left' + } else { + this.#homeButtonEl.remove() + } if (this.#navigationBarEl) this.#navigationBarEl.hidden = metrics.navigationBarHeight <= 0 if (this.#tabBarEl) this.#tabBarEl.hidden = metrics.tabBarHeight <= 0 this.#publishContentRect() diff --git a/packages/frame/src/frame-size.ts b/packages/frame/src/frame-size.ts index 11496a8..23e921b 100644 --- a/packages/frame/src/frame-size.ts +++ b/packages/frame/src/frame-size.ts @@ -13,6 +13,12 @@ import { DEVICE_FRAME_BORDER_WIDTH } from './styles.js' */ export function frameOuterSize(profile: DeviceProfile, orientation: Orientation): ScreenSize { const screen = orientedScreen(profile, orientation) - const margin = 2 * (resolveDevice(profile).shell.bezel + DEVICE_FRAME_BORDER_WIDTH) - return { width: screen.width + margin, height: screen.height + margin } + const { bezelInsets } = resolveDevice(profile).shell + const insets = orientation === 'portrait' + ? bezelInsets + : { top: bezelInsets.left, right: bezelInsets.top, bottom: bezelInsets.right, left: bezelInsets.bottom } + return { + width: screen.width + insets.left + insets.right + 2 * DEVICE_FRAME_BORDER_WIDTH, + height: screen.height + insets.top + insets.bottom + 2 * DEVICE_FRAME_BORDER_WIDTH, + } } diff --git a/packages/frame/src/legacy-shell.test.ts b/packages/frame/src/legacy-shell.test.ts new file mode 100644 index 0000000..9a56fde --- /dev/null +++ b/packages/frame/src/legacy-shell.test.ts @@ -0,0 +1,90 @@ +import { findDevice, resolveDevice } from '@devicekit/devices' +import { afterEach, describe, expect, it } from 'vitest' +import { DEVICE_FRAME_TAG, defineDeviceFrame, type DeviceFrameElement } from './device-frame.js' +import { frameOuterSize } from './frame-size.js' +import { DEVICE_FRAME_BORDER_WIDTH, DEVICE_FRAME_STYLES } from './styles.js' + +defineDeviceFrame() + +function mount(attributes: Record): DeviceFrameElement { + const el = document.createElement(DEVICE_FRAME_TAG) as DeviceFrameElement + for (const [name, value] of Object.entries(attributes)) el.setAttribute(name, value) + document.body.append(el) + return el +} + +afterEach(() => { document.body.innerHTML = '' }) + +describe('legacy iPhone shell', () => { + it('resolves a distinct top/bottom bezel and physical Home button, while modern iPhones keep the uniform shell', () => { + const legacy = resolveDevice(findDevice('iPhone SE')!) + const modern = resolveDevice(findDevice('iPhone 15')!) + + expect(legacy.shell.bezelInsets.top).toBeGreaterThan(legacy.shell.bezel) + expect(legacy.shell.bezelInsets.bottom).toBeGreaterThan(legacy.shell.bezel) + expect(legacy.shell.homeButton).toEqual({ diameter: 30 }) + expect(modern.shell.bezelInsets).toEqual({ top: modern.shell.bezel, right: modern.shell.bezel, bottom: modern.shell.bezel, left: modern.shell.bezel }) + expect(modern.shell.homeButton).toBeNull() + }) + + it('adds each oriented shell edge to frameOuterSize', () => { + const profile = findDevice('iPhone 7 Plus')! + const shell = resolveDevice(profile).shell + expect(frameOuterSize(profile, 'portrait')).toEqual({ + width: 414 + shell.bezelInsets.left + shell.bezelInsets.right + 2 * DEVICE_FRAME_BORDER_WIDTH, + height: 736 + shell.bezelInsets.top + shell.bezelInsets.bottom + 2 * DEVICE_FRAME_BORDER_WIDTH, + }) + expect(frameOuterSize(profile, 'landscape')).toEqual({ + width: 736 + shell.bezelInsets.top + shell.bezelInsets.bottom + 2 * DEVICE_FRAME_BORDER_WIDTH, + height: 414 + shell.bezelInsets.left + shell.bezelInsets.right + 2 * DEVICE_FRAME_BORDER_WIDTH, + }) + }) + + it('rotates the button from the portrait bottom onto its 48px landscape left bezel', () => { + const portrait = mount({ device: 'iPhone SE' }) + const landscape = mount({ device: 'iPhone SE', orientation: 'landscape' }) + const modern = mount({ device: 'iPhone 15' }) + + expect(portrait.shadowRoot!.querySelector('.home-button')?.hidden).toBe(false) + expect(portrait.shadowRoot!.querySelector('.home-button')?.dataset.edge).toBe('bottom') + expect(landscape.shadowRoot!.querySelector('.home-button')?.dataset.edge).toBe('left') + expect(modern.shadowRoot!.querySelector('.home-button')).toBeNull() + expect(portrait.style.getPropertyValue('--device-bezel-top')).toBe('44px') + expect(landscape.style.getPropertyValue('--device-bezel-left')).toBe('48px') + expect(DEVICE_FRAME_STYLES).toMatch(/padding:\s*var\(--device-bezel-top\)/) + }) +}) + +describe('hidden status-bar retains physical cutouts', () => { + it('keeps the Dynamic Island but hides time and icons', () => { + const el = mount({ device: 'iPhone 17 Pro Max', 'status-bar': 'hidden' }) + const root = el.shadowRoot! + expect(root.querySelector('.status-bar__notch')!.hidden).toBe(false) + expect(root.querySelector('.status-bar__notch')!.dataset.shape).toBe('pill') + expect(root.querySelector('.status-bar__time')!.hidden).toBe(true) + expect(root.querySelector('.status-bar__icons')!.hidden).toBe(true) + expect(root.querySelector('.status-bar')!.style.backgroundColor).toBe('') + expect(DEVICE_FRAME_STYLES).toMatch(/\.status-bar__icons\[hidden\]\s*\{\s*display:\s*none;/) + }) + + it('does not paint a status-bar background while only the cutout remains', () => { + const el = mount({ device: 'iPhone 15', 'status-bar': 'hidden', 'status-bar-background': '#07c160' }) + expect(el.shadowRoot!.querySelector('.status-bar')!.style.backgroundColor).toBe('') + }) + + it('does not retain a cutout in landscape or embedded mode', () => { + const landscape = mount({ device: 'iPhone 15', orientation: 'landscape', 'status-bar': 'hidden' }) + const embedded = mount({ device: 'iPhone 15', embedded: '', 'status-bar': 'hidden' }) + expect(landscape.shadowRoot!.querySelector('.status-bar__notch')!.hidden).toBe(true) + expect(embedded.shadowRoot!.querySelector('.status-bar__notch')!.hidden).toBe(true) + }) + + it('restores status-bar content without replacing the cutout when toggled', () => { + const el = mount({ device: 'iPhone 15', 'status-bar': 'hidden' }) + const root = el.shadowRoot! + el.removeAttribute('status-bar') + expect(root.querySelector('.status-bar__notch')!.hidden).toBe(false) + expect(root.querySelector('.status-bar__time')!.hidden).toBe(false) + expect(root.querySelector('.status-bar__icons')!.hidden).toBe(false) + }) +}) diff --git a/packages/frame/src/metrics.ts b/packages/frame/src/metrics.ts index e5e1c23..8dc4d5f 100644 --- a/packages/frame/src/metrics.ts +++ b/packages/frame/src/metrics.ts @@ -8,7 +8,7 @@ import { type CutoutSpec, type DeviceProfile, - type DeviceShell, + type ResolvedDeviceShell, type EdgeInsets, type Orientation, orientedScreen, @@ -71,7 +71,7 @@ export interface DeviceMetrics { /** Where that window sits on the screen. */ content: ContentBox cutout: CutoutSpec | null - shell: Required + shell: ResolvedDeviceShell } /** diff --git a/packages/frame/src/reflect.ts b/packages/frame/src/reflect.ts index f358153..f290aea 100644 --- a/packages/frame/src/reflect.ts +++ b/packages/frame/src/reflect.ts @@ -59,5 +59,12 @@ export function reflectMetrics(style: CSSStyleDeclaration, metrics: DeviceMetric style.setProperty('--device-safe-area-left', `${insets.left}px`) style.setProperty('--device-screen-radius', `${shell.screenRadius}px`) style.setProperty('--device-bezel', `${shell.bezel}px`) + const shellInsets = metrics.orientation === 'portrait' + ? shell.bezelInsets + : { top: shell.bezelInsets.left, right: shell.bezelInsets.top, bottom: shell.bezelInsets.right, left: shell.bezelInsets.bottom } + style.setProperty('--device-bezel-top', `${shellInsets.top}px`) + style.setProperty('--device-bezel-right', `${shellInsets.right}px`) + style.setProperty('--device-bezel-bottom', `${shellInsets.bottom}px`) + style.setProperty('--device-bezel-left', `${shellInsets.left}px`) style.setProperty('--device-body-radius', `${shell.bodyRadius}px`) } diff --git a/packages/frame/src/status-bar-styles.ts b/packages/frame/src/status-bar-styles.ts index bc0606c..c9230b4 100644 --- a/packages/frame/src/status-bar-styles.ts +++ b/packages/frame/src/status-bar-styles.ts @@ -107,6 +107,10 @@ export const STATUS_BAR_STYLES = ` gap: calc(5.5px * var(--sb-scale, 1)); } +.status-bar__icons[hidden] { + display: none; +} + /* * ios-classic splits this cluster the way a real pre-notch status bar does: * signal+Wi-Fi sit next to the carrier name on the left, battery on the diff --git a/packages/frame/src/status-bar.ts b/packages/frame/src/status-bar.ts index de09bf7..daff62c 100644 --- a/packages/frame/src/status-bar.ts +++ b/packages/frame/src/status-bar.ts @@ -31,6 +31,8 @@ export interface StatusBarRenderOptions { textStyle: string | null /** The `status-bar-background` attribute, or null to stay transparent. */ background: string | null + /** Embedded frames draw no physical screen hardware. */ + embedded: boolean } function currentClockText(now: Date): string { @@ -52,6 +54,7 @@ export class StatusBar { readonly element: HTMLElement #timeEl: HTMLElement + #iconsEl: HTMLElement #cutoutEl: HTMLElement // Either the one-shot alignment timeout or the steady-state interval that // replaces it — never both at once, so a single field and clearing both @@ -85,6 +88,7 @@ export class StatusBar { const icons = document.createElement('div') icons.className = 'status-bar__icons' + this.#iconsEl = icons for (const glyph of ['signal', 'wifi', 'battery']) { const span = document.createElement('span') span.className = `status-bar__${glyph}` @@ -111,14 +115,17 @@ export class StatusBar { render(metrics: StatusBarMetrics, options: StatusBarRenderOptions): void { const { device, orientation } = metrics const { visible, mode, textStyle, background } = options - this.element.hidden = !visible - if (background) this.element.style.backgroundColor = background + const cutoutVisible = !options.embedded && orientation === 'portrait' && device.cutout !== null + this.element.hidden = !visible && !cutoutVisible + this.#timeEl.hidden = !visible + this.#iconsEl.hidden = !visible + if (background && visible) this.element.style.backgroundColor = background else this.element.style.removeProperty('background-color') // Ahead of the early return: the cutout and the layout variables have to // follow orientation even when the whole bar is gone, or they keep the // previous orientation's state on an element anyone can read. - this.#renderCutout(device, orientation) + this.#renderCutout(device, orientation, cutoutVisible) const layout = computeStatusBarLayout(device, orientation) this.#renderLayout(layout) @@ -170,8 +177,8 @@ export class StatusBar { * place than the one this bar draws, while the screen it costs is reported * through the landscape insets all the same. */ - #renderCutout(device: ResolvedDevice, orientation: Orientation): void { - const cutout = orientation === 'portrait' ? device.cutout : null + #renderCutout(device: ResolvedDevice, orientation: Orientation, visible: boolean): void { + const cutout = visible && orientation === 'portrait' ? device.cutout : null this.#cutoutEl.hidden = cutout === null if (!cutout) { diff --git a/packages/frame/src/styles.ts b/packages/frame/src/styles.ts index 83bde4a..4faa43e 100644 --- a/packages/frame/src/styles.ts +++ b/packages/frame/src/styles.ts @@ -41,6 +41,10 @@ export const DEVICE_FRAME_STYLES = ` --device-safe-area-left: 0px; --device-screen-radius: 38px; --device-bezel: 0px; + --device-bezel-top: 0px; + --device-bezel-right: 0px; + --device-bezel-bottom: 0px; + --device-bezel-left: 0px; --device-body-radius: 38px; --device-frame-border-width: ${DEVICE_FRAME_BORDER_WIDTH}px; @@ -80,7 +84,7 @@ export const DEVICE_FRAME_STYLES = ` box-sizing: content-box; width: var(--device-width); height: var(--device-height); - padding: var(--device-bezel); + padding: var(--device-bezel-top) var(--device-bezel-right) var(--device-bezel-bottom) var(--device-bezel-left); min-height: 0; overflow: hidden; border: var(--device-frame-border); @@ -237,6 +241,30 @@ ${STATUS_BAR_STYLES} background: currentColor; } +/* A Home button is body hardware, separate from the screen's gesture indicator. */ +.home-button { + position: absolute; + z-index: 10; + width: var(--device-home-button-diameter); + height: var(--device-home-button-diameter); + border: 1px solid rgba(255, 255, 255, 0.45); + border-radius: 50%; + box-sizing: border-box; + pointer-events: none; +} + +.home-button[data-edge="bottom"] { + left: 50%; + bottom: calc((var(--device-bezel-bottom) - var(--device-home-button-diameter)) / 2); + transform: translateX(-50%); +} + +.home-button[data-edge="left"] { + top: 50%; + left: calc((var(--device-bezel-left) - var(--device-home-button-diameter)) / 2); + transform: translateY(-50%); +} + /* Host-owned layers that ride above the screen (extension mount points, chrome affordances). The frame owns the stacking; slotted children opt themselves back into pointer events. */ From 7f2a788b3c808df31ab338bbdacb773195ac0382 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Tue, 8 Sep 2026 16:56:35 +0800 Subject: [PATCH 2/2] fix(frame): harden asymmetric shell handling --- packages/devices/README.md | 4 ++- packages/devices/README.zh-CN.md | 4 ++- packages/devices/src/devices.ts | 2 +- .../devices/src/profile-validation.test.ts | 11 ++++++ packages/devices/src/validate.ts | 4 +-- packages/frame/README.md | 6 ++-- packages/frame/README.zh-CN.md | 8 +++-- packages/frame/src/frame-size.ts | 5 ++- packages/frame/src/legacy-shell.test.ts | 21 ++++++++++++ packages/frame/src/reflect.ts | 5 ++- packages/frame/src/shell-insets.test.ts | 34 +++++++++++++++++++ packages/frame/src/shell-insets.ts | 8 +++++ 12 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 packages/frame/src/shell-insets.test.ts create mode 100644 packages/frame/src/shell-insets.ts diff --git a/packages/devices/README.md b/packages/devices/README.md index 9e2385b..5f61d39 100644 --- a/packages/devices/README.md +++ b/packages/devices/README.md @@ -163,7 +163,9 @@ const version = systemVersion(profile) | `SafeAreaRect` | `{ top, left, right, bottom, width, height }` | | `CutoutShape` | `'notch' \| 'pill' \| 'circle'` | | `CutoutSpec` | Cutout shape and geometry | -| `DeviceShell` | Screen radius, bezel, and optional body radius | +| `DeviceShell` | Screen radius and uniform `bezel`; `bezelInsets` can override individual edges, `homeButton` describes physical hardware, and per-edge profiles should set `bodyRadius` explicitly | +| `HomeButtonSpec` | `{ diameter }`, the physical Home button centered in the body bezel | +| `ResolvedDeviceShell` | A shell with every edge resolved and `homeButton` set to a spec or `null` | | `WindowSizeOptions` | Options for `resolveWindowSize()` | | `DeviceName` | Union of the values in `DEVICE_NAMES` | diff --git a/packages/devices/README.zh-CN.md b/packages/devices/README.zh-CN.md index 2aee1d4..e5529f4 100644 --- a/packages/devices/README.zh-CN.md +++ b/packages/devices/README.zh-CN.md @@ -192,7 +192,9 @@ import { deviceUserAgent, systemVersion } from '@devicekit/devices' | `SafeAreaRect` | `{ top, left, right, bottom, width, height }`,边是屏幕坐标,跟 `wx.getWindowInfo().safeArea` 一回事 | | `CutoutShape` | `'notch' \| 'pill' \| 'circle'` | | `CutoutSpec` | 挖孔的形状和几何:`shape`、`width`、`height`、`top`,可选 `centerX` | -| `DeviceShell` | 机身:`screenRadius`、`bezel`,可选 `bodyRadius` | +| `DeviceShell` | 机身:`screenRadius`、统一的 `bezel`;`bezelInsets` 可单独指定四边,`homeButton` 描述实体 Home 键;用了分边 inset 时应显式设置 `bodyRadius` | +| `HomeButtonSpec` | `{ diameter }`,机身 bezel 中央的实体 Home 键 | +| `ResolvedDeviceShell` | 四边都已补齐、`homeButton` 为规格或 `null` 的机身数据 | | `WindowSizeOptions` | `resolveWindowSize` 的选项:`orientation`、`navigationBar`、`tabBarHeight` | | `DeviceName` | 所有 `DEVICE_NAMES` 值的联合类型——也就是每一个真实的 `DeviceProfile.name` | diff --git a/packages/devices/src/devices.ts b/packages/devices/src/devices.ts index bf44895..f494563 100644 --- a/packages/devices/src/devices.ts +++ b/packages/devices/src/devices.ts @@ -85,7 +85,7 @@ export interface DeviceShell { bezelInsets?: Partial /** A physical Home button centered in the bottom bezel. */ homeButton?: HomeButtonSpec | null - /** Body corner radius. Omitted = screenRadius + bezel, which keeps the two concentric. */ + /** Body corner radius. Omitted = screenRadius + uniform bezel; profiles with per-edge insets should set this explicitly. */ bodyRadius?: number } diff --git a/packages/devices/src/profile-validation.test.ts b/packages/devices/src/profile-validation.test.ts index cd3f8c8..b560a9c 100644 --- a/packages/devices/src/profile-validation.test.ts +++ b/packages/devices/src/profile-validation.test.ts @@ -320,6 +320,17 @@ describe('assertDeviceProfile checks shell when present', () => { it.each(['screenRadius', 'bezel', 'bodyRadius'] as const)('a negative %s is rejected', (field) => { expectRejects({ ...VALID, shell: { [field]: -1 } }, undefined, `deviceProfile.shell.${field}`) }) + + it('rejects null or a negative edge in bezelInsets with the bad value named', () => { + expectRejects({ ...VALID, shell: { bezelInsets: null } }, undefined, 'deviceProfile.shell.bezelInsets', 'got null') + expectRejects({ ...VALID, shell: { bezelInsets: { top: -1 } } }, undefined, 'deviceProfile.shell.bezelInsets.top', 'got -1') + }) + + it('rejects an incomplete or zero-diameter Home button', () => { + expectRejects({ ...VALID, shell: { homeButton: 'button' } }, undefined, 'deviceProfile.shell.homeButton', 'got string') + expectRejects({ ...VALID, shell: { homeButton: {} } }, undefined, 'deviceProfile.shell.homeButton.diameter') + expectRejects({ ...VALID, shell: { homeButton: { diameter: 0 } } }, undefined, 'deviceProfile.shell.homeButton.diameter', 'got 0') + }) }) describe('a profile with a full cutout and shell passes and resolves cleanly', () => { diff --git a/packages/devices/src/validate.ts b/packages/devices/src/validate.ts index fbdffda..15cd11f 100644 --- a/packages/devices/src/validate.ts +++ b/packages/devices/src/validate.ts @@ -142,7 +142,7 @@ export function assertDeviceProfile(value: unknown, label = 'deviceProfile'): as } } if (shell.bezelInsets !== undefined) { - if (!isPlainObject(shell.bezelInsets)) throw new TypeError(`${label}.shell.bezelInsets must be an object`) + if (!isPlainObject(shell.bezelInsets)) throw new TypeError(`${label}.shell.bezelInsets must be an object, got ${shell.bezelInsets === null ? 'null' : typeof shell.bezelInsets}`) for (const edge of EDGES) { if (shell.bezelInsets[edge] !== undefined) { expectFiniteAtLeast(`${label}.shell.bezelInsets.${edge}`, shell.bezelInsets[edge], 0, false) @@ -150,7 +150,7 @@ export function assertDeviceProfile(value: unknown, label = 'deviceProfile'): as } } if (shell.homeButton !== undefined && shell.homeButton !== null) { - if (!isPlainObject(shell.homeButton)) throw new TypeError(`${label}.shell.homeButton must be an object`) + if (!isPlainObject(shell.homeButton)) throw new TypeError(`${label}.shell.homeButton must be an object, got ${shell.homeButton === null ? 'null' : typeof shell.homeButton}`) expectFiniteAtLeast(`${label}.shell.homeButton.diameter`, shell.homeButton.diameter, 0, true) } } diff --git a/packages/frame/README.md b/packages/frame/README.md index 8d004e8..4094622 100644 --- a/packages/frame/README.md +++ b/packages/frame/README.md @@ -176,14 +176,16 @@ Resolved layout values are published on the element: | `--device-navigation-bar-height` | Navigation slot height, or zero | | `--device-tab-bar-height` | Tab slot height, or zero | | `--device-safe-area-top`, `--device-safe-area-right`, `--device-safe-area-bottom`, `--device-safe-area-left` | Safe-area insets | -| `--device-screen-radius`, `--device-bezel`, `--device-body-radius` | Shell geometry | +| `--device-screen-radius`, `--device-body-radius` | Shell geometry | +| `--device-bezel` | Uniform bezel fallback when an edge is not specified separately | +| `--device-bezel-top`, `--device-bezel-right`, `--device-bezel-bottom`, `--device-bezel-left` | Oriented bezel thickness on each physical edge | | `--device-frame-border-width` | Body border width in device CSS pixels | Use these variables to change the frame appearance: | Variable | Purpose | | --- | --- | -| `--device-frame-radius` | Overrides the body radius with a CSS length | +| `--device-frame-radius` | Overrides the body radius with a CSS length; the concentric screen calculation assumes a uniform bezel | | `--device-frame-border` | Body border | | `--device-frame-background` | Body background | | `--device-frame-shadow` | Body shadow | diff --git a/packages/frame/README.zh-CN.md b/packages/frame/README.zh-CN.md index ed657c4..b5081ff 100644 --- a/packages/frame/README.zh-CN.md +++ b/packages/frame/README.zh-CN.md @@ -343,12 +343,14 @@ frame 只帮这个槽让开状态栏。**横屏时它不处理左右安全区** | `--device-navigation-bar-height` | 插槽里那层导航栏的高度,没插内容时是 0 | | `--device-tab-bar-height` | 插槽里那层 tab 栏的高度,没插内容时是 0 | | `--device-safe-area-top` / `-right` / `-bottom` / `-left` | 安全区**边距**(离各边多远),跟 `env(safe-area-inset-*)` 报的是同一回事 | -| `--device-screen-radius`、`--device-bezel`、`--device-body-radius` | 机身几何 | +| `--device-screen-radius`、`--device-body-radius` | 机身几何 | +| `--device-bezel` | 没有单独指定某条边时用的统一 bezel 兜底值 | +| `--device-bezel-top`、`--device-bezel-right`、`--device-bezel-bottom`、`--device-bezel-left` | 当前方向下机身四条物理边的 bezel 厚度 | | `--device-frame-border-width` | 机身描边宽度,单位是设备逻辑像素 | -`embedded` 下屏幕尺寸那两个不再写出:元素自己宽高走 `100%`,尺寸归容器管。归零的是本来被手机外壳占掉的那些——窗口尺寸、三条栏的高度、四条安全区边距。`--device-pixel-ratio`、`--device-screen-radius`、`--device-bezel`、`--device-body-radius` 仍然是这台机器自己的值,宿主画自己的外壳时照样读得到。 +`embedded` 下屏幕尺寸那两个不再写出:元素自己宽高走 `100%`,尺寸归容器管。归零的是本来被手机外壳占掉的那些——窗口尺寸、三条栏的高度、四条安全区边距。`--device-pixel-ratio`、`--device-screen-radius`、`--device-bezel`、`--device-bezel-top`、`--device-bezel-right`、`--device-bezel-bottom`、`--device-bezel-left`、`--device-body-radius` 仍然是这台机器自己的值,宿主画自己的外壳时照样读得到。 -外观也留了几个变量可以盖:`--device-frame-radius`(盖过机型自己的机身圆角,现在同时驱动机身和屏幕两处圆角,屏幕会自动跟着收窄描边和内边距那部分;只接受 CSS ``——百分比会被浏览器各自相对两个盒子单独解析,机身和屏幕就不再共享同一个圆心,这个变量也就不再是"同心一个圆角"的意思了)、`--device-frame-border`、`--device-frame-background`、`--device-frame-shadow`、`--device-cutout-color`(刘海/灵动岛/挖孔的颜色)、`--device-screen-background`(屏幕上没被 slot 盖住的地方显示什么——状态栏默认透明,没有 `navigation-bar` slot 时时钟那一条露出来的就是它;默认白色,暗色页面要把它设成页面自己的背景色,否则白色状态栏文字没东西衬)。机身默认是近黑色(`#0b0b0c`)配一圈极淡的白色描边,`--device-bezel` 按平台取默认值(iOS 6、Android/HarmonyOS 4),单个机型可以在 `shell.bezel` 里覆盖。改 `--device-frame-border` 顺带把描边宽度也改了的话,必须同步设置 `--device-frame-border-width`,否则圆角公式还是按旧的默认宽度算。 +外观也留了几个变量可以盖:`--device-frame-radius`(盖过机型自己的机身圆角,现在同时驱动机身和屏幕两处圆角,屏幕会自动跟着收窄描边和内边距那部分;这个同心计算只适用于统一 bezel,使用 `bezelInsets` 的机型应直接设置自己的 `bodyRadius`;只接受 CSS ``——百分比会被浏览器各自相对两个盒子单独解析,机身和屏幕就不再共享同一个圆心,这个变量也就不再是"同心一个圆角"的意思了)、`--device-frame-border`、`--device-frame-background`、`--device-frame-shadow`、`--device-cutout-color`(刘海/灵动岛/挖孔的颜色)、`--device-screen-background`(屏幕上没被 slot 盖住的地方显示什么——状态栏默认透明,没有 `navigation-bar` slot 时时钟那一条露出来的就是它;默认白色,暗色页面要把它设成页面自己的背景色,否则白色状态栏文字没东西衬)。机身默认是近黑色(`#0b0b0c`)配一圈极淡的白色描边,`--device-bezel` 按平台取默认值(iOS 6、Android/HarmonyOS 4),单个机型可以在 `shell.bezel` 里覆盖。改 `--device-frame-border` 顺带把描边宽度也改了的话,必须同步设置 `--device-frame-border-width`,否则圆角公式还是按旧的默认宽度算。 `` 会覆盖底部安全区;在 `deviceProfile` 里设置 `safeAreaInsets.bottom: 30` 也一样。它会更新 `metrics.safeAreaInsets.bottom`、`--device-safe-area-bottom`,并让元素自身按这个安全区绘制底部区域和手势条。 diff --git a/packages/frame/src/frame-size.ts b/packages/frame/src/frame-size.ts index 23e921b..34ba0d8 100644 --- a/packages/frame/src/frame-size.ts +++ b/packages/frame/src/frame-size.ts @@ -1,5 +1,6 @@ import { type DeviceProfile, type Orientation, type ScreenSize, orientedScreen, resolveDevice } from '@devicekit/devices' import { DEVICE_FRAME_BORDER_WIDTH } from './styles.js' +import { orientedShellInsets } from './shell-insets.js' /** * The frame's outer footprint for a given profile and orientation — what a @@ -14,9 +15,7 @@ import { DEVICE_FRAME_BORDER_WIDTH } from './styles.js' export function frameOuterSize(profile: DeviceProfile, orientation: Orientation): ScreenSize { const screen = orientedScreen(profile, orientation) const { bezelInsets } = resolveDevice(profile).shell - const insets = orientation === 'portrait' - ? bezelInsets - : { top: bezelInsets.left, right: bezelInsets.top, bottom: bezelInsets.right, left: bezelInsets.bottom } + const insets = orientedShellInsets(bezelInsets, orientation) return { width: screen.width + insets.left + insets.right + 2 * DEVICE_FRAME_BORDER_WIDTH, height: screen.height + insets.top + insets.bottom + 2 * DEVICE_FRAME_BORDER_WIDTH, diff --git a/packages/frame/src/legacy-shell.test.ts b/packages/frame/src/legacy-shell.test.ts index 9a56fde..32a28cd 100644 --- a/packages/frame/src/legacy-shell.test.ts +++ b/packages/frame/src/legacy-shell.test.ts @@ -53,6 +53,27 @@ describe('legacy iPhone shell', () => { expect(landscape.style.getPropertyValue('--device-bezel-left')).toBe('48px') expect(DEVICE_FRAME_STYLES).toMatch(/padding:\s*var\(--device-bezel-top\)/) }) + + it('restores the Home button and shell variables after legacy → modern → legacy', () => { + const el = mount({ device: 'iPhone SE' }) + expect(el.shadowRoot!.querySelector('.home-button')?.dataset.edge).toBe('bottom') + el.setAttribute('device', 'iPhone 15') + expect(el.shadowRoot!.querySelector('.home-button')).toBeNull() + el.setAttribute('device', 'iPhone SE') + const button = el.shadowRoot!.querySelector('.home-button')! + expect(button.parentElement?.className).toBe('body') + expect(button.dataset.edge).toBe('bottom') + expect(el.style.getPropertyValue('--device-bezel-bottom')).toBe('48px') + }) + + it('restores the Home button after embedded mode is removed', () => { + const el = mount({ device: 'iPhone SE' }) + el.embedded = true + expect(el.shadowRoot!.querySelector('.home-button')).toBeNull() + el.embedded = false + expect(el.shadowRoot!.querySelector('.home-button')?.parentElement?.className).toBe('body') + expect(el.style.getPropertyValue('--device-bezel-top')).toBe('44px') + }) }) describe('hidden status-bar retains physical cutouts', () => { diff --git a/packages/frame/src/reflect.ts b/packages/frame/src/reflect.ts index f290aea..03e8861 100644 --- a/packages/frame/src/reflect.ts +++ b/packages/frame/src/reflect.ts @@ -12,6 +12,7 @@ * disagree. */ import type { DeviceMetrics } from './metrics.js' +import { orientedShellInsets } from './shell-insets.js' /** A value-carrying attribute. `null`/`undefined` clear it, matching `removeAttribute`. */ export function reflectAttribute(el: Element, name: string, value: string | null | undefined): void { @@ -59,9 +60,7 @@ export function reflectMetrics(style: CSSStyleDeclaration, metrics: DeviceMetric style.setProperty('--device-safe-area-left', `${insets.left}px`) style.setProperty('--device-screen-radius', `${shell.screenRadius}px`) style.setProperty('--device-bezel', `${shell.bezel}px`) - const shellInsets = metrics.orientation === 'portrait' - ? shell.bezelInsets - : { top: shell.bezelInsets.left, right: shell.bezelInsets.top, bottom: shell.bezelInsets.right, left: shell.bezelInsets.bottom } + const shellInsets = orientedShellInsets(shell.bezelInsets, metrics.orientation) style.setProperty('--device-bezel-top', `${shellInsets.top}px`) style.setProperty('--device-bezel-right', `${shellInsets.right}px`) style.setProperty('--device-bezel-bottom', `${shellInsets.bottom}px`) diff --git a/packages/frame/src/shell-insets.test.ts b/packages/frame/src/shell-insets.test.ts new file mode 100644 index 0000000..3e599d3 --- /dev/null +++ b/packages/frame/src/shell-insets.test.ts @@ -0,0 +1,34 @@ +import { findDevice, resolveDevice } from '@devicekit/devices' +import { describe, expect, it } from 'vitest' +import { frameOuterSize } from './frame-size.js' +import { reflectMetrics } from './reflect.js' +import { orientedShellInsets } from './shell-insets.js' +import { DEVICE_FRAME_BORDER_WIDTH } from './styles.js' + +describe('oriented shell insets', () => { + it('rotates the portrait bottom bezel onto landscape left and keeps size and reflected variables aligned', () => { + const profile = findDevice('iPhone SE')! + const metrics = { + ...resolveDevice(profile), + orientation: 'landscape' as const, + screen: { width: 667, height: 375 }, + safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 }, + safeArea: { top: 0, right: 667, bottom: 375, left: 0, width: 667, height: 375 }, + statusBarHeight: 0, + navigationBarHeight: 0, + tabBarHeight: 0, + window: { width: 667, height: 375 }, + content: { x: 0, y: 0, width: 667, height: 375 }, + } + const insets = orientedShellInsets(metrics.shell.bezelInsets, 'landscape') + const style = document.createElement('div').style + reflectMetrics(style, metrics, false) + + expect(insets).toEqual({ top: 6, right: 44, bottom: 6, left: 48 }) + expect(frameOuterSize(profile, 'landscape')).toEqual({ width: 667 + 48 + 44 + 2 * DEVICE_FRAME_BORDER_WIDTH, height: 375 + 6 + 6 + 2 * DEVICE_FRAME_BORDER_WIDTH }) + expect(style.getPropertyValue('--device-bezel-top')).toBe(`${insets.top}px`) + expect(style.getPropertyValue('--device-bezel-right')).toBe(`${insets.right}px`) + expect(style.getPropertyValue('--device-bezel-bottom')).toBe(`${insets.bottom}px`) + expect(style.getPropertyValue('--device-bezel-left')).toBe(`${insets.left}px`) + }) +}) diff --git a/packages/frame/src/shell-insets.ts b/packages/frame/src/shell-insets.ts new file mode 100644 index 0000000..c296ce7 --- /dev/null +++ b/packages/frame/src/shell-insets.ts @@ -0,0 +1,8 @@ +import type { EdgeInsets, Orientation } from '@devicekit/devices' + +/** Rotates the physical shell clockwise with the screen: portrait bottom becomes landscape left. */ +export function orientedShellInsets(insets: EdgeInsets, orientation: Orientation): EdgeInsets { + return orientation === 'portrait' + ? insets + : { top: insets.left, right: insets.top, bottom: insets.right, left: insets.bottom } +}