From 8b4f974048f8c5c550c954c2929b4d8955748f50 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Mon, 14 Sep 2026 11:52:18 +0800 Subject: [PATCH 1/2] fix(devices): correct iPhone Duo outer screen to its D-shaped corners The outer screen used a uniform screenRadius, but the real device has near-right-angle corners on the hinge side and large rounded corners on the free edge. Adds a generic per-corner screenCorners override to DeviceShell (any corner can diverge from the uniform radius) and threads it through resolution, orientation rotation, and the rendered CSS so the D-shape rotates correctly between portrait and landscape. --- CHANGELOG.md | 9 ++++ packages/devices/README.md | 2 + packages/devices/README.zh-CN.md | 2 + packages/devices/src/devices.ts | 36 +++++++++++-- packages/devices/src/index.ts | 1 + packages/devices/src/presets/ios.ts | 8 ++- .../devices/src/profile-validation.test.ts | 6 +++ packages/devices/src/shell-defaults.test.ts | 33 ++++++++++++ packages/devices/src/validate.ts | 9 ++++ packages/frame/README.md | 1 + packages/frame/README.zh-CN.md | 3 +- packages/frame/src/reflect.ts | 37 ++++++++++---- packages/frame/src/shell-insets.test.ts | 10 +++- packages/frame/src/shell-insets.ts | 9 +++- packages/frame/src/shell-radius.test.ts | 51 ++++++++++++++++--- packages/frame/src/styles-radius.test.ts | 13 +++-- packages/frame/src/styles.ts | 11 +++- 17 files changed, 212 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 150a62d..eb10998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `@devicekit/devices`: `DeviceShell` accepts an optional `screenCorners`, overriding `screenRadius` per corner for shells whose screen corners are not all the same. +- `@devicekit/frame`: publish oriented per-corner `--device-screen-radius-top-left/top-right/bottom-right/bottom-left` variables and derive the `.screen` border-radius from them. + +### Changed + +- `@devicekit/devices`: correct "iPhone Duo (outer)" to a "D"-shaped screen — near-right-angle hinge corners and large free-edge corners — using the new `screenCorners` field. + ## [0.3.0] - 2026-09-11 ### Added diff --git a/packages/devices/README.md b/packages/devices/README.md index 3622d8b..dacd1ad 100644 --- a/packages/devices/README.md +++ b/packages/devices/README.md @@ -168,6 +168,8 @@ const version = systemVersion(profile) | `CutoutShape` | `'notch' \| 'pill' \| 'circle'` | | `CutoutSpec` | Cutout shape and geometry | | `DeviceShell` | Screen radius and uniform `bezel`; `bezelInsets` can override individual edges, `homeButton` describes physical hardware, and per-edge profiles should set `bodyRadius` explicitly | +| `CornerRadii` | `{ topLeft, topRight, bottomRight, bottomLeft }`, ordered the way CSS `border-radius` orders them | +| `screenCorners` (on `DeviceShell`) | Per-corner override of `screenRadius`, for shells whose screen corners are not all the same — a folding phone's hinge side, say. Omitted corners keep the uniform `screenRadius` value | | `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()` | diff --git a/packages/devices/README.zh-CN.md b/packages/devices/README.zh-CN.md index 132e5f1..5a36226 100644 --- a/packages/devices/README.zh-CN.md +++ b/packages/devices/README.zh-CN.md @@ -196,6 +196,8 @@ import { deviceUserAgent, systemVersion } from '@devicekit/devices' | `CutoutShape` | `'notch' \| 'pill' \| 'circle'` | | `CutoutSpec` | 挖孔的形状和几何:`shape`、`width`、`height`、`top`,可选 `centerX` | | `DeviceShell` | 机身:`screenRadius`、统一的 `bezel`;`bezelInsets` 可单独指定四边,`homeButton` 描述实体 Home 键;用了分边 inset 时应显式设置 `bodyRadius` | +| `CornerRadii` | `{ topLeft, topRight, bottomRight, bottomLeft }`,顺序和 CSS `border-radius` 一致 | +| `screenCorners`(`DeviceShell` 上的字段) | 按角覆盖 `screenRadius`,用于四角屏幕圆角不一致的机身——比如折叠屏合页那一侧。省略的角沿用统一的 `screenRadius` | | `HomeButtonSpec` | `{ diameter }`,机身 bezel 中央的实体 Home 键 | | `ResolvedDeviceShell` | 四边都已补齐、`homeButton` 为规格或 `null` 的机身数据 | | `WindowSizeOptions` | `resolveWindowSize` 的选项:`orientation`、`navigationBar`、`tabBarHeight` | diff --git a/packages/devices/src/devices.ts b/packages/devices/src/devices.ts index c37f7b2..49e78b4 100644 --- a/packages/devices/src/devices.ts +++ b/packages/devices/src/devices.ts @@ -58,6 +58,14 @@ export interface EdgeInsets { left: number } +/** Four corner radii, named the way CSS `border-radius` orders them: clockwise from top-left. */ +export interface CornerRadii { + topLeft: number + topRight: number + bottomRight: number + bottomLeft: number +} + /** * The shape of what interrupts the screen. `notch` hangs off the top edge, * `pill` floats below it (a Dynamic Island), `circle` is a punch-hole camera — @@ -83,8 +91,10 @@ export interface CutoutSpec { /** The phone's body around the screen. */ export interface DeviceShell { - /** Screen corner radius. */ + /** Screen corner radius on every corner. */ screenRadius: number + /** Per-corner screen radius. Omitted corners keep the uniform `screenRadius` value. */ + screenCorners?: Partial /** 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. */ @@ -99,7 +109,8 @@ export interface HomeButtonSpec { diameter: number } -export interface ResolvedDeviceShell extends Required> { +export interface ResolvedDeviceShell extends Required> { + screenCorners: CornerRadii bezelInsets: EdgeInsets homeButton: HomeButtonSpec | null } @@ -259,6 +270,16 @@ function withInsets(partial: Partial | undefined, fallback: EdgeInse } } +function withCorners(partial: Partial | undefined, fallback: CornerRadii): CornerRadii { + if (!partial) return fallback + return { + topLeft: partial.topLeft ?? fallback.topLeft, + topRight: partial.topRight ?? fallback.topRight, + bottomRight: partial.bottomRight ?? fallback.bottomRight, + bottomLeft: partial.bottomLeft ?? fallback.bottomLeft, + } +} + /** * Fills a profile's optional fields in from its platform's defaults, generating * the user agent when the profile states none. @@ -274,6 +295,13 @@ export function resolveDevice(profile: DeviceProfile): ResolvedDevice { const statusBarEdge = profile.statusBarEdge ?? defaults.statusBarEdge const statusBarEdgeLandscape = profile.statusBarEdgeLandscape ?? defaults.statusBarEdgeLandscape const screenRadius = profile.shell?.screenRadius ?? defaults.shell.screenRadius + const uniformCorners = { topLeft: screenRadius, topRight: screenRadius, bottomRight: screenRadius, bottomLeft: screenRadius } + const profileCorners = profile.shell?.screenCorners + const screenCorners = profileCorners !== undefined + ? withCorners(profileCorners, uniformCorners) + : profile.shell?.screenRadius !== undefined + ? uniformCorners + : withCorners(defaults.shell.screenCorners, uniformCorners) const bezel = profile.shell?.bezel ?? defaults.shell.bezel const uniformBezelInsets = { top: bezel, right: bezel, bottom: bezel, left: bezel } const profileInsets = profile.shell?.bezelInsets @@ -309,6 +337,7 @@ export function resolveDevice(profile: DeviceProfile): ResolvedDevice { cutoutLandscape: profile.cutoutLandscape ?? null, shell: { screenRadius, + screenCorners, bezel, bezelInsets, homeButton, @@ -316,7 +345,8 @@ export function resolveDevice(profile: DeviceProfile): ResolvedDevice { // asymmetric modern shell. reflectMetrics may replace it with the // per-corner ellipse when the profile did not explicitly provide a // scalar bodyRadius. - bodyRadius: profile.shell?.bodyRadius ?? screenRadius + Math.max(...Object.values(bezelInsets)), + bodyRadius: profile.shell?.bodyRadius + ?? Math.max(...Object.values(screenCorners)) + Math.max(...Object.values(bezelInsets)), }, } } diff --git a/packages/devices/src/index.ts b/packages/devices/src/index.ts index 5173904..a303bd0 100644 --- a/packages/devices/src/index.ts +++ b/packages/devices/src/index.ts @@ -13,6 +13,7 @@ export { type DeviceProfile, type PresetDeviceProfile, type DeviceShell, + type CornerRadii, type EdgeInsets, type HomeButtonSpec, type Orientation, diff --git a/packages/devices/src/presets/ios.ts b/packages/devices/src/presets/ios.ts index 059983b..f6c8b35 100644 --- a/packages/devices/src/presets/ios.ts +++ b/packages/devices/src/presets/ios.ts @@ -787,6 +787,12 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ releaseYear: 2026, }, // Points, DPR, safe area, and chrome are provisional HIG visual calibrations from Apple physical pixels and diagrams, not Xcode 27.1 DeviceHub measurements. + // The corner radii (66/12, roughly 5.5:1) come from a third-party open-source + // project's reconstruction of Apple's own USDZ/AR model, not a published Apple + // figure. 12 (the hinge corners) has no independent corroboration — it is + // only plausible in magnitude. 66 (the free-edge corners) checks out against + // iPhone 18 Pro Max's `screenRadius: 62` scaled by screen width (62 × 466/440 + // ≈ 65.7). { name: 'iPhone Duo (outer)', os: 'ios', @@ -803,7 +809,7 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ cutout: { shape: 'circle', width: 37, height: 37, top: 26, centerX: 0.9 }, // Rotated clockwise from the portrait geometry measured in Apple's closed-device render; not a DeviceHub measurement. cutoutLandscape: { shape: 'circle', width: 37, height: 37, top: 401, centerX: 0.934 }, - shell: { screenRadius: 42, bezel: 6, bezelInsets: { left: 8 } }, + shell: { screenRadius: 66, screenCorners: { topLeft: 12, bottomLeft: 12 }, bezel: 6, bezelInsets: { left: 8 } }, releaseYear: 2026, }, { diff --git a/packages/devices/src/profile-validation.test.ts b/packages/devices/src/profile-validation.test.ts index 41d6a36..5ce33f2 100644 --- a/packages/devices/src/profile-validation.test.ts +++ b/packages/devices/src/profile-validation.test.ts @@ -340,6 +340,12 @@ describe('assertDeviceProfile checks shell when present', () => { expectRejects({ ...VALID, shell: { bezelInsets: { top: -1 } } }, undefined, 'deviceProfile.shell.bezelInsets.top', 'got -1') }) + it('rejects a non-object, null, or a negative corner in screenCorners with the bad value named', () => { + expectRejects({ ...VALID, shell: { screenCorners: 'round' } }, undefined, 'deviceProfile.shell.screenCorners', 'got string') + expectRejects({ ...VALID, shell: { screenCorners: null } }, undefined, 'deviceProfile.shell.screenCorners', 'got null') + expectRejects({ ...VALID, shell: { screenCorners: { topLeft: -1 } } }, undefined, 'deviceProfile.shell.screenCorners.topLeft', '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') diff --git a/packages/devices/src/shell-defaults.test.ts b/packages/devices/src/shell-defaults.test.ts index b4f557c..da5104d 100644 --- a/packages/devices/src/shell-defaults.test.ts +++ b/packages/devices/src/shell-defaults.test.ts @@ -41,3 +41,36 @@ describe('resolved shell default precedence', () => { expect(resolveDevice(profile({ homeButton: null })).shell.homeButton).toBeNull() }) }) + +describe('resolved screenCorners precedence', () => { + it('every corner takes the uniform screenRadius when the profile omits screenCorners', () => { + PLATFORM_DEFAULTS.ios.shell = { screenRadius: 10, bezel: 3 } + expect(resolveDevice(profile({ screenRadius: 20 })).shell.screenCorners).toEqual({ + topLeft: 20, topRight: 20, bottomRight: 20, bottomLeft: 20, + }) + }) + + it('a partial screenCorners override, including zero, falls back to screenRadius for the omitted corners', () => { + PLATFORM_DEFAULTS.ios.shell = { screenRadius: 10, bezel: 3 } + expect(resolveDevice(profile({ screenRadius: 20, screenCorners: { topLeft: 0, bottomLeft: 0 } })).shell.screenCorners).toEqual({ + topLeft: 0, topRight: 20, bottomRight: 20, bottomLeft: 0, + }) + }) + + it('a profile with screenCorners but no screenRadius falls back to the platform screenRadius for omitted corners', () => { + PLATFORM_DEFAULTS.ios.shell = { screenRadius: 15, bezel: 3 } + expect(resolveDevice(profile({ screenCorners: { topLeft: 4, bottomLeft: 4 } })).shell.screenCorners).toEqual({ + topLeft: 4, topRight: 15, bottomRight: 15, bottomLeft: 4, + }) + }) + + it('the default bodyRadius is the largest corner radius plus the largest bezel inset', () => { + PLATFORM_DEFAULTS.ios.shell = { screenRadius: 10, bezel: 3 } + const resolved = resolveDevice(profile({ + screenRadius: 20, + screenCorners: { topLeft: 5, bottomLeft: 5 }, + bezelInsets: { left: 9 }, + })) + expect(resolved.shell.bodyRadius).toBe(20 + 9) + }) +}) diff --git a/packages/devices/src/validate.ts b/packages/devices/src/validate.ts index 5bc9fd1..cb8b99d 100644 --- a/packages/devices/src/validate.ts +++ b/packages/devices/src/validate.ts @@ -41,6 +41,7 @@ function expectString(path: string, value: unknown): void { } const EDGES = ['top', 'right', 'bottom', 'left'] as const +const CORNERS = ['topLeft', 'topRight', 'bottomRight', 'bottomLeft'] as const const NONNEGATIVE_HEIGHT_FIELDS = [ 'statusBarHeight', 'statusBarHeightLandscape', @@ -160,6 +161,14 @@ export function assertDeviceProfile(value: unknown, label = 'deviceProfile'): as expectFiniteAtLeast(`${label}.shell.${field}`, shell[field], 0, false) } } + if (shell.screenCorners !== undefined) { + if (!isPlainObject(shell.screenCorners)) throw new TypeError(`${label}.shell.screenCorners must be an object, got ${shell.screenCorners === null ? 'null' : typeof shell.screenCorners}`) + for (const corner of CORNERS) { + if (shell.screenCorners[corner] !== undefined) { + expectFiniteAtLeast(`${label}.shell.screenCorners.${corner}`, shell.screenCorners[corner], 0, false) + } + } + } if (shell.bezelInsets !== undefined) { 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) { diff --git a/packages/frame/README.md b/packages/frame/README.md index e7197cc..3d71492 100644 --- a/packages/frame/README.md +++ b/packages/frame/README.md @@ -186,6 +186,7 @@ Resolved layout values are published on the element: | `--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-body-radius` | Shell geometry | +| `--device-screen-radius-top-left`, `--device-screen-radius-top-right`, `--device-screen-radius-bottom-right`, `--device-screen-radius-bottom-left` | Per-corner screen radius, oriented — see `screenCorners` in `@devicekit/devices` | | `--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 | diff --git a/packages/frame/README.zh-CN.md b/packages/frame/README.zh-CN.md index dea2cee..9e05101 100644 --- a/packages/frame/README.zh-CN.md +++ b/packages/frame/README.zh-CN.md @@ -371,13 +371,14 @@ frame 只帮这个槽让开状态栏。**横屏时它不处理左右安全区** | `--device-tab-bar-height` | 插槽里那层 tab 栏的高度,没插内容时是 0 | | `--device-safe-area-top` / `-right` / `-bottom` / `-left` | 安全区**边距**(离各边多远),跟 `env(safe-area-inset-*)` 报的是同一回事 | | `--device-screen-radius`、`--device-body-radius` | 机身几何 | +| `--device-screen-radius-top-left`、`--device-screen-radius-top-right`、`--device-screen-radius-bottom-right`、`--device-screen-radius-bottom-left` | 按角、已按方向旋转的屏幕圆角——对应 `@devicekit/devices` 里的 `screenCorners` | | `--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-bezel-top`、`--device-bezel-right`、`--device-bezel-bottom`、`--device-bezel-left`、`--device-body-radius` 仍然是这台机器自己的值,宿主画自己的外壳时照样读得到。 -外观也留了几个变量可以盖:`--device-frame-radius`(盖过机型自己的机身圆角,现在同时驱动机身和屏幕两处圆角,屏幕会自动跟着收窄描边和内边距那部分;统一 bezel 会使用标量圆角,设置了 `bezelInsets` 且省略 `bodyRadius` 时会按四边厚度自动生成同心椭圆圆角,显式设置 `bodyRadius` 则保留这个标量;只接受 CSS ``——百分比会被浏览器各自相对两个盒子单独解析,机身和屏幕就不再共享同一个圆心,这个变量也就不再是"同心一个圆角"的意思了)、`--device-frame-border`、`--device-frame-background`、`--device-frame-shadow`、`--device-cutout-color`(刘海/灵动岛/挖孔的颜色)、`--device-screen-background`(屏幕上没被 slot 盖住的地方显示什么——状态栏默认透明,没有 `navigation-bar` slot 时时钟那一条露出来的就是它;默认白色,暗色页面要把它设成页面自己的背景色,否则白色状态栏文字没东西衬)。原生宿主如果能提供合法的图像输出,也可以通过继承的 `--device-status-bar-signal-image`、`--device-status-bar-wifi-image`、`--device-status-bar-battery-image` 覆盖三种状态栏 mask;不提供时仍使用包内自有 fallback path。机身默认是近黑色(`#0b0b0c`)配一圈极淡的白色描边,`--device-bezel` 按平台取默认值(iOS 6、Android/HarmonyOS 4),单个机型可以在 `shell.bezel` 里覆盖。改 `--device-frame-border` 顺带把描边宽度也改了的话,必须同步设置 `--device-frame-border-width`,否则圆角公式还是按旧的默认宽度算。 +外观也留了几个变量可以盖:`--device-frame-radius`(盖过机型自己的机身圆角,现在同时驱动机身和屏幕两处圆角,屏幕会自动跟着收窄描边和内边距那部分;统一 bezel 会使用标量圆角,设置了 `bezelInsets` 且省略 `bodyRadius` 时会按四边厚度自动生成同心椭圆圆角,显式设置 `bodyRadius` 则保留这个标量;按角屏幕圆角(`screenCorners`)也会被这个覆盖统一取代,四角一起变成同一个值;只接受 CSS ``——百分比会被浏览器各自相对两个盒子单独解析,机身和屏幕就不再共享同一个圆心,这个变量也就不再是"同心一个圆角"的意思了)、`--device-frame-border`、`--device-frame-background`、`--device-frame-shadow`、`--device-cutout-color`(刘海/灵动岛/挖孔的颜色)、`--device-screen-background`(屏幕上没被 slot 盖住的地方显示什么——状态栏默认透明,没有 `navigation-bar` slot 时时钟那一条露出来的就是它;默认白色,暗色页面要把它设成页面自己的背景色,否则白色状态栏文字没东西衬)。原生宿主如果能提供合法的图像输出,也可以通过继承的 `--device-status-bar-signal-image`、`--device-status-bar-wifi-image`、`--device-status-bar-battery-image` 覆盖三种状态栏 mask;不提供时仍使用包内自有 fallback path。机身默认是近黑色(`#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/reflect.ts b/packages/frame/src/reflect.ts index 1bdac7d..73f38da 100644 --- a/packages/frame/src/reflect.ts +++ b/packages/frame/src/reflect.ts @@ -13,7 +13,7 @@ */ import type { DeviceProfile } from '@devicekit/devices' import type { DeviceMetrics } from './metrics.js' -import { orientedShellInsets } from './shell-insets.js' +import { orientedShellCorners, orientedShellInsets } from './shell-insets.js' import { DEVICE_FRAME_BORDER_WIDTH } from './styles.js' /** A value-carrying attribute. `null`/`undefined` clear it, matching `removeAttribute`. */ @@ -64,6 +64,11 @@ export function reflectMetrics(style: CSSStyleDeclaration, metrics: DeviceMetric style.setProperty('--device-safe-area-bottom', `${insets.bottom}px`) style.setProperty('--device-safe-area-left', `${insets.left}px`) style.setProperty('--device-screen-radius', `${shell.screenRadius}px`) + const corners = orientedShellCorners(shell.screenCorners, metrics.orientation) + style.setProperty('--device-screen-radius-top-left', `${corners.topLeft}px`) + style.setProperty('--device-screen-radius-top-right', `${corners.topRight}px`) + style.setProperty('--device-screen-radius-bottom-right', `${corners.bottomRight}px`) + style.setProperty('--device-screen-radius-bottom-left', `${corners.bottomLeft}px`) style.setProperty('--device-bezel', `${shell.bezel}px`) const shellInsets = orientedShellInsets(shell.bezelInsets, metrics.orientation) style.setProperty('--device-bezel-top', `${shellInsets.top}px`) @@ -79,20 +84,32 @@ export function reflectMetrics(style: CSSStyleDeclaration, metrics: DeviceMetric const uniformInsets = shellInsets.top === shellInsets.right && shellInsets.right === shellInsets.bottom && shellInsets.bottom === shellInsets.left + const uniformCorners = corners.topLeft === corners.topRight + && corners.topRight === corners.bottomRight + && corners.bottomRight === corners.bottomLeft // A caller-provided scalar is an explicit shell contract, even when the - // screen also has asymmetric insets. Automatic elliptical radii apply only - // when the profile leaves bodyRadius unspecified. + // screen also has asymmetric insets or corners. Automatic elliptical radii + // apply only when the profile leaves bodyRadius unspecified. const explicitBodyRadius = profile?.shell?.bodyRadius !== undefined - if (shell.screenRadius === 0 || uniformInsets || explicitBodyRadius) { + const allCornersZero = Object.values(corners).every(v => v === 0) + if (allCornersZero || (uniformInsets && uniformCorners) || explicitBodyRadius) { style.removeProperty('--device-body-radius-x') style.removeProperty('--device-body-radius-y') } else { - const left = shell.screenRadius + shellInsets.left + DEVICE_FRAME_BORDER_WIDTH - const right = shell.screenRadius + shellInsets.right + DEVICE_FRAME_BORDER_WIDTH - const top = shell.screenRadius + shellInsets.top + DEVICE_FRAME_BORDER_WIDTH - const bottom = shell.screenRadius + shellInsets.bottom + DEVICE_FRAME_BORDER_WIDTH - style.setProperty('--device-body-radius-x', `${left}px ${right}px ${right}px ${left}px`) - style.setProperty('--device-body-radius-y', `${top}px ${top}px ${bottom}px ${bottom}px`) + const x = { + topLeft: corners.topLeft + shellInsets.left + DEVICE_FRAME_BORDER_WIDTH, + topRight: corners.topRight + shellInsets.right + DEVICE_FRAME_BORDER_WIDTH, + bottomRight: corners.bottomRight + shellInsets.right + DEVICE_FRAME_BORDER_WIDTH, + bottomLeft: corners.bottomLeft + shellInsets.left + DEVICE_FRAME_BORDER_WIDTH, + } + const y = { + topLeft: corners.topLeft + shellInsets.top + DEVICE_FRAME_BORDER_WIDTH, + topRight: corners.topRight + shellInsets.top + DEVICE_FRAME_BORDER_WIDTH, + bottomRight: corners.bottomRight + shellInsets.bottom + DEVICE_FRAME_BORDER_WIDTH, + bottomLeft: corners.bottomLeft + shellInsets.bottom + DEVICE_FRAME_BORDER_WIDTH, + } + style.setProperty('--device-body-radius-x', `${x.topLeft}px ${x.topRight}px ${x.bottomRight}px ${x.bottomLeft}px`) + style.setProperty('--device-body-radius-y', `${y.topLeft}px ${y.topRight}px ${y.bottomRight}px ${y.bottomLeft}px`) } } diff --git a/packages/frame/src/shell-insets.test.ts b/packages/frame/src/shell-insets.test.ts index 3e599d3..5729f8c 100644 --- a/packages/frame/src/shell-insets.test.ts +++ b/packages/frame/src/shell-insets.test.ts @@ -2,7 +2,7 @@ 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 { orientedShellCorners, orientedShellInsets } from './shell-insets.js' import { DEVICE_FRAME_BORDER_WIDTH } from './styles.js' describe('oriented shell insets', () => { @@ -32,3 +32,11 @@ describe('oriented shell insets', () => { expect(style.getPropertyValue('--device-bezel-left')).toBe(`${insets.left}px`) }) }) + +describe('oriented shell corners', () => { + it('rotates each corner 90° clockwise with the physical shell: TL to TR, TR to BR, BR to BL, BL to TL', () => { + const corners = { topLeft: 1, topRight: 2, bottomRight: 3, bottomLeft: 4 } + expect(orientedShellCorners(corners, 'portrait')).toEqual(corners) + expect(orientedShellCorners(corners, 'landscape')).toEqual({ topLeft: 4, topRight: 1, bottomRight: 2, bottomLeft: 3 }) + }) +}) diff --git a/packages/frame/src/shell-insets.ts b/packages/frame/src/shell-insets.ts index c296ce7..3322239 100644 --- a/packages/frame/src/shell-insets.ts +++ b/packages/frame/src/shell-insets.ts @@ -1,4 +1,4 @@ -import type { EdgeInsets, Orientation } from '@devicekit/devices' +import type { CornerRadii, 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 { @@ -6,3 +6,10 @@ export function orientedShellInsets(insets: EdgeInsets, orientation: Orientation ? insets : { top: insets.left, right: insets.top, bottom: insets.right, left: insets.bottom } } + +/** Rotates the shell's screen corners with the same 90° clockwise physical turn: each corner moves TL→TR→BR→BL. */ +export function orientedShellCorners(corners: CornerRadii, orientation: Orientation): CornerRadii { + return orientation === 'portrait' + ? corners + : { topLeft: corners.bottomLeft, topRight: corners.topLeft, bottomRight: corners.topRight, bottomLeft: corners.bottomRight } +} diff --git a/packages/frame/src/shell-radius.test.ts b/packages/frame/src/shell-radius.test.ts index 6f65f5f..1efe631 100644 --- a/packages/frame/src/shell-radius.test.ts +++ b/packages/frame/src/shell-radius.test.ts @@ -18,27 +18,66 @@ function radiusVariables(el: DeviceFrameElement): { x: string, y: string } { } } +function cornerVariables(el: DeviceFrameElement): { topLeft: string, topRight: string, bottomRight: string, bottomLeft: string } { + return { + topLeft: el.style.getPropertyValue('--device-screen-radius-top-left'), + topRight: el.style.getPropertyValue('--device-screen-radius-top-right'), + bottomRight: el.style.getPropertyValue('--device-screen-radius-bottom-right'), + bottomLeft: el.style.getPropertyValue('--device-screen-radius-bottom-left'), + } +} + afterEach(() => { document.body.innerHTML = '' }) describe('modern shell corner radii follow the screen radius and adjacent oriented insets', () => { it('Duo outer uses elliptical per-corner radii in portrait', () => { - expect(resolveDevice(findDevice('iPhone Duo (outer)')!).shell.bodyRadius).toBe(50) + expect(resolveDevice(findDevice('iPhone Duo (outer)')!).shell.bodyRadius).toBe(74) const el = mount({ device: 'iPhone Duo (outer)' }) expect(radiusVariables(el)).toEqual({ - x: '51px 49px 49px 51px', - y: '49px 49px 49px 49px', + x: '21px 73px 73px 21px', + y: '19px 73px 73px 19px', }) }) it('Duo outer rotates the same per-edge rule in landscape', () => { - expect(resolveDevice(findDevice('iPhone Duo (outer)')!).shell.bodyRadius).toBe(50) + expect(resolveDevice(findDevice('iPhone Duo (outer)')!).shell.bodyRadius).toBe(74) const el = mount({ device: 'iPhone Duo (outer)', orientation: 'landscape' }) expect(radiusVariables(el)).toEqual({ - x: '49px 49px 49px 49px', - y: '51px 51px 49px 49px', + x: '19px 19px 73px 73px', + y: '21px 21px 73px 73px', }) }) + it('Duo outer publishes the near-right-angle hinge corners and near-semicircle free-edge corners, rotated with orientation', () => { + const portrait = mount({ device: 'iPhone Duo (outer)' }) + expect(cornerVariables(portrait)).toEqual({ topLeft: '12px', topRight: '66px', bottomRight: '66px', bottomLeft: '12px' }) + + const landscape = mount({ device: 'iPhone Duo (outer)', orientation: 'landscape' }) + expect(cornerVariables(landscape)).toEqual({ topLeft: '12px', topRight: '12px', bottomRight: '66px', bottomLeft: '66px' }) + }) + + it('publishes body-radius-x/y for a non-uniform screenCorners profile even when bezelInsets stay uniform', () => { + const base = findDevice('iPhone 15')! + const el = mount() + el.deviceProfile = { ...base, shell: { ...base.shell, screenCorners: { topLeft: 10 } } } + expect(radiusVariables(el)).not.toEqual({ x: '', y: '' }) + }) + + it('keeps an explicit scalar bodyRadius on a non-uniform screenCorners profile', () => { + const base = findDevice('iPhone 15')! + const el = mount() + el.deviceProfile = { ...base, shell: { ...base.shell, screenCorners: { topLeft: 10 }, bodyRadius: 63 } } + expect(el.style.getPropertyValue('--device-body-radius')).toBe('63px') + expect(radiusVariables(el)).toEqual({ x: '', y: '' }) + }) + + it('keeps a zero-radius shell (no screenCorners override) scalar', () => { + const base = findDevice('iPhone 15')! + const el = mount() + el.deviceProfile = { ...base, shell: { ...base.shell, screenRadius: 0 } } + expect(radiusVariables(el)).toEqual({ x: '', y: '' }) + }) + it.each(['iPhone Duo (inner)', 'iPhone 15'])('%s keeps its scalar bodyRadius for uniform bezels', (name) => { const shell = resolveDevice(findDevice(name)!).shell const el = mount({ device: name }) diff --git a/packages/frame/src/styles-radius.test.ts b/packages/frame/src/styles-radius.test.ts index a74de09..48f628a 100644 --- a/packages/frame/src/styles-radius.test.ts +++ b/packages/frame/src/styles-radius.test.ts @@ -38,10 +38,15 @@ describe('DEVICE_FRAME_STYLES corner radius variables', () => { ) }) - it('.screen uses an effective radius that preserves the host override calculation', () => { - expect(ruleBody('.screen')).toMatch(/border-radius:\s*var\(--device-screen-radius-effective\)/) - expect(DEVICE_FRAME_STYLES).toMatch( - /--device-screen-radius-effective:\s*max\(0px,\s*calc\(var\(--device-frame-radius,\s*calc\(var\(--device-screen-radius\)\s*\+\s*var\(--device-bezel\)\s*\+\s*var\(--device-frame-border-width\)\)\)\s*-\s*var\(--device-bezel\)\s*-\s*var\(--device-frame-border-width\)\)\)/, + it('.screen uses per-corner effective radii that each preserve the host override calculation', () => { + expect(ruleBody('.screen')).toMatch( + /border-radius:\s*var\(--device-screen-radius-effective-top-left\)\s*var\(--device-screen-radius-effective-top-right\)\s*var\(--device-screen-radius-effective-bottom-right\)\s*var\(--device-screen-radius-effective-bottom-left\)/, ) + for (const corner of ['top-left', 'top-right', 'bottom-right', 'bottom-left']) { + const pattern = new RegExp( + `--device-screen-radius-effective-${corner}:\\s*max\\(0px,\\s*calc\\(var\\(--device-frame-radius,\\s*calc\\(var\\(--device-screen-radius-${corner}\\)\\s*\\+\\s*var\\(--device-bezel\\)\\s*\\+\\s*var\\(--device-frame-border-width\\)\\)\\)\\s*-\\s*var\\(--device-bezel\\)\\s*-\\s*var\\(--device-frame-border-width\\)\\)\\)`, + ) + expect(DEVICE_FRAME_STYLES).toMatch(pattern) + } }) }) diff --git a/packages/frame/src/styles.ts b/packages/frame/src/styles.ts index 3470380..6ad165b 100644 --- a/packages/frame/src/styles.ts +++ b/packages/frame/src/styles.ts @@ -40,13 +40,20 @@ export const DEVICE_FRAME_STYLES = ` --device-safe-area-bottom: 0px; --device-safe-area-left: 0px; --device-screen-radius: 38px; + --device-screen-radius-top-left: 38px; + --device-screen-radius-top-right: 38px; + --device-screen-radius-bottom-right: 38px; + --device-screen-radius-bottom-left: 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-screen-radius-effective: max(0px, calc(var(--device-frame-radius, calc(var(--device-screen-radius) + var(--device-bezel) + var(--device-frame-border-width))) - var(--device-bezel) - var(--device-frame-border-width))); + --device-screen-radius-effective-top-left: max(0px, calc(var(--device-frame-radius, calc(var(--device-screen-radius-top-left) + var(--device-bezel) + var(--device-frame-border-width))) - var(--device-bezel) - var(--device-frame-border-width))); + --device-screen-radius-effective-top-right: max(0px, calc(var(--device-frame-radius, calc(var(--device-screen-radius-top-right) + var(--device-bezel) + var(--device-frame-border-width))) - var(--device-bezel) - var(--device-frame-border-width))); + --device-screen-radius-effective-bottom-right: max(0px, calc(var(--device-frame-radius, calc(var(--device-screen-radius-bottom-right) + var(--device-bezel) + var(--device-frame-border-width))) - var(--device-bezel) - var(--device-frame-border-width))); + --device-screen-radius-effective-bottom-left: max(0px, calc(var(--device-frame-radius, calc(var(--device-screen-radius-bottom-left) + var(--device-bezel) + var(--device-frame-border-width))) - var(--device-bezel) - var(--device-frame-border-width))); --device-frame-border-width: ${DEVICE_FRAME_BORDER_WIDTH}px; /* Host-overridable skin. --device-frame-radius overrides the body radius the @@ -131,7 +138,7 @@ export const DEVICE_FRAME_STYLES = ` /* The screen sits inside both the bezel and the border, so staying concentric with the body needs both subtracted back out — floored at 0 so a small --device-frame-radius override never asks for a negative radius. */ - border-radius: var(--device-screen-radius-effective); + border-radius: var(--device-screen-radius-effective-top-left) var(--device-screen-radius-effective-top-right) var(--device-screen-radius-effective-bottom-right) var(--device-screen-radius-effective-bottom-left); } /* A centered ring makes a desktop mouse read as a touch point while it is From 6a2c43d6659083fde593c045ff4e1e8d5a76e30f Mon Sep 17 00:00:00 2001 From: lbb00 Date: Mon, 14 Sep 2026 12:12:12 +0800 Subject: [PATCH 2/2] style(devices): wrap iPhone Duo outer shell object to satisfy oxfmt --- packages/devices/src/presets/ios.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/devices/src/presets/ios.ts b/packages/devices/src/presets/ios.ts index f6c8b35..aee3439 100644 --- a/packages/devices/src/presets/ios.ts +++ b/packages/devices/src/presets/ios.ts @@ -809,7 +809,12 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ cutout: { shape: 'circle', width: 37, height: 37, top: 26, centerX: 0.9 }, // Rotated clockwise from the portrait geometry measured in Apple's closed-device render; not a DeviceHub measurement. cutoutLandscape: { shape: 'circle', width: 37, height: 37, top: 401, centerX: 0.934 }, - shell: { screenRadius: 66, screenCorners: { topLeft: 12, bottomLeft: 12 }, bezel: 6, bezelInsets: { left: 8 } }, + shell: { + screenRadius: 66, + screenCorners: { topLeft: 12, bottomLeft: 12 }, + bezel: 6, + bezelInsets: { left: 8 }, + }, releaseYear: 2026, }, {