Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/devices/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()` |
Expand Down
2 changes: 2 additions & 0 deletions packages/devices/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
36 changes: 33 additions & 3 deletions packages/devices/src/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand All @@ -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<CornerRadii>
/** 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. */
Expand All @@ -99,7 +109,8 @@ export interface HomeButtonSpec {
diameter: number
}

export interface ResolvedDeviceShell extends Required<Omit<DeviceShell, 'bezelInsets' | 'homeButton'>> {
export interface ResolvedDeviceShell extends Required<Omit<DeviceShell, 'screenCorners' | 'bezelInsets' | 'homeButton'>> {
screenCorners: CornerRadii
bezelInsets: EdgeInsets
homeButton: HomeButtonSpec | null
}
Expand Down Expand Up @@ -259,6 +270,16 @@ function withInsets(partial: Partial<EdgeInsets> | undefined, fallback: EdgeInse
}
}

function withCorners(partial: Partial<CornerRadii> | 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.
Expand All @@ -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
Expand Down Expand Up @@ -309,14 +337,16 @@ export function resolveDevice(profile: DeviceProfile): ResolvedDevice {
cutoutLandscape: profile.cutoutLandscape ?? null,
shell: {
screenRadius,
screenCorners,
bezel,
bezelInsets,
homeButton,
// The largest adjacent inset is the natural scalar envelope for an
// 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)),
},
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/devices/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export {
type DeviceProfile,
type PresetDeviceProfile,
type DeviceShell,
type CornerRadii,
type EdgeInsets,
type HomeButtonSpec,
type Orientation,
Expand Down
13 changes: 12 additions & 1 deletion packages/devices/src/presets/ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -803,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: 42, bezel: 6, bezelInsets: { left: 8 } },
shell: {
screenRadius: 66,
screenCorners: { topLeft: 12, bottomLeft: 12 },
bezel: 6,
bezelInsets: { left: 8 },
},
releaseYear: 2026,
},
{
Expand Down
6 changes: 6 additions & 0 deletions packages/devices/src/profile-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
33 changes: 33 additions & 0 deletions packages/devices/src/shell-defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
9 changes: 9 additions & 0 deletions packages/devices/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/frame/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 2 additions & 1 deletion packages/frame/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<length>`——百分比会被浏览器各自相对两个盒子单独解析,机身和屏幕就不再共享同一个圆心,这个变量也就不再是"同心一个圆角"的意思了)、`--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 `<length>`——百分比会被浏览器各自相对两个盒子单独解析,机身和屏幕就不再共享同一个圆心,这个变量也就不再是"同心一个圆角"的意思了)、`--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 safe-area-bottom="30">` 会覆盖底部安全区;在 `deviceProfile` 里设置 `safeAreaInsets.bottom: 30` 也一样。它会更新 `metrics.safeAreaInsets.bottom`、`--device-safe-area-bottom`,并让元素自身按这个安全区绘制底部区域和手势条。

Expand Down
Loading