From 9207dbdd1f04eee79df725e6fdc51c15e38ac430 Mon Sep 17 00:00:00 2001 From: Grok Bot Date: Fri, 30 Oct 2026 02:58:07 +0800 Subject: [PATCH 1/5] fix(devices): screen radii, Duo landscape, Face ID notch radii, Android cutouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - iOS ScreenCorners fractions; Duo outer hinge 8; Duo inner landscape safe 84/34 - Face ID notch bottomRadius / cited topRadius (X/XS/11 Pro ear 6); uncalibrated ears unset - Android geometry + cutout audit (Pixel 5/9/10 Pro, Fold, S20+/Ultra, 3 XL, …) - Classic honesty (drop unmeasured Pixel 8); validation / verified-preset tests --- packages/devices/CHANGELOG.md | 15 + packages/devices/README.md | 37 +- packages/devices/README.zh-CN.md | 31 +- packages/devices/src/classic.test.ts | 43 +- packages/devices/src/devices.ts | 26 +- packages/devices/src/index.ts | 1 + packages/devices/src/iphone-duo.test.ts | 24 +- packages/devices/src/presets-verified.test.ts | 177 +++++++- packages/devices/src/presets/android.ts | 427 ++++++++++++++++-- packages/devices/src/presets/classic.ts | 4 +- packages/devices/src/presets/ios.ts | 127 ++++-- .../devices/src/profile-validation.test.ts | 15 + packages/devices/src/validate.ts | 9 + 13 files changed, 827 insertions(+), 109 deletions(-) diff --git a/packages/devices/CHANGELOG.md b/packages/devices/CHANGELOG.md index dd345cf..77590c7 100644 --- a/packages/devices/CHANGELOG.md +++ b/packages/devices/CHANGELOG.md @@ -1,5 +1,20 @@ # @devicekit/devices +## Unreleased + +### Minor Changes + +- Screen-fidelity P0 follow-up (adversarial review): + - iOS ScreenCorners fractions: store **47.33** / **53.33** where measured (was truncated 47 / 53). + - iPhone Duo outer hinge corners **8** (DeviceHub / safearea.info); free edge remains 59 continuous. + - iPhone Duo **inner landscape** safe area **right 84 / bottom 34**. + - Face ID notch: per-family `bottomRadius` / cited `topRadius` (X/XS/11 Pro ear 6); uncalibrated ears stay unset. + - Classic drops **Pixel 8** until RoundedCorner is measured (no invented radius). Xiaomi 14 already omitted. + - Classic / default **Galaxy S24 Ultra** documents FHD+ window **384×832 @2.8125** (was silent 412×915); radius 2.13 noted as near-sharp under FHD+. + - Pixel 9 Pro status / safe top **52** + bottom **24** (emulator-tier windowinsets) called out vs older 68 strip. + - Android cutout audit: Pixel 5 / 9 Pro Fold centerX; Pixel 9/10 Pro Ø; S20+/Ultra holes; Pixel 3 XL notch; cite-only elsewhere. + - CI: Classic modern phones lock expected radius buckets; Harmony skip stays honest; cutout ⇒ explicit radius guard for modern iPhones. + ## 0.4.1 No changes in this release. diff --git a/packages/devices/README.md b/packages/devices/README.md index dacd1ad..cf0166a 100644 --- a/packages/devices/README.md +++ b/packages/devices/README.md @@ -49,7 +49,7 @@ const windowSize = resolveWindowSize(profile, { `DEVICES` contains the complete table. `IOS_DEVICES`, `ANDROID_DEVICES`, and `HARMONY_DEVICES` expose the platform lists. Folding devices use separate profiles for their inner and outer screens. -`CLASSIC_DEVICES` is a 19-profile subset for compact device selectors. `DEFAULT_DEVICE` is the iPhone X profile. `DEVICE_NAMES` provides typed names such as `DEVICE_NAMES.iPhone_16_Pro`. +`CLASSIC_DEVICES` is a 17-profile subset for compact device selectors (Pixel 8 and Xiaomi 14 are kept in `DEVICES` but omitted from Classic until screen radius / cutout / safe are measured). `DEFAULT_DEVICE` is the iPhone X profile. `DEVICE_NAMES` provides typed names such as `DEVICE_NAMES.iPhone_16_Pro`. ## Device profiles @@ -100,10 +100,40 @@ const windowSize = resolveWindowSize(profile, { `resolveSafeAreaInsets()` returns distances from the four screen edges. `resolveSafeArea()` returns `{ top, left, right, bottom, width, height }` in screen coordinates. `resolveWindowSize()` subtracts the status bar, the requested navigation bar, and `tabBarHeight` from the oriented screen. -The iPhone Duo rows are provisional: camera and status chrome are pixel-calibrated against Apple's website renders, while points, DPR, and safe-area values still await Xcode 27.1 DeviceHub verification. +iPhone Duo layout points, safe-area insets, and free-edge screen radius (59, continuous) follow Xcode 27.1 DeviceHub / safearea.info. Outer idle cutout remains Ø37 circle; hinge-side corners are **8**. Crease / pose APIs are not modeled yet. `navigationBar` accepts `true`, `false`, or a non-negative number. `tabBarHeight` accepts a non-negative number. Invalid numeric options throw a `RangeError`. + +## Galaxy S24 Ultra window mode (FHD+ default) + +Classic / default `Galaxy S24 Ultra` uses the **FHD+ gesture window** reported by windowinsets.info at densityDpi 450: + +| Field | Value | +| --- | --- | +| Logical window | **384×832** @ **2.8125** | +| Screen radius | **2.13** (WindowInsets.RoundedCorner — near-sharp content clip under FHD+; screenshot QA should expect a near-square content corner, not S24's 36) | +| Safe area | top ≈34 / bottom ≈15 | + +This is **not** the panel's full WQHD+ logical size (previously shipped as 412×915 @2.625). Hosts that need the old WQHD+-ish window should pass an explicit `deviceProfile` override: + +```ts +const ultra = findDevice('Galaxy S24 Ultra')! +const wqhd = { + ...ultra, + screen: { width: 412, height: 915 }, + pixelRatio: 2.625, +} +``` + +A named dual-field / `screenMode` API is tracked as P1. Until then, Classic consumers should treat 384×832 as the breaking default. + +## Classic screen-fidelity notes + +- Modern Classic phones (non-tablet, non-Harmony) lock an explicit measured `shell.screenRadius` in CI. +- Harmony Classic rows stay in the picker but **skip** the radius lock until RoundedCorner + bottom inset are measured — no invented cutouts. +- Pixel 8 and Xiaomi 14 remain in `ANDROID_DEVICES` / `DEVICES` but are **not** Classic. + ## User agents ```ts @@ -127,7 +157,7 @@ const version = systemVersion(profile) | `IOS_DEVICES` | `readonly PresetDeviceProfile[]` | iOS profiles | | `ANDROID_DEVICES` | `readonly PresetDeviceProfile[]` | Android profiles | | `HARMONY_DEVICES` | `readonly PresetDeviceProfile[]` | HarmonyOS profiles | -| `CLASSIC_DEVICES` | `readonly PresetDeviceProfile[]` | 19 profiles for short selectors | +| `CLASSIC_DEVICES` | `readonly PresetDeviceProfile[]` | 17 profiles for short selectors | | `DEFAULT_DEVICE` | `PresetDeviceProfile` | iPhone X profile | | `PLATFORM_DEFAULTS` | `Record` | Default bar and shell values by platform | | `DEVICE_NAMES` | `const object` | Typed name constants generated from `DEVICES` | @@ -170,6 +200,7 @@ const version = systemVersion(profile) | `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 | +| `cornerCurve` (on `DeviceShell`) | `'circular'` or `'continuous'`; applied only to `.screen` (iOS defaults to continuous / squircle; Android and HarmonyOS stay circular) | | `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 5a36226..f09e5c1 100644 --- a/packages/devices/README.zh-CN.md +++ b/packages/devices/README.zh-CN.md @@ -24,7 +24,31 @@ const device = findDevice(DEVICE_NAMES.iPhone_16_Pro) ?? DEFAULT_DEVICE `DEVICE_NAMES` 把表里每个 `name` 都变成一个能自动补全的常量,不用手打字符串:`DEVICE_NAMES.iPhone_16_Pro` 就是字符串 `'iPhone 16 Pro'`。键的推导规则是把机型名里每一段不属于 `[A-Za-z0-9]` 的字符合并成一个 `_`,再去掉首尾的 `_`——`'iPhone 12/13 (Pro)'` 变成 `iPhone_12_13_Pro`,`'iPad Pro 10.5-inch'` 变成 `iPad_Pro_10_5_inch`。写 `findDevice(DEVICE_NAMES.iPhone_16_Pro)` 传的还是字符串本身,常量的作用只是让手误变成一个不存在的属性报错,而不是查找悄悄落空。`DEVICE_NAMES` 来自一份生成文件,见下面的 API 一览。 -`CLASSIC_DEVICES` 是从同一批对象里手选出的精简子集——19 台,按 iOS → Android → HarmonyOS 分组——给放不下 171 行的机型下拉框用。选哪几台是主观判断,不是排名:iPhone 和 iPad、Pixel 和 Galaxy,加一台小米和四台华为。需要完整机型表的宿主仍然读 `DEVICES`。 +`CLASSIC_DEVICES` 是从同一批对象里手选出的精简子集——**17** 台,按 iOS → Android → HarmonyOS 分组——给放不下 171 行的机型下拉框用。选哪几台是主观判断,不是排名:iPhone 和 iPad、Pixel 9 Pro 与 Galaxy S24/Ultra,加四台华为。Pixel 8 与小米 14 仍在 `DEVICES` 里,但未测到 RoundedCorner / cutout / safe 前不进 Classic。需要完整机型表的宿主仍然读 `DEVICES`。 + + +## Galaxy S24 Ultra 窗口模式(默认 FHD+) + +Classic / 默认的 `Galaxy S24 Ultra` 使用 windowinsets.info 在 densityDpi 450 下报告的 **FHD+ 手势窗**: + +| 字段 | 值 | +| --- | --- | +| 逻辑窗 | **384×832** @ **2.8125** | +| 屏幕圆角 | **2.13**(FHD+ 下接近直角裁切;截图验收不要按 S24 的 36 来比) | +| 安全区 | top ≈34 / bottom ≈15 | + +这不是面板完整 WQHD+ 逻辑尺寸(旧值曾是 412×915 @2.625)。需要旧窗的宿主请显式覆盖 `deviceProfile`: + +```ts +const ultra = findDevice('Galaxy S24 Ultra')! +const wqhd = { + ...ultra, + screen: { width: 412, height: 915 }, + pixelRatio: 2.625, +} +``` + +命名的双字段 / `screenMode` API 记为 P1。在此之前,Classic 消费者应把 384×832 当作破坏性默认。 ## 一条机型长什么样 @@ -95,7 +119,7 @@ const resolved = resolveDevice(device) 明确**未核实**、因而留在默认值上没有猜的: -- iPhone Duo 的摄像头孔和专用状态栏已按 Apple 官网营销图做像素校准;points、DPR 和安全区仍未经过 Xcode 27.1 DeviceHub 实测。 +- iPhone Duo 的布局点、安全区和自由边圆角(59,continuous)按 Xcode 27.1 DeviceHub / safearea.info;外屏空闲挖孔仍为 Ø37 圆;铰链边圆角(~6)仍为 provisional。折痕 / pose API 尚未建模。 - 鸿蒙的手势条高度——来源里没有这一项,所以这些机型报的底部安全区是 0,手势条也不画。 - 安卓和鸿蒙横屏的一切——包括状态栏转屏后是不是还在。机型不写横屏字段时并不会沿用自己竖屏的值,而是落到 `PLATFORM_DEFAULTS`:安卓固定 24、鸿蒙固定 36,跟这台机器竖屏实测多少无关(比如 Pixel 7 竖屏状态栏是 52,横屏没写就落到 24)。机型不写 `safeAreaInsetsLandscape` 时,顶部安全区仍然按横屏状态栏高度补齐,不会补成 0——安卓和鸿蒙转成横屏状态栏照样在,补 0 跟竖屏不测就漏填一样是错的。只有横屏状态栏本来就是 0 的 iOS 才能放心用空安全区兜底。 @@ -156,7 +180,7 @@ import { deviceUserAgent, systemVersion } from '@devicekit/devices' | `IOS_DEVICES` | `readonly PresetDeviceProfile[]` | iOS 那部分 | | `ANDROID_DEVICES` | `readonly PresetDeviceProfile[]` | Android 那部分 | | `HARMONY_DEVICES` | `readonly PresetDeviceProfile[]` | HarmonyOS 那部分 | -| `CLASSIC_DEVICES` | `readonly PresetDeviceProfile[]` | 手选的不到 20 台,同一批对象,给短列表用 | +| `CLASSIC_DEVICES` | `readonly PresetDeviceProfile[]` | 手选的 17 台,同一批对象,给短列表用 | | `DEFAULT_DEVICE` | `PresetDeviceProfile` | 没指定机型时画的那台(iPhone X) | | `PLATFORM_DEFAULTS` | `Record` | 各平台的状态栏、导航栏和机身默认值 | | `DEVICE_NAMES` | `{ [key: string]: string }`(as const) | 每个 `DEVICES[number].name`,键是 `deviceNameKey(name)`——生成文件,见下 | @@ -198,6 +222,7 @@ import { deviceUserAgent, systemVersion } from '@devicekit/devices' | `DeviceShell` | 机身:`screenRadius`、统一的 `bezel`;`bezelInsets` 可单独指定四边,`homeButton` 描述实体 Home 键;用了分边 inset 时应显式设置 `bodyRadius` | | `CornerRadii` | `{ topLeft, topRight, bottomRight, bottomLeft }`,顺序和 CSS `border-radius` 一致 | | `screenCorners`(`DeviceShell` 上的字段) | 按角覆盖 `screenRadius`,用于四角屏幕圆角不一致的机身——比如折叠屏合页那一侧。省略的角沿用统一的 `screenRadius` | +| `cornerCurve`(`DeviceShell` 上的字段) | `'circular'` 或 `'continuous'`;只作用在 `.screen`(iOS 默认 continuous / squircle;安卓与鸿蒙保持 circular) | | `HomeButtonSpec` | `{ diameter }`,机身 bezel 中央的实体 Home 键 | | `ResolvedDeviceShell` | 四边都已补齐、`homeButton` 为规格或 `null` 的机身数据 | | `WindowSizeOptions` | `resolveWindowSize` 的选项:`orientation`、`navigationBar`、`tabBarHeight` | diff --git a/packages/devices/src/classic.test.ts b/packages/devices/src/classic.test.ts index 8731406..652ea9e 100644 --- a/packages/devices/src/classic.test.ts +++ b/packages/devices/src/classic.test.ts @@ -5,7 +5,21 @@ * a name chosen from it resolves through findDevice() like any other entry. */ import { describe, expect, it } from 'vitest' -import { CLASSIC_DEVICES, DEFAULT_DEVICE, DEVICES, findDevice, type DeviceOS } from './index.js' +import { CLASSIC_DEVICES, DEFAULT_DEVICE, DEVICES, findDevice, resolveDevice, type DeviceOS } from './index.js' + +/** Measured screenRadius locked for Classic modern phones (Harmony skipped until measured). */ +const CLASSIC_SCREEN_RADIUS: Record = { + 'iPhone SE (3rd gen)': 0, + 'iPhone X': 39, + 'iPhone 12/13 (Pro)': 47.33, + 'iPhone 14 Pro': 55, + 'iPhone 15': 55, + 'iPhone 16 Pro Max': 62, + 'iPhone 17 Pro': 62, + 'Pixel 9 Pro': 52.33, + 'Galaxy S24': 36, + 'Galaxy S24 Ultra': 2.13, +} describe('CLASSIC_DEVICES', () => { it('is a short list: at most 20 entries, at least one per platform', () => { @@ -38,4 +52,31 @@ describe('CLASSIC_DEVICES', () => { expect(lastIndex('ios')).toBeLessThan(firstIndex('android')) expect(lastIndex('android')).toBeLessThan(firstIndex('harmony')) }) + + it('does not include unverified Classic rows (Pixel 8 / Xiaomi 14)', () => { + const names = new Set(CLASSIC_DEVICES.map((d) => d.name)) + expect(names.has('Pixel 8')).toBe(false) + expect(names.has('Xiaomi 14')).toBe(false) + }) + + it('locks measured screenRadius for Classic modern phones (Harmony skipped until measured)', () => { + for (const d of CLASSIC_DEVICES) { + if (d.formFactor === 'tablet') continue + // Harmony Classic rows still lack measured RoundedCorner — skip honestly. + if (d.os === 'harmony') continue + const expected = CLASSIC_SCREEN_RADIUS[d.name] + expect(expected, `${d.name} must have a locked Classic radius`).toEqual(expect.any(Number)) + expect(d.shell?.screenRadius, `${d.name} explicit screenRadius`).toBe(expected) + expect(resolveDevice(d).shell.screenRadius, `${d.name} resolved`).toBe(expected) + } + }) + + it('Harmony Classic phones are present but not inventing radius/cutout', () => { + const harmony = CLASSIC_DEVICES.filter((d) => d.os === 'harmony' && d.formFactor !== 'tablet') + expect(harmony.length).toBeGreaterThan(0) + for (const d of harmony) { + expect(d.shell?.screenRadius, `${d.name} must not invent radius`).toBeUndefined() + expect(d.cutout, `${d.name} must not invent cutout`).toBeUndefined() + } + }) }) diff --git a/packages/devices/src/devices.ts b/packages/devices/src/devices.ts index 49e78b4..e769c21 100644 --- a/packages/devices/src/devices.ts +++ b/packages/devices/src/devices.ts @@ -73,6 +73,9 @@ export interface CornerRadii { */ export type CutoutShape = 'notch' | 'pill' | 'circle' +/** How `.screen` corners are curved. iOS uses continuous (squircle); Android/Harmony stay circular. */ +export type CornerCurve = 'circular' | 'continuous' + /** * Where to draw the cutout, in CSS px at the device's portrait width. * @@ -87,6 +90,17 @@ export interface CutoutSpec { top: number /** Horizontal center as a fraction of screen width. Omitted = centered. */ centerX?: number + /** + * Notch bottom-corner radius in CSS px (portrait). Omitted → `round(height * 0.6)`. + * Sourced from Apple Design Resources bezel screen-hole path fits, or NotchView mm→pt + * when no bezel asset exists (see NOTCH-CORNER-RESTORE.md). + */ + bottomRadius?: number + /** + * Notch top-edge ear fillet radius in CSS px (the outward sweep where the notch + * meets the screen's top edge). Omitted → 6. + */ + topRadius?: number } /** The phone's body around the screen. */ @@ -95,6 +109,11 @@ export interface DeviceShell { screenRadius: number /** Per-corner screen radius. Omitted corners keep the uniform `screenRadius` value. */ screenCorners?: Partial + /** + * Screen corner curve applied only to `.screen` (not the body/bezel). + * Omitted = the platform default (`continuous` on iOS, `circular` elsewhere). + */ + cornerCurve?: CornerCurve /** 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. */ @@ -207,7 +226,7 @@ export const PLATFORM_DEFAULTS: Record { +describe('iPhone Duo DeviceHub profiles', () => { it('stores both screens, their edge-oriented chrome, and the outer camera', () => { const outer = findDevice('iPhone Duo (outer)') const inner = findDevice('iPhone Duo (inner)') @@ -11,27 +11,31 @@ describe('iPhone Duo provisional profiles', () => { expect(outer).toMatchObject({ screen: { width: 466, height: 678 }, pixelRatio: 3, system: 'iOS 27.1', releaseYear: 2026, statusBarHeight: 44, statusBarHeightLandscape: 44, statusBarEdge: 'right', statusBarEdgeLandscape: 'right', - safeAreaInsets: { top: 12, right: 68, bottom: 12, left: 0 }, - safeAreaInsetsLandscape: { top: 12, right: 68, bottom: 12, left: 0 }, + safeAreaInsets: { top: 0, right: 84, bottom: 34, left: 0 }, + safeAreaInsetsLandscape: { top: 0, right: 84, bottom: 34, left: 0 }, cutout: { shape: 'circle', width: 37, height: 37, top: 26, centerX: 0.9 }, + shell: { screenRadius: 59, screenCorners: { topLeft: 8, bottomLeft: 8 } }, }) expect(inner).toMatchObject({ - screen: { width: 626, height: 890 }, pixelRatio: 3, system: 'iOS 27.1', releaseYear: 2026, + screen: { width: 669, height: 951 }, pixelRatio: 3, system: 'iOS 27.1', releaseYear: 2026, statusBarHeight: 24, statusBarHeightLandscape: 44, statusBarEdge: 'top', statusBarEdgeLandscape: 'right', - safeAreaInsets: { top: 68, bottom: 34 }, - safeAreaInsetsLandscape: { right: 68 }, + safeAreaInsets: { top: 82, bottom: 34 }, + safeAreaInsetsLandscape: { top: 0, right: 84, bottom: 34, left: 0 }, + shell: { screenRadius: 59 }, }) const resolvedOuter = resolveDevice(outer!) const resolvedInner = resolveDevice(inner!) - expect(safeAreaInsetsFor(resolvedOuter, 'portrait')).toEqual({ top: 12, right: 68, bottom: 12, left: 0 }) - expect(safeAreaInsetsFor(resolvedInner, 'landscape')).toEqual({ top: 0, right: 68, bottom: 0, left: 0 }) + expect(safeAreaInsetsFor(resolvedOuter, 'portrait')).toEqual({ top: 0, right: 84, bottom: 34, left: 0 }) + expect(safeAreaInsetsFor(resolvedInner, 'landscape')).toEqual({ top: 0, right: 84, bottom: 34, left: 0 }) + expect(resolvedOuter.shell.cornerCurve).toBe('continuous') + expect(resolvedInner.shell.cornerCurve).toBe('continuous') expect(statusBarEdgeFor(resolvedOuter, 'portrait')).toBe('right') expect(statusBarEdgeFor(resolvedInner, 'landscape')).toBe('right') expect(DEVICE_NAMES.iPhone_Duo_outer).toBe('iPhone Duo (outer)') expect(DEVICE_NAMES.iPhone_Duo_inner).toBe('iPhone Duo (inner)') }) - it('keeps the provisional camera and 44px status indicator outside each safe content rectangle', () => { + it('keeps the outer camera and 44px status indicator outside each safe content rectangle', () => { const outer = findDevice('iPhone Duo (outer)')! const inner = findDevice('iPhone Duo (inner)')! const outerPortraitSafe = resolveSafeArea(outer) @@ -40,7 +44,7 @@ describe('iPhone Duo provisional profiles', () => { const innerLandscapeSafe = resolveSafeArea(inner, 'landscape') // The frame draws the top indicator from y=22 and keeps side indicators - // inside the 68px strip; Duo values remain provisional Apple-image calibration. + // inside the trailing safe strip (DeviceHub/safearea Fully Open HT Off: outer 84 / inner landscape 84). expect(innerPortraitSafe.top).toBeGreaterThanOrEqual(22 + 44) expect(outer.screen.width - 25 - 44 + 2).toBeGreaterThanOrEqual(outerPortraitSafe.right) expect(inner.screen.height - 25 - 44 + 2).toBeGreaterThanOrEqual(innerLandscapeSafe.right) diff --git a/packages/devices/src/presets-verified.test.ts b/packages/devices/src/presets-verified.test.ts index 6343793..cfe34e7 100644 --- a/packages/devices/src/presets-verified.test.ts +++ b/packages/devices/src/presets-verified.test.ts @@ -35,12 +35,16 @@ describe('A. iOS presets', () => { expect(d.statusBarHeight, 'statusBarHeight').toBe(48) expect(d.safeAreaInsets, 'safeAreaInsets').toEqual(insets(48, 0, 34, 0)) expect(d.safeAreaInsetsLandscape, 'safeAreaInsetsLandscape').toEqual(insets(0, 48, 21, 48)) + // safearea.info DeviceHub 227×32 (ADV/CANONICAL agree); bottom 21.5 bezel; ear unset. + expect(d.cutout, 'cutout').toEqual({ + shape: 'notch', width: 227, height: 32, top: 0, bottomRadius: 21.5, + }) }) }) it('iPhone XS Max — notch cutout + landscape 44/44/21 + safe area 44/34 (portrait/landscape insets measured, notch size from spec)', () => { const d = device('iPhone XS Max') - expect(d.cutout).toEqual({ shape: 'notch', width: 209, height: 30, top: 0 }) + expect(d.cutout).toEqual({ shape: 'notch', width: 209, height: 30, top: 0, bottomRadius: 20, topRadius: 6 }) expect(d.safeAreaInsetsLandscape).toEqual(insets(0, 44, 21, 44)) expect(d.safeAreaInsets).toEqual(insets(44, 0, 34, 0)) }) @@ -61,8 +65,21 @@ describe('A. iOS presets', () => { expect(d.statusBarHeight, 'statusBarHeight').toBe(50) expect(d.safeAreaInsets, 'safeAreaInsets').toEqual(insets(50, 0, 34, 0)) expect(d.safeAreaInsetsLandscape, 'safeAreaInsetsLandscape').toEqual(insets(0, 50, 21, 50)) - // Icons start at x=285.7 in the iOS Simulator with ~2pt clearance, so the notch cannot be wider than this. - expect(d.cutout, 'cutout').toEqual({ shape: 'notch', width: 192, height: 30, top: 0 }) + }) + + // DeviceHub / safearea.info supersede the old Simulator icon-clearance lock (192×30). + it('iPhone 12 Mini cutout matches safearea.info DeviceHub notch (226×34) + ADV bottom ≈20', () => { + expect(device('iPhone 12 Mini').cutout).toEqual({ + shape: 'notch', width: 226, height: 34, top: 0, bottomRadius: 20, + }) + }) + + it('iPhone 13 Mini / merged mini use safearea.info 13 mini notch (174×37) + ADV bottom ≈21', () => { + const expected = { + shape: 'notch', width: 174, height: 37, top: 0, bottomRadius: 21, + } + expect(device('iPhone 13 Mini').cutout).toEqual(expected) + expect(device('iPhone 12/13 mini').cutout).toEqual(expected) }) }) @@ -216,13 +233,151 @@ describe('A. iOS presets', () => { const offenders = iphones.filter((d) => device(d.name).statusBarHeightLandscape !== 0) expect(offenders.map((d) => d.name)).toEqual([]) }) + + it('iPhone 17 Pro Max locks screenRadius 62 (ScreenCorners / same as 17 Pro)', () => { + expect(device('iPhone 17 Pro Max').shell.screenRadius).toBe(62) + }) + + + describe('ScreenCorners radius buckets (locked)', () => { + it.each([ + ['iPhone X', 39], + ['iPhone XR', 41.5], + ['iPhone 12 Mini', 44], + ['iPhone 12', 47.33], + ['iPhone 12 Pro', 47.33], + ['iPhone 13 Pro', 47.33], + ['iPhone 14', 47.33], + ['iPhone 12 Pro Max', 53.33], + ['iPhone 14 Plus', 53.33], + ['iPhone 14 Pro', 55], + ['iPhone 15', 55], + ['iPhone 16 Pro Max', 62], + ['iPhone 17 Pro', 62], + ['iPhone 17 Pro Max', 62], + ] as const)('%s screenRadius %s', (name, radius) => { + expect(device(name).shell.screenRadius).toBe(radius) + }) + }) + + it('every modern iPhone with a cutout declares an explicit screenRadius (no DEFAULT 38)', () => { + const offenders = IOS_DEVICES.filter((p) => { + if (p.formFactor === 'tablet') return false + if (!p.cutout) return false + return p.shell?.screenRadius === undefined + }) + expect(offenders.map((p) => p.name)).toEqual([]) + }) + + it('iPhone Duo screens use DeviceHub layout points and continuous free-edge radius 59', () => { + const outer = device('iPhone Duo (outer)') + const inner = device('iPhone Duo (inner)') + expect(outer.screen).toEqual({ width: 466, height: 678 }) + expect(inner.screen).toEqual({ width: 669, height: 951 }) + expect(outer.shell.screenRadius).toBe(59) + expect(outer.shell.screenCorners).toEqual({ topLeft: 8, topRight: 59, bottomRight: 59, bottomLeft: 8 }) + expect(inner.shell.screenRadius).toBe(59) + expect(outer.shell.cornerCurve).toBe('continuous') + expect(inner.safeAreaInsets).toEqual(insets(82, 0, 34, 0)) + expect(inner.safeAreaInsetsLandscape).toEqual(insets(0, 84, 34, 0)) + expect(outer.safeAreaInsets).toEqual(insets(0, 84, 34, 0)) + expect(outer.safeAreaInsetsLandscape).toEqual(insets(0, 84, 34, 0)) + expect(outer.cutout).toEqual({ shape: 'circle', width: 37, height: 37, top: 26, centerX: 0.9 }) + }) + + }) describe('B. Android presets', () => { - it('Samsung S21 FE — pixelRatio 3 (1080x2400 -> 360x800)', () => { + + it('Galaxy S24 / S24 Ultra / Pixel 9 Pro expose measured screen radius, safe area, and cutout', () => { + const s24 = device('Galaxy S24') + expect(s24.shell.screenRadius).toBe(36) + expect(s24.safeAreaInsets).toEqual(insets(34, 0, 15, 0)) + expect(s24.cutout?.shape).toBe('circle') + + const ultra = device('Galaxy S24 Ultra') + expect(ultra.screen).toEqual({ width: 384, height: 832 }) + expect(ultra.pixelRatio).toBe(2.8125) + expect(ultra.shell.screenRadius).toBe(2.13) + expect(ultra.safeAreaInsets).toEqual(insets(34, 0, 15, 0)) + + const pixel = device('Pixel 9 Pro') + expect(pixel.shell.screenRadius).toBe(52.33) + expect(pixel.safeAreaInsets).toEqual(insets(52, 0, 24, 0)) + expect(pixel.statusBarHeight).toBe(52) + expect(pixel.shell.cornerCurve).toBe('circular') + }) + + it('Samsung S21 FE — windowinsets FHD window 360x780 @3 (was 360x800)', () => { const d = device('Samsung S21 FE') expect(d.pixelRatio).toBe(3) - expect(d.screen).toEqual({ width: 360, height: 800 }) + expect(d.screen).toEqual({ width: 360, height: 780 }) + expect(d.shell.screenRadius).toBe(40.33) + }) + + // windowinsets.info gesture captures — radius + safeDrawing (bottom included). + describe('windowinsets-backed Android screen geometry', () => { + it.each([ + ['Galaxy S23', 36, 27, 15, { width: 360, height: 780 }, 3], + ['Galaxy S23 Plus', 35.91, 26, 15, { width: 384, height: 832 }, 2.8125], + ['Galaxy S23 Ultra', 3.91, 33, 15, { width: 384, height: 823 }, 2.8125], + ['Galaxy S22', 34, 27, 15, { width: 360, height: 780 }, 3], + ['Galaxy S22 Ultra', 35.91, 27, 15, { width: 384, height: 823 }, 2.8125], + ['Galaxy S24 Plus', 35.91, 34, 15, { width: 384, height: 832 }, 2.8125], + ['Samsung Galaxy A55', 41.96, 32, 15, { width: 384, height: 832 }, 2.8125], + ['Pixel 8', 39.62, 50, 24, { width: 412, height: 915 }, 2.625], + ['Pixel 7', 17.9, 52, 24, { width: 412, height: 915 }, 2.625], + ['Pixel 7a', 17.9, 45, 24, { width: 412, height: 915 }, 2.625], + ['Pixel 6a', 17.9, 50, 24, { width: 412, height: 915 }, 2.625], + ['Pixel 8 Pro', 30.33, 50, 24, { width: 448, height: 997 }, 3], + ['Pixel 9', 50.29, 54, 24, { width: 412, height: 924 }, 2.625], + ['Pixel 9 Pro XL', 51, 53, 24, { width: 448, height: 997 }, 3], + ['Pixel 10 Pro', 52.33, 52, 24, { width: 427, height: 952 }, 3], + ] as const)('%s radius/safe/window', (name, radius, top, bottom, screen, pr) => { + const d = device(name) + expect(d.shell.screenRadius, 'screenRadius').toBe(radius) + expect(d.safeAreaInsets).toEqual(insets(top, 0, bottom, 0)) + expect(d.statusBarHeight).toBe(top) + expect(d.screen).toEqual(screen) + expect(d.pixelRatio).toBe(pr) + expect(d.shell.cornerCurve).toBe('circular') + }) + }) + + it('legacy flat Android panels — explicit screenRadius 2 (no WI; honesty vs default 16)', () => { + for (const name of [ + 'Nexus 5', + 'Nexus 5X', + 'Nexus 6', + 'Nexus 4', + 'Nexus 6P', + 'Nexus 7', + 'Nexus 10', + 'Galaxy Note 3', + 'Galaxy Note II', + 'Galaxy S III', + 'Galaxy S5', + 'LG Optimus L70', + 'Moto G4', + ]) { + expect(device(name).shell.screenRadius, name).toBe(2) + } + }) + + it('Pixel 2 / Pixel 2 XL — OEM sysui_rounded_size defaults (XDA/SystemUI); no WI', () => { + expect(device('Pixel 2').shell.screenRadius).toBe(0) + expect(device('Pixel 2 XL').shell.screenRadius).toBe(26) + }) + + it('Galaxy Z Flip 7 Cover — WI cover window + asymmetric corners + bottom pill cutout', () => { + const d = device('Galaxy Z Flip 7 Cover') + expect(d.screen).toEqual({ width: 361, height: 399 }) + expect(d.pixelRatio).toBe(2.625) + expect(d.shell.screenRadius).toBe(6.1) + expect(d.shell.screenCorners).toEqual({ topLeft: 6.1, topRight: 6.1, bottomRight: 41.14, bottomLeft: 41.14 }) + expect(d.cutout).toEqual({ shape: 'pill', width: 198, height: 84, top: 315, centerX: 0.726 }) + expect(d.safeAreaInsets.bottom).toBe(84) }) it('Pixel Tablet — screen is 800x1280 dp, not the 1600x2560 px panel', () => { @@ -301,6 +456,18 @@ describe('B. Android presets', () => { expect(device('Pixel 3 XL').userAgent).toContain('Pixel 3 XL') }) + it('Pixel 5 — left-edge punch-hole centerX from windowinsets.info bounds', () => { + expect(device('Pixel 5').cutout).toEqual({ + shape: 'circle', width: 49, height: 49, top: 0, centerX: 0.063, + }) + }) + + it('Pixel 9 Pro Fold (inner) — right-edge hole centerX from windowinsets.info bounds', () => { + expect(device('Pixel 9 Pro Fold (inner)').cutout).toEqual({ + shape: 'circle', width: 56, height: 56, top: 0, centerX: 0.967, + }) + }) + it('Pixel 7 Pro — statusBarHeight 41, cutout circle 25/25/top8 (AVD measured; screen/pixelRatio kept at current values)', () => { const d = device('Pixel 7 Pro') expect(d.statusBarHeight).toBe(41) diff --git a/packages/devices/src/presets/android.ts b/packages/devices/src/presets/android.ts index 4579a0f..03da536 100644 --- a/packages/devices/src/presets/android.ts +++ b/packages/devices/src/presets/android.ts @@ -14,9 +14,11 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 915 }, pixelRatio: 2.625, system: 'Android 13', + // windowinsets.info/pixel-7 (emulator, gesture nav): RoundedCorner topLeft 17.9dp (asymmetric BR 18.29); safe top≈52 bottom≈24. statusBarHeight: 52, - safeAreaInsets: { top: 52 }, + safeAreaInsets: { top: 52, bottom: 24 }, safeAreaInsetsLandscape: { right: 52 }, + shell: { screenRadius: 17.9 }, cutout: { shape: 'circle', width: 26, height: 26, top: 13 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', @@ -28,9 +30,11 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 915 }, pixelRatio: 2.625, system: 'Android 14', + // windowinsets.info/pixel-8 (emulator, gesture nav): RoundedCorner topLeft 39.62dp (asymmetric BR 38.86); safe top≈50 bottom≈24. statusBarHeight: 50, - safeAreaInsets: { top: 50 }, + safeAreaInsets: { top: 50, bottom: 24 }, safeAreaInsetsLandscape: { right: 50 }, + shell: { screenRadius: 39.62 }, cutout: { shape: 'circle', width: 28, height: 28, top: 11 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', @@ -42,9 +46,11 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 448, height: 997 }, pixelRatio: 3, system: 'Android 14', + // windowinsets.info/pixel-8-pro (emulator, gesture nav): RoundedCorner 30.33dp; safe top≈50 bottom≈24. statusBarHeight: 50, - safeAreaInsets: { top: 50 }, + safeAreaInsets: { top: 50, bottom: 24 }, safeAreaInsetsLandscape: { right: 50 }, + shell: { screenRadius: 30.33 }, cutout: { shape: 'circle', width: 28, height: 28, top: 11 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', @@ -56,9 +62,11 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 915 }, pixelRatio: 2.625, system: 'Android 14', + // windowinsets.info/pixel-8a (emulator, gesture nav): RoundedCorner 48.76dp; safe top≈46 bottom≈24. statusBarHeight: 46, - safeAreaInsets: { top: 46 }, + safeAreaInsets: { top: 46, bottom: 24 }, safeAreaInsetsLandscape: { right: 46 }, + shell: { screenRadius: 48.76 }, cutout: { shape: 'circle', width: 26, height: 26, top: 13 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', @@ -70,9 +78,11 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 924 }, pixelRatio: 2.625, system: 'Android 14', - statusBarHeight: 58, - safeAreaInsets: { top: 58 }, - safeAreaInsetsLandscape: { right: 58 }, + // windowinsets.info/pixel-9 (emulator, gesture nav): RoundedCorner 50.29dp; safe top≈54 bottom≈24. + statusBarHeight: 54, + safeAreaInsets: { top: 54, bottom: 24 }, + safeAreaInsetsLandscape: { right: 54 }, + shell: { screenRadius: 50.29 }, cutout: { shape: 'circle', width: 28, height: 28, top: 15 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', @@ -84,10 +94,13 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 427, height: 952 }, pixelRatio: 3, system: 'Android 14', - statusBarHeight: 68, - safeAreaInsets: { top: 68 }, - safeAreaInsetsLandscape: { right: 68 }, - cutout: { shape: 'circle', width: 32, height: 32, top: 18 }, + // windowinsets.info/pixel-9-pro (emulator, gesture nav): RoundedCorner 52.33dp; safe top≈52 bottom≈24; + // displayCutoutBounds 36.33×52@0 → circle Ø36 top8 (same derive as Pixel 10 Pro XL / 9a exclusion-width). + statusBarHeight: 52, + safeAreaInsets: { top: 52, bottom: 24 }, + safeAreaInsetsLandscape: { right: 52 }, + shell: { screenRadius: 52.33 }, + cutout: { shape: 'circle', width: 36, height: 36, top: 8 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 9 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2024, @@ -98,10 +111,13 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 448, height: 997 }, pixelRatio: 3, system: 'Android 14', - statusBarHeight: 66, - safeAreaInsets: { top: 66 }, - safeAreaInsetsLandscape: { right: 66 }, - cutout: { shape: 'circle', width: 32, height: 32, top: 17 }, + // windowinsets.info/pixel-9-pro-xl (emulator, gesture nav): RoundedCorner 51dp; safe top≈53 bottom≈24; + // displayCutoutBounds 38.33×53@0 (byte-identical to pixel-10-pro-xl) → circle Ø38 top8. + statusBarHeight: 53, + safeAreaInsets: { top: 53, bottom: 24 }, + safeAreaInsetsLandscape: { right: 53 }, + shell: { screenRadius: 51 }, + cutout: { shape: 'circle', width: 38, height: 38, top: 8 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 9 Pro XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2024, @@ -112,9 +128,11 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 924 }, pixelRatio: 2.625, system: 'Android 16', - statusBarHeight: 58, - safeAreaInsets: { top: 58 }, - safeAreaInsetsLandscape: { right: 58 }, + // windowinsets.info/pixel-10 (emulator, gesture nav): RoundedCorner 50.29dp; safe top≈54 bottom≈24. + statusBarHeight: 54, + safeAreaInsets: { top: 54, bottom: 24 }, + safeAreaInsetsLandscape: { right: 54 }, + shell: { screenRadius: 50.29 }, cutout: { shape: 'circle', width: 28, height: 28, top: 15 }, userAgent: 'Mozilla/5.0 (Linux; Android 16; Pixel 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', @@ -136,8 +154,17 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ os: 'android', statusBarStyle: 'android-samsung', screen: { width: 412, height: 915 }, - pixelRatio: 3.5, + pixelRatio: 2.625, system: 'Android 13', + // windowinsets.info/galaxy-s20-ultra (measured, gesture nav): RoundedCorner 24dp; safe top≈28 bottom≈15. + // Published displayCutoutBounds=null (FHD window on QHD panel; left buggy). Raw InsetsProbe boundingRects + // https://github.com/easyhooon/windowinsets.info/blob/main/measurements/galaxy-s/galaxy-s20-ultra/main-gesture.json + // 22.1×28.19@0 → circle Ø22 top3; omit centerX (hardware centered; ignore left=263 / path x=720). + statusBarHeight: 28, + safeAreaInsets: { top: 28, bottom: 15 }, + safeAreaInsetsLandscape: { right: 28 }, + shell: { screenRadius: 24 }, + cutout: { shape: 'circle', width: 22, height: 22, top: 3 }, userAgent: 'Mozilla/5.0 (Linux; Android 10; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.162 Mobile Safari/537.36', releaseYear: 2020, @@ -163,9 +190,15 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ name: 'Samsung Galaxy A55', os: 'android', statusBarStyle: 'android-samsung', - screen: { width: 360, height: 800 }, - pixelRatio: 2.25, + screen: { width: 384, height: 832 }, + pixelRatio: 2.8125, system: 'Android 14', + // windowinsets.info/galaxy-a55-5g (measured, gesture nav): RoundedCorner 41.96dp; safe top≈32 bottom≈15; cutout circle 23@8. + statusBarHeight: 32, + safeAreaInsets: { top: 32, bottom: 15 }, + safeAreaInsetsLandscape: { right: 32 }, + cutout: { shape: 'circle', width: 23, height: 23, top: 8 }, + shell: { screenRadius: 41.96 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2024, @@ -176,6 +209,12 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 922 }, pixelRatio: 2.625, system: 'Android 14', + // windowinsets.info/pixel-9-pro-fold (emulator, gesture nav): RoundedCorner 47.18dp; safe top≈62 bottom≈24; cutout circle 46@8. + statusBarHeight: 62, + safeAreaInsets: { top: 62, bottom: 24 }, + safeAreaInsetsLandscape: { right: 62 }, + cutout: { shape: 'circle', width: 46, height: 46, top: 8 }, + shell: { screenRadius: 47.18 }, userAgent: 'Mozilla/5.0 (Linux; Android 15; Pixel 9 Pro Fold) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', releaseYear: 2024, @@ -186,6 +225,13 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 836, height: 842 }, pixelRatio: 2.625, system: 'Android 14', + // windowinsets.info/pixel-9-pro-fold main/inner (emulator, gesture nav): RoundedCorner 34.87dp; safe top≈56 bottom≈32; + // displayCutoutBounds 55.8×55.8@0 left≈796 on ~852dp → circle Ø56 top0 centerX≈0.967 (right-edge hole; not centered). + statusBarHeight: 56, + safeAreaInsets: { top: 56, bottom: 32 }, + safeAreaInsetsLandscape: { right: 56 }, + cutout: { shape: 'circle', width: 56, height: 56, top: 0, centerX: 0.967 }, + shell: { screenRadius: 34.87 }, releaseYear: 2024, }, { @@ -195,6 +241,13 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 968 }, pixelRatio: 2.625, system: 'Android 14', + // windowinsets.info/galaxy-z-fold6 (measured, gesture nav): RoundedCorner 6.1dp; safe top≈36 bottom≈15; cutout circle 24@6. + // Window left legacy — kit 412×968 matches neither WI cover (~369×905) nor main (~707×823). + statusBarHeight: 36, + safeAreaInsets: { top: 36, bottom: 15 }, + safeAreaInsetsLandscape: { right: 36 }, + cutout: { shape: 'circle', width: 24, height: 24, top: 6 }, + shell: { screenRadius: 6.1 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; SM-F956U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2024, @@ -207,6 +260,11 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 707, height: 823 }, pixelRatio: 2.625, system: 'Android 14', + // windowinsets.info/galaxy-z-fold6 (measured, gesture nav): RoundedCorner 4.95dp; safe top≈36 bottom≈15. + statusBarHeight: 36, + safeAreaInsets: { top: 36, bottom: 15 }, + safeAreaInsetsLandscape: { right: 36 }, + shell: { screenRadius: 4.95 }, releaseYear: 2024, }, { @@ -216,6 +274,12 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 344, height: 882 }, pixelRatio: 2.625, system: 'Android 13', + // windowinsets.info/galaxy-z-fold5 (measured, gesture nav): RoundedCorner 25.9dp; safe top≈32 bottom≈15; cutout circle 22@5. + statusBarHeight: 32, + safeAreaInsets: { top: 32, bottom: 15 }, + safeAreaInsetsLandscape: { right: 32 }, + cutout: { shape: 'circle', width: 22, height: 22, top: 5 }, + shell: { screenRadius: 25.9 }, userAgent: 'Mozilla/5.0 (Linux; Android 13; SM-F946B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.131 Mobile Safari/537.36', releaseYear: 2023, @@ -227,6 +291,11 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 690, height: 829 }, pixelRatio: 2.625, system: 'Android 13', + // windowinsets.info/galaxy-z-fold5 (measured, gesture nav): RoundedCorner 25.9dp; safe top≈30 bottom≈15. + statusBarHeight: 30, + safeAreaInsets: { top: 30, bottom: 15 }, + safeAreaInsetsLandscape: { right: 30 }, + shell: { screenRadius: 25.9 }, releaseYear: 2023, }, { @@ -236,6 +305,7 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 914 }, pixelRatio: 2.625, system: 'Android 10', + // Infinity-O punch-hole on hardware. windowinsets.info/galaxy-a71 pending; a51 absent — cutout omitted. userAgent: 'Mozilla/5.0 (Linux; Android 10; SM-A515F Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Mobile Safari/537.36', releaseYear: 2019, @@ -258,6 +328,8 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 360, height: 640 }, pixelRatio: 3, system: 'Android 6.0', + // legacy flat panel radius 2; no WI capture — same honesty as Nexus 5/5X/6 + shell: { screenRadius: 2 }, userAgent: 'Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2016, @@ -268,6 +340,7 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 823 }, pixelRatio: 1.75, system: 'Android 11', + // 2021 punch-hole hardware; no windowinsets.info page — cutout omitted. releaseYear: 2021, }, { @@ -310,6 +383,8 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 360, height: 640 }, pixelRatio: 3, system: 'Android 4.3', + // legacy flat panel radius 2; no WI capture — same honesty as Nexus 5/5X/6 + shell: { screenRadius: 2 }, userAgent: 'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', releaseYear: 2013, @@ -321,6 +396,8 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 360, height: 640 }, pixelRatio: 2, system: 'Android 4.1', + // legacy flat panel radius 2; no WI capture — same honesty as Nexus 5/5X/6 + shell: { screenRadius: 2 }, userAgent: 'Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', releaseYear: 2012, @@ -332,6 +409,8 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 360, height: 640 }, pixelRatio: 2, system: 'Android 4.0', + // legacy flat panel radius 2; no WI capture — same honesty as Nexus 5/5X/6 + shell: { screenRadius: 2 }, userAgent: 'Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', releaseYear: 2012, @@ -343,6 +422,8 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 360, height: 640 }, pixelRatio: 3, system: 'Android 5.0', + // legacy flat panel radius 2; no WI capture — same honesty as Nexus 5/5X/6 + shell: { screenRadius: 2 }, userAgent: 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2014, @@ -365,6 +446,12 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 360, height: 780 }, pixelRatio: 3, system: 'Android 14', + // windowinsets.info/galaxy-s24 (measured, gesture nav): RoundedCorner 36dp; safe top≈34 bottom≈15. + statusBarHeight: 34, + safeAreaInsets: { top: 34, bottom: 15 }, + safeAreaInsetsLandscape: { right: 34 }, + shell: { screenRadius: 36 }, + cutout: { shape: 'circle', width: 20, height: 20, top: 7 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; SM-S921U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2024, @@ -377,6 +464,12 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 640, height: 1024 }, pixelRatio: 2.5, system: 'Android 14', + // windowinsets.info/galaxy-tab-s9 (measured, gesture nav): RoundedCorner 9.88dp; safe top≈30 bottom≈15. + // Window left legacy — kit 640×1024@2.5 ≠ WI main ~753×1205@2.125. + statusBarHeight: 30, + safeAreaInsets: { top: 30, bottom: 15 }, + safeAreaInsetsLandscape: { right: 30 }, + shell: { screenRadius: 9.88 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; SM-X710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Safari/537.36', releaseYear: 2023, @@ -388,6 +481,13 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 484, height: 1188 }, pixelRatio: 2, system: 'Android 14', + // windowinsets.info/galaxy-z-fold6 cover (measured, gesture nav): RoundedCorner 6.1dp; safe top≈36 bottom≈15; cutout circle 24@6. + // Window left legacy — kit 484×1188@2 ≠ WI cover ~369×905@2.625. + statusBarHeight: 36, + safeAreaInsets: { top: 36, bottom: 15 }, + safeAreaInsetsLandscape: { right: 36 }, + cutout: { shape: 'circle', width: 24, height: 24, top: 6 }, + shell: { screenRadius: 6.1 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; SM-F956U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2024, @@ -399,6 +499,12 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 984, height: 1092 }, pixelRatio: 2, system: 'Android 16', + // windowinsets.info/galaxy-z-fold7 main/inner (measured, gesture nav): RoundedCorner 4.95dp; safe top≈34 bottom≈15. Cover metrics live on Galaxy Z Fold 7 Cover. + // Window left legacy — kit 984×1092@2 ≠ WI main ~750×832@2.625. + statusBarHeight: 34, + safeAreaInsets: { top: 34, bottom: 15 }, + safeAreaInsetsLandscape: { right: 34 }, + shell: { screenRadius: 4.95 }, userAgent: 'Mozilla/5.0 (Linux; Android 16; SM-F966U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2025, @@ -410,6 +516,13 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 360, height: 840 }, pixelRatio: 3, system: 'Android 16', + // windowinsets.info/galaxy-z-fold7 cover (measured, gesture nav): RoundedCorner 4.95dp; safe top≈42 bottom≈15; cutout circle 23@8. + // Window left legacy — kit 360×840@3 ≠ WI cover ~411×960@2.625. + statusBarHeight: 42, + safeAreaInsets: { top: 42, bottom: 15 }, + safeAreaInsetsLandscape: { right: 42 }, + cutout: { shape: 'circle', width: 23, height: 23, top: 8 }, + shell: { screenRadius: 4.95 }, userAgent: 'Mozilla/5.0 (Linux; Android 16; SM-F966U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2025, @@ -421,6 +534,12 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 360, height: 880 }, pixelRatio: 3, system: 'Android 14', + // windowinsets.info/galaxy-z-flip6 (measured, gesture nav): RoundedCorner 38dp; safe top≈31 bottom≈15; cutout circle 24@4. + statusBarHeight: 31, + safeAreaInsets: { top: 31, bottom: 15 }, + safeAreaInsetsLandscape: { right: 31 }, + cutout: { shape: 'circle', width: 24, height: 24, top: 4 }, + shell: { screenRadius: 38 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; SM-F741U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2024, @@ -432,6 +551,8 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 360, height: 374 }, pixelRatio: 2, system: 'Android 14', + // STILL-GAP: Samsung cover has a lower cutout (same class as Flip5/7 Cover). WI Flip6 = main only — no cover + // capture. Do not copy Flip7 Cover pill 198×84 (different geometry). Wait for WI/RTL cover dump. userAgent: 'Mozilla/5.0 (Linux; Android 14; SM-F741U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2024, @@ -443,6 +564,12 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 360, height: 840 }, pixelRatio: 3, system: 'Android 16', + // windowinsets.info/galaxy-z-flip7 (measured, gesture nav): RoundedCorner 22dp; safe top≈36 bottom≈15; cutout circle 24@6. + statusBarHeight: 36, + safeAreaInsets: { top: 36, bottom: 15 }, + safeAreaInsetsLandscape: { right: 36 }, + cutout: { shape: 'circle', width: 24, height: 24, top: 6 }, + shell: { screenRadius: 22 }, userAgent: 'Mozilla/5.0 (Linux; Android 16; SM-F761U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2025, @@ -451,9 +578,17 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ name: 'Galaxy Z Flip 7 Cover', os: 'android', statusBarStyle: 'android-samsung', - screen: { width: 474, height: 524 }, - pixelRatio: 2, + // windowinsets.info/galaxy-z-flip7 cover (measured, gesture nav): logical 361.14×399.24@2.625 + // (same 948×1048 panel as prior kit 474×524@2; rewritten to WI dp). Asymmetric corners TL/TR 6.1 BR/BL 41.14; + // safe top≈0 bottom≈84; displayCutoutBounds ~198×84 at top≈315 (bottom camera bar) → pill cutout. + screen: { width: 361, height: 399 }, + pixelRatio: 2.625, system: 'Android 16', + statusBarHeight: 0, + safeAreaInsets: { top: 0, bottom: 84 }, + safeAreaInsetsLandscape: { right: 0 }, + cutout: { shape: 'pill', width: 198, height: 84, top: 315, centerX: 0.726 }, + shell: { screenRadius: 6.1, screenCorners: { topLeft: 6.1, topRight: 6.1, bottomRight: 41.14, bottomLeft: 41.14 } }, userAgent: 'Mozilla/5.0 (Linux; Android 16; SM-F761U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2025, @@ -464,6 +599,8 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 384, height: 640 }, pixelRatio: 1.25, system: 'Android 4.4', + // legacy flat panel radius 2; no WI capture — same honesty as Nexus 5/5X/6 + shell: { screenRadius: 2 }, userAgent: 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2014, @@ -475,6 +612,8 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 800, height: 1280 }, pixelRatio: 2, system: 'Android 5.1', + // legacy flat panel radius 2; no WI capture — same honesty as Nexus 5/5X/6 + shell: { screenRadius: 2 }, userAgent: 'Mozilla/5.0 (Linux; Android 5.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Safari/537.36', releaseYear: 2012, @@ -485,6 +624,8 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 384, height: 640 }, pixelRatio: 2, system: 'Android 4.4', + // legacy flat panel radius 2; no WI capture — same honesty as Nexus 5/5X/6 + shell: { screenRadius: 2 }, userAgent: 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2012, @@ -495,6 +636,8 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 732 }, pixelRatio: 3.5, system: 'Android 8.0', + // legacy flat panel radius 2; no WI capture — same honesty as Nexus 5/5X/6 + shell: { screenRadius: 2 }, userAgent: 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2015, @@ -506,6 +649,8 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 600, height: 960 }, pixelRatio: 2, system: 'Android 6.0', + // legacy flat panel radius 2; no WI capture — same honesty as Nexus 5/5X/6 + shell: { screenRadius: 2 }, userAgent: 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Safari/537.36', releaseYear: 2012, @@ -516,6 +661,8 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 411, height: 731 }, pixelRatio: 2.625, system: 'Android 8.0', + // OEM SystemUI sysui_rounded_size default 0 (Pixel 2 / XDA+SystemUI RoundedCorners); no WI capture + shell: { screenRadius: 0 }, userAgent: 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2017, @@ -526,6 +673,8 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 411, height: 823 }, pixelRatio: 3.5, system: 'Android 8.0', + // OEM SystemUI sysui_rounded_size default 26 (Pixel 2 XL / XDA+SystemUI RoundedCorners); no WI capture + shell: { screenRadius: 26 }, userAgent: 'Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2017, @@ -556,6 +705,7 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 892 }, pixelRatio: 2.63, system: 'Android 11', + // Hardware punch-hole (left-biased like Pixel 4a/5). WI has pixel-4a only, not 4a (5G) — cutout omitted until measured. userAgent: 'Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2020, @@ -566,6 +716,12 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 393, height: 851 }, pixelRatio: 2.75, system: 'Android 11', + // windowinsets.info/pixel-5 (emulator, gesture nav): safe top≈49 bottom≈24 (no RoundedCorner in capture); + // displayCutoutBounds 49.5×49.5@0 left=0 on ~393dp → circle Ø49 top0 centerX≈0.063 (left-edge hole; not centered). + statusBarHeight: 49, + safeAreaInsets: { top: 49, bottom: 24 }, + safeAreaInsetsLandscape: { right: 49 }, + cutout: { shape: 'circle', width: 49, height: 49, top: 0, centerX: 0.063 }, userAgent: 'Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2020, @@ -576,6 +732,12 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 915 }, pixelRatio: 2.625, system: 'Android 12', + // windowinsets.info/pixel-6 (emulator, gesture nav): RoundedCorner 10.67dp; safe top≈49 bottom≈24; cutout circle 45@2. + statusBarHeight: 49, + safeAreaInsets: { top: 49, bottom: 24 }, + safeAreaInsetsLandscape: { right: 49 }, + cutout: { shape: 'circle', width: 45, height: 45, top: 2 }, + shell: { screenRadius: 10.67 }, userAgent: 'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2021, @@ -586,6 +748,12 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 892 }, pixelRatio: 3.5, system: 'Android 12', + // windowinsets.info/pixel-6-pro (emulator, gesture nav): RoundedCorner 14.29dp; safe top≈41 bottom≈0; cutout circle 37@2. + statusBarHeight: 41, + safeAreaInsets: { top: 41, bottom: 0 }, + safeAreaInsetsLandscape: { right: 41 }, + cutout: { shape: 'circle', width: 37, height: 37, top: 2 }, + shell: { screenRadius: 14.29 }, userAgent: 'Mozilla/5.0 (Linux; Android 12; Pixel 6 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2021, @@ -596,6 +764,13 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 915 }, pixelRatio: 2.625, system: 'Android 12', + // windowinsets.info/pixel-6a (emulator, gesture nav): RoundedCorner topLeft 17.9dp (asymmetric BR 18.29); safe top≈50 bottom≈24; + // cutout circle 55@0 uses excl width (bounds 55.24×50.29 — not square; Ø may exceed excl H). WEAK-CITE; no AVD re-measure to shrink. + statusBarHeight: 50, + safeAreaInsets: { top: 50, bottom: 24 }, + safeAreaInsetsLandscape: { right: 50 }, + cutout: { shape: 'circle', width: 55, height: 55, top: 0 }, + shell: { screenRadius: 17.9 }, userAgent: 'Mozilla/5.0 (Linux; Android 12; Pixel 6a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2022, @@ -606,7 +781,11 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 892 }, pixelRatio: 3.5, system: 'Android 13', + // windowinsets.info/pixel-7-pro (emulator, gesture nav): RoundedCorner 14.14dp; safe top≈41 bottom≈24. statusBarHeight: 41, + safeAreaInsets: { top: 41, bottom: 24 }, + safeAreaInsetsLandscape: { right: 41 }, + shell: { screenRadius: 14.14 }, cutout: { shape: 'circle', width: 25, height: 25, top: 8 }, userAgent: 'Mozilla/5.0 (Linux; Android 13; Pixel 7 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', @@ -618,6 +797,13 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 915 }, pixelRatio: 2.625, system: 'Android 13', + // windowinsets.info/pixel-7a (emulator, gesture nav): RoundedCorner topLeft 17.9dp (asymmetric BR 18.29); safe top≈45 bottom≈24; + // cutout circle 55@0 uses excl width (bounds 55.24×44.95 — not square; Ø exceeds excl H). WEAK-CITE; no AVD re-measure to shrink. + statusBarHeight: 45, + safeAreaInsets: { top: 45, bottom: 24 }, + safeAreaInsetsLandscape: { right: 45 }, + cutout: { shape: 'circle', width: 55, height: 55, top: 0 }, + shell: { screenRadius: 17.9 }, userAgent: 'Mozilla/5.0 (Linux; Android 13; Pixel 7a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2023, @@ -628,10 +814,13 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 427, height: 952 }, pixelRatio: 3, system: 'Android 16', - statusBarHeight: 68, - safeAreaInsets: { top: 68 }, - safeAreaInsetsLandscape: { right: 68 }, - cutout: { shape: 'circle', width: 32, height: 32, top: 18 }, + // windowinsets.info/pixel-10-pro (emulator, gesture nav): RoundedCorner 52.33dp; safe top≈52 bottom≈24; + // displayCutoutBounds 36.33×52@0 (identical to pixel-9-pro) → circle Ø36 top8. + statusBarHeight: 52, + safeAreaInsets: { top: 52, bottom: 24 }, + safeAreaInsetsLandscape: { right: 52 }, + shell: { screenRadius: 52.33 }, + cutout: { shape: 'circle', width: 36, height: 36, top: 8 }, userAgent: 'Mozilla/5.0 (Linux; Android 16; Pixel 10 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2025, @@ -642,6 +831,12 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 448, height: 997 }, pixelRatio: 3, system: 'Android 16', + // windowinsets.info/pixel-10-pro-xl (emulator, gesture nav): RoundedCorner 51dp; safe top≈53 bottom≈24; cutout circle 38@8. + statusBarHeight: 53, + safeAreaInsets: { top: 53, bottom: 24 }, + safeAreaInsetsLandscape: { right: 53 }, + cutout: { shape: 'circle', width: 38, height: 38, top: 8 }, + shell: { screenRadius: 51 }, userAgent: 'Mozilla/5.0 (Linux; Android 16; Pixel 10 Pro XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2025, @@ -652,6 +847,14 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 393, height: 786 }, pixelRatio: 2.75, system: 'Android 11', + // DisplayCutout Rect(442,0–998,171) px on 1440-wide panel (SO): + // https://stackoverflow.com/questions/60541124/displaycutout-position + // Scale 1440→1080 for kit FHD 393×786@2.75 → notch ≈152×47 @ top 0 (centered). No WI page. + // Prefer later: native ~411×846@3.5 + notch ≈159×49 when window is fixed. + statusBarHeight: 47, + safeAreaInsets: { top: 47, bottom: 0 }, + safeAreaInsetsLandscape: { right: 47 }, + cutout: { shape: 'notch', width: 152, height: 47, top: 0 }, userAgent: 'Mozilla/5.0 (Linux; Android 11; Pixel 3 XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.181 Mobile Safari/537.36', releaseYear: 2018, @@ -662,6 +865,14 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 734, height: 1014 }, pixelRatio: 2.8, system: 'Android 13', + // windowinsets.info/pixel-fold cover: safe top≈42 bottom≈24; cutout circle 42@0 centered (HOLD Ø). + // MUST-FIX window: kit 734×1014@2.8 matches neither WI cover (~411×797) nor main (~841×701, bounds null / UDC). + // Painting cover Ø42 on this legacy window mis-sizes the hole. Fix = cover-only window or split Cover/Main rows + // (structural; not applied in cutout ADV merge — keep Ø from cover capture). + statusBarHeight: 42, + safeAreaInsets: { top: 42, bottom: 24 }, + safeAreaInsetsLandscape: { right: 42 }, + cutout: { shape: 'circle', width: 42, height: 42, top: 0 }, userAgent: 'Mozilla/5.0 (Linux; Android 13; Pixel Fold) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Mobile Safari/537.36', releaseYear: 2023, @@ -673,6 +884,11 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 800, height: 1280 }, pixelRatio: 2, system: 'Android 13', + // windowinsets.info/pixel-tablet (emulator, gesture nav): WI capture is landscape 1280×800@2; kit stores portrait 800×1280 (swap-equal). + // safe top≈24 bottom≈32 (no RoundedCorner in capture). + statusBarHeight: 24, + safeAreaInsets: { top: 24, bottom: 32 }, + safeAreaInsetsLandscape: { right: 24 }, userAgent: 'Mozilla/5.0 (Linux; Android 13; Pixel Tablet) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', releaseYear: 2023, @@ -683,6 +899,12 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 915 }, pixelRatio: 2.625, system: 'Android 15', + // windowinsets.info/pixel-9a (emulator, gesture nav): RoundedCorner 50.29dp; safe top≈54 bottom≈24; cutout circle 42@6. + statusBarHeight: 54, + safeAreaInsets: { top: 54, bottom: 24 }, + safeAreaInsetsLandscape: { right: 54 }, + cutout: { shape: 'circle', width: 42, height: 42, top: 6 }, + shell: { screenRadius: 50.29 }, userAgent: 'Mozilla/5.0 (Linux; Android 15; Pixel 9a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', releaseYear: 2025, @@ -703,6 +925,12 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 360, height: 800 }, pixelRatio: 3, system: 'Android 11', + // windowinsets.info/galaxy-s21 (measured, gesture nav): RoundedCorner 30dp; safe top≈27 bottom≈15; cutout circle 20@3. + statusBarHeight: 27, + safeAreaInsets: { top: 27, bottom: 15 }, + safeAreaInsetsLandscape: { right: 27 }, + cutout: { shape: 'circle', width: 20, height: 20, top: 3 }, + shell: { screenRadius: 30 }, releaseYear: 2021, }, { @@ -712,15 +940,26 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 384, height: 854 }, pixelRatio: 2.8125, system: 'Android 11', + // windowinsets.info/galaxy-s21-plus (measured, gesture nav): RoundedCorner 35.91dp; safe top≈27 bottom≈15; cutout circle 20@3. + statusBarHeight: 27, + safeAreaInsets: { top: 27, bottom: 15 }, + safeAreaInsetsLandscape: { right: 27 }, + cutout: { shape: 'circle', width: 20, height: 20, top: 3 }, + shell: { screenRadius: 35.91 }, releaseYear: 2021, }, { name: 'Galaxy S21 Ultra', os: 'android', statusBarStyle: 'android-samsung', - screen: { width: 412, height: 915 }, - pixelRatio: 2.625, + screen: { width: 384, height: 853 }, + pixelRatio: 2.8125, system: 'Android 11', + // windowinsets.info/galaxy-s21-ultra (measured, gesture nav): safe top≈27 bottom≈15 (no RoundedCorner in capture). + statusBarHeight: 27, + safeAreaInsets: { top: 27, bottom: 15 }, + safeAreaInsetsLandscape: { right: 27 }, + cutout: { shape: 'circle', width: 21, height: 21, top: 3 }, releaseYear: 2021, }, { @@ -730,6 +969,7 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 360, height: 800 }, pixelRatio: 3, system: 'Android 10', + // windowinsets.info/galaxy-s20: capture pending. Hardware has centered punch-hole — cutout omitted until measured. releaseYear: 2020, }, { @@ -737,17 +977,32 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ os: 'android', statusBarStyle: 'android-samsung', screen: { width: 384, height: 854 }, - pixelRatio: 3, + pixelRatio: 2.8125, system: 'Android 10', + // windowinsets.info/galaxy-s20-plus (measured, gesture nav): RoundedCorner 24.18dp; safe top≈26 bottom≈15. + // Published bounds=null (FHD/QHD left bug). Raw InsetsProbe boundingRects + // https://github.com/easyhooon/windowinsets.info/blob/main/measurements/galaxy-s/galaxy-s20-plus/rtl-2026-09-30/main-gesture.json + // 19.91×25.96@0 → circle Ø20 top3; omit centerX (hardware centered; ignore left=246). + statusBarHeight: 26, + safeAreaInsets: { top: 26, bottom: 15 }, + safeAreaInsetsLandscape: { right: 26 }, + shell: { screenRadius: 24.18 }, + cutout: { shape: 'circle', width: 20, height: 20, top: 3 }, releaseYear: 2020, }, { name: 'Samsung S21 FE', os: 'android', statusBarStyle: 'android-samsung', - screen: { width: 360, height: 800 }, + screen: { width: 360, height: 780 }, pixelRatio: 3, system: 'Android 13', + // windowinsets.info/galaxy-s21-fe (measured, gesture nav): RoundedCorner 40.33dp; safe top≈33 bottom≈15; cutout circle 23@5. + statusBarHeight: 33, + safeAreaInsets: { top: 33, bottom: 15 }, + safeAreaInsetsLandscape: { right: 33 }, + cutout: { shape: 'circle', width: 23, height: 23, top: 5 }, + shell: { screenRadius: 40.33 }, userAgent: 'Mozilla/5.0 (Linux; Android 13; SAMSUNG SM-G990B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/20.0 Chrome/106.0.5249.126 Mobile Safari/537.36', releaseYear: 2021, @@ -759,17 +1014,28 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 320, height: 872 }, pixelRatio: 3, system: 'Android 13', + // windowinsets.info/galaxy-z-fold3 (measured, gesture nav): safe top≈31 bottom≈15 (no RoundedCorner in capture). + statusBarHeight: 31, + safeAreaInsets: { top: 31, bottom: 15 }, + safeAreaInsetsLandscape: { right: 31 }, + cutout: { shape: 'circle', width: 24, height: 24, top: 4 }, userAgent: 'Mozilla/5.0 (Linux; Android 13; SM-F926U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.131 Mobile Safari/537.36', releaseYear: 2021, }, { + // Window left legacy vs WI Fold3 main ~674×841@2.625 — radius/safe only. name: 'Galaxy Fold3 (Unfolded)', os: 'android', statusBarStyle: 'android-samsung', screen: { width: 590, height: 736 }, pixelRatio: 3, system: 'Android 13', + // windowinsets.info/galaxy-z-fold3 (measured, gesture nav): RoundedCorner 20.19dp; safe top≈34 bottom≈64. + statusBarHeight: 34, + safeAreaInsets: { top: 34, bottom: 64 }, + safeAreaInsetsLandscape: { right: 34 }, + shell: { screenRadius: 20.19 }, userAgent: 'Mozilla/5.0 (Linux; Android 13; SM-F926U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.131 Mobile Safari/537.36', releaseYear: 2021, @@ -778,9 +1044,15 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ name: 'Galaxy S22', os: 'android', statusBarStyle: 'android-samsung', - screen: { width: 360, height: 800 }, + screen: { width: 360, height: 780 }, pixelRatio: 3, system: 'Android 13', + // windowinsets.info/galaxy-s22 (measured, gesture nav): RoundedCorner 34dp; safe top≈27 bottom≈15; cutout circle 19@4. + statusBarHeight: 27, + safeAreaInsets: { top: 27, bottom: 15 }, + safeAreaInsetsLandscape: { right: 27 }, + cutout: { shape: 'circle', width: 19, height: 19, top: 4 }, + shell: { screenRadius: 34 }, userAgent: 'Mozilla/5.0 (Linux; Android 13; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.36', releaseYear: 2022, @@ -789,9 +1061,15 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ name: 'Galaxy S22 Plus', os: 'android', statusBarStyle: 'android-samsung', - screen: { width: 384, height: 854 }, - pixelRatio: 3, + screen: { width: 384, height: 832 }, + pixelRatio: 2.8125, system: 'Android 13', + // windowinsets.info/galaxy-s22-plus (measured, gesture nav): RoundedCorner 35.91dp; safe top≈26 bottom≈15; cutout circle 18@4. + statusBarHeight: 26, + safeAreaInsets: { top: 26, bottom: 15 }, + safeAreaInsetsLandscape: { right: 26 }, + cutout: { shape: 'circle', width: 18, height: 18, top: 4 }, + shell: { screenRadius: 35.91 }, userAgent: 'Mozilla/5.0 (Linux; Android 13; SM-S906B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.36', releaseYear: 2022, @@ -800,9 +1078,15 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ name: 'Galaxy S22 Ultra', os: 'android', statusBarStyle: 'android-samsung', - screen: { width: 412, height: 915 }, - pixelRatio: 2.625, + screen: { width: 384, height: 823 }, + pixelRatio: 2.8125, system: 'Android 13', + // windowinsets.info/galaxy-s22-ultra (measured, gesture nav): RoundedCorner 35.91dp; safe top≈27 bottom≈15; cutout circle 20@3. + statusBarHeight: 27, + safeAreaInsets: { top: 27, bottom: 15 }, + safeAreaInsetsLandscape: { right: 27 }, + cutout: { shape: 'circle', width: 20, height: 20, top: 3 }, + shell: { screenRadius: 35.91 }, userAgent: 'Mozilla/5.0 (Linux; Android 13; SM-S908B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.36', releaseYear: 2022, @@ -811,9 +1095,15 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ name: 'Galaxy S23', os: 'android', statusBarStyle: 'android-samsung', - screen: { width: 360, height: 800 }, + screen: { width: 360, height: 780 }, pixelRatio: 3, system: 'Android 14', + // windowinsets.info/galaxy-s23 (measured, gesture nav): RoundedCorner 36dp; safe top≈27 bottom≈15; cutout circle 19@4. + statusBarHeight: 27, + safeAreaInsets: { top: 27, bottom: 15 }, + safeAreaInsetsLandscape: { right: 27 }, + cutout: { shape: 'circle', width: 19, height: 19, top: 4 }, + shell: { screenRadius: 36 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', releaseYear: 2023, @@ -822,9 +1112,15 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ name: 'Galaxy S23 Plus', os: 'android', statusBarStyle: 'android-samsung', - screen: { width: 384, height: 854 }, - pixelRatio: 3, + screen: { width: 384, height: 832 }, + pixelRatio: 2.8125, system: 'Android 14', + // windowinsets.info/galaxy-s23-plus (measured, gesture nav): RoundedCorner 35.91dp; safe top≈26 bottom≈15; cutout circle 18@4. + statusBarHeight: 26, + safeAreaInsets: { top: 26, bottom: 15 }, + safeAreaInsetsLandscape: { right: 26 }, + cutout: { shape: 'circle', width: 18, height: 18, top: 4 }, + shell: { screenRadius: 35.91 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; SM-S916B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', releaseYear: 2023, @@ -833,9 +1129,15 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ name: 'Galaxy S23 Ultra', os: 'android', statusBarStyle: 'android-samsung', - screen: { width: 412, height: 915 }, - pixelRatio: 2.625, + screen: { width: 384, height: 823 }, + pixelRatio: 2.8125, system: 'Android 14', + // windowinsets.info/galaxy-s23-ultra (measured, gesture nav): RoundedCorner 3.91dp; safe top≈33 bottom≈15; cutout circle 19@7. + statusBarHeight: 33, + safeAreaInsets: { top: 33, bottom: 15 }, + safeAreaInsetsLandscape: { right: 33 }, + cutout: { shape: 'circle', width: 19, height: 19, top: 7 }, + shell: { screenRadius: 3.91 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', releaseYear: 2023, @@ -844,20 +1146,36 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ name: 'Galaxy S24 Plus', os: 'android', statusBarStyle: 'android-samsung', - screen: { width: 384, height: 854 }, - pixelRatio: 3, + screen: { width: 384, height: 832 }, + pixelRatio: 2.8125, system: 'Android 14', + // windowinsets.info/galaxy-s24-plus (measured, gesture nav): RoundedCorner 35.91dp; safe top≈34 bottom≈15; cutout circle 18@8. + statusBarHeight: 34, + safeAreaInsets: { top: 34, bottom: 15 }, + safeAreaInsetsLandscape: { right: 34 }, + cutout: { shape: 'circle', width: 18, height: 18, top: 8 }, + shell: { screenRadius: 35.91 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; SM-S926B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', releaseYear: 2024, }, { + // Default FHD+ window (384×832 @2.8125 / densityDpi 450), not the panel's + // full WQHD+ logical size. Matches windowinsets.info / Android default zoom. + // Documented in packages/devices README (EN+ZH); WQHD+ via deviceProfile override. + // screenRadius 2.13 is near-sharp under FHD+ — screenshot QA should expect that. name: 'Galaxy S24 Ultra', os: 'android', statusBarStyle: 'android-samsung', - screen: { width: 412, height: 915 }, - pixelRatio: 2.625, + screen: { width: 384, height: 832 }, + pixelRatio: 2.8125, system: 'Android 14', + // windowinsets.info/galaxy-s24-ultra (measured, gesture nav): RoundedCorner 2.13dp; safe top≈34 bottom≈15. + statusBarHeight: 34, + safeAreaInsets: { top: 34, bottom: 15 }, + safeAreaInsetsLandscape: { right: 34 }, + shell: { screenRadius: 2.13 }, + cutout: { shape: 'circle', width: 18, height: 18, top: 8 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', releaseYear: 2024, @@ -869,6 +1187,13 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 264, height: 844 }, pixelRatio: 2.8, system: 'Android 13', + // windowinsets.info/galaxy-z-flip5 (measured, gesture nav): RoundedCorner 36dp; safe top≈31 bottom≈15; cutout circle 24@4. + // Window left legacy — kit 264×844@2.8 ≠ WI main 360×880@3. + statusBarHeight: 31, + safeAreaInsets: { top: 31, bottom: 15 }, + safeAreaInsetsLandscape: { right: 31 }, + cutout: { shape: 'circle', width: 24, height: 24, top: 4 }, + shell: { screenRadius: 36 }, userAgent: 'Mozilla/5.0 (Linux; Android 13; SM-F731B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.131 Mobile Safari/537.36', releaseYear: 2023, @@ -880,6 +1205,11 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 344, height: 882 }, pixelRatio: 2.8, system: 'Android 13', + // windowinsets.info/galaxy-z-fold4 (measured, gesture nav): safe top≈33 bottom≈15 (no RoundedCorner in capture). + statusBarHeight: 33, + safeAreaInsets: { top: 33, bottom: 15 }, + safeAreaInsetsLandscape: { right: 33 }, + cutout: { shape: 'circle', width: 26, height: 26, top: 4 }, userAgent: 'Mozilla/5.0 (Linux; Android 13; SM-F936B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.131 Mobile Safari/537.36', releaseYear: 2022, @@ -890,6 +1220,7 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 915 }, pixelRatio: 2.625, system: 'Android 14', + // Centered punch-hole on hardware; not in windowinsets.info (Samsung/Pixel only) — cutout omitted. userAgent: 'Mozilla/5.0 (Linux; Android 14; CPH2581) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', releaseYear: 2024, @@ -900,6 +1231,7 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 915 }, pixelRatio: 2.625, system: 'Android 13', + // Centered punch-hole on hardware; not in windowinsets.info — cutout omitted. userAgent: 'Mozilla/5.0 (Linux; Android 13; A065) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.36', releaseYear: 2023, @@ -911,6 +1243,7 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 412, height: 915 }, pixelRatio: 2.625, system: 'Android 14', + // Centered punch-hole on hardware; not in windowinsets.info — cutout omitted. userAgent: 'Mozilla/5.0 (Linux; Android 14; 23127PN0CC) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', releaseYear: 2024, @@ -921,6 +1254,7 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 264, height: 844 }, pixelRatio: 2.8, system: 'Android 13', + // Main/inner punch-hole on hardware (preset size matches flip-main class). No WI page — cutout omitted. userAgent: 'Mozilla/5.0 (Linux; Android 13; XT2321-1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.131 Mobile Safari/537.36', releaseYear: 2023, @@ -931,6 +1265,9 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ screen: { width: 734, height: 1014 }, pixelRatio: 2.8, system: 'Android 13', + // STILL-GAP: hardware has punch-hole on BOTH cover and main (not UDC; Find N3 twin). Kit single row uses + // legacy 734×1014 (matches neither official cover nor main). No WI/OEM DisplayCutout dp — cutout omitted + // until Cover+main rows exist with citeable bounds. Do not invent Ø. userAgent: 'Mozilla/5.0 (Linux; Android 13; CPH2551) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.131 Mobile Safari/537.36', releaseYear: 2023, diff --git a/packages/devices/src/presets/classic.ts b/packages/devices/src/presets/classic.ts index 7885efd..ebe69bb 100644 --- a/packages/devices/src/presets/classic.ts +++ b/packages/devices/src/presets/classic.ts @@ -29,11 +29,11 @@ export const CLASSIC_DEVICES: readonly PresetDeviceProfile[] = [ pick('iPhone 17 Pro'), pick('iPad Mini'), pick('iPad Pro 11'), - pick('Pixel 8'), + // Pixel 8 dropped from Classic until RoundedCorner is measured (no invented radius). pick('Pixel 9 Pro'), pick('Galaxy S24'), pick('Galaxy S24 Ultra'), - pick('Xiaomi 14'), + // Xiaomi 14 dropped from Classic until screenRadius/cutout/safe are measured. pick('Pixel Tablet'), pick('HUAWEI Mate 60 Pro'), pick('HUAWEI Mate 70'), diff --git a/packages/devices/src/presets/ios.ts b/packages/devices/src/presets/ios.ts index aee3439..edeecf7 100644 --- a/packages/devices/src/presets/ios.ts +++ b/packages/devices/src/presets/ios.ts @@ -13,6 +13,30 @@ const LEGACY_IPHONE_SHELL = { homeButton: { diameter: 30 }, } +/** + * Face ID notch families — cited radii only. + * + * - X / XS / 11 Pro: PaintCode bottom **20** + ear **topRadius: 6**. + * - XR / 11 / 12* / 13* / 14 / Plus / 16e / 17e: bottomRadius from bezel circular + * fit; **topRadius intentionally omitted** (no public per-gen ear dump). + * Paint falls back to `DEFAULT_NOTCH_TOP_RADIUS=6` — PaintCode-X **legacy + * only**, not a per-gen calibration. + * - Bboxes: safearea / DeviceHub where cited (XR/11 **227×32**; minis unlocked). + */ +const NOTCH_X = { shape: 'notch' as const, width: 209, height: 30, top: 0, bottomRadius: 20, topRadius: 6 } +const NOTCH_XS_MAX = { shape: 'notch' as const, width: 209, height: 30, top: 0, bottomRadius: 20, topRadius: 6 } +/** ear unset → DEFAULT_NOTCH_TOP_RADIUS legacy fallback (uncalibrated) */ +const NOTCH_XR = { shape: 'notch' as const, width: 227, height: 32, top: 0, bottomRadius: 21.5 } +const NOTCH_11_PRO = { shape: 'notch' as const, width: 209, height: 30, top: 0, bottomRadius: 20, topRadius: 6 } +/** ear unset → DEFAULT 6 legacy; not calibrated */ +const NOTCH_12 = { shape: 'notch' as const, width: 210, height: 32, top: 0, bottomRadius: 20 } +const NOTCH_12_MAX = { shape: 'notch' as const, width: 210, height: 32, top: 0, bottomRadius: 20 } +const NOTCH_12_MINI = { shape: 'notch' as const, width: 226, height: 34, top: 0, bottomRadius: 20 } +/** ear unset → DEFAULT 6 legacy; not calibrated (also 14 / Plus / 16e / 17e) */ +const NOTCH_13 = { shape: 'notch' as const, width: 162, height: 34, top: 0, bottomRadius: 21 } +const NOTCH_13_MAX = { shape: 'notch' as const, width: 161, height: 34, top: 0, bottomRadius: 21 } +const NOTCH_13_MINI = { shape: 'notch' as const, width: 174, height: 37, top: 0, bottomRadius: 21 } + /** iPhones and iPads. Part of DEVICES; listed here so a host can offer one platform alone. */ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ { @@ -39,7 +63,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 48, bottom: 34 }, safeAreaInsetsLandscape: { left: 48, right: 48, bottom: 21 }, - cutout: { shape: 'notch', width: 231, height: 33, top: 0 }, + cutout: NOTCH_XR, + shell: { screenRadius: 41.5 }, releaseYear: 2018, }, { @@ -53,7 +78,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 47, bottom: 34 }, safeAreaInsetsLandscape: { left: 47, right: 47, bottom: 21 }, - cutout: { shape: 'notch', width: 210, height: 32, top: 0 }, + cutout: NOTCH_12, + shell: { screenRadius: 47.33 }, releaseYear: 2020, }, { @@ -67,8 +93,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ 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 }, + cutout: NOTCH_13, + shell: { screenRadius: 47.33 }, releaseYear: 2022, }, { @@ -82,7 +108,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 47, bottom: 34 }, safeAreaInsetsLandscape: { left: 47, right: 47, bottom: 21 }, - cutout: { shape: 'notch', width: 161, height: 34, top: 0 }, + cutout: NOTCH_13_MAX, + shell: { screenRadius: 53.33 }, releaseYear: 2022, }, { @@ -110,6 +137,7 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ safeAreaInsets: { top: 59, bottom: 34 }, safeAreaInsetsLandscape: { left: 59, right: 59, bottom: 21 }, cutout: { shape: 'pill', width: 125, height: 37, top: 11 }, + shell: { screenRadius: 55 }, releaseYear: 2022, }, { @@ -122,6 +150,7 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ safeAreaInsets: { top: 59, bottom: 34 }, safeAreaInsetsLandscape: { left: 59, right: 59, bottom: 21 }, cutout: { shape: 'pill', width: 125, height: 37, top: 11 }, + shell: { screenRadius: 55 }, releaseYear: 2023, }, { @@ -136,6 +165,7 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ safeAreaInsets: { top: 59, bottom: 34 }, safeAreaInsetsLandscape: { left: 59, right: 59, bottom: 21 }, cutout: { shape: 'pill', width: 125, height: 37, top: 11 }, + shell: { screenRadius: 55 }, releaseYear: 2023, }, { @@ -148,6 +178,7 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ safeAreaInsets: { top: 59, bottom: 34 }, safeAreaInsetsLandscape: { left: 59, right: 59, bottom: 21 }, cutout: { shape: 'pill', width: 125, height: 37, top: 11 }, + shell: { screenRadius: 55 }, releaseYear: 2023, }, { @@ -162,6 +193,7 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ safeAreaInsets: { top: 59, bottom: 34 }, safeAreaInsetsLandscape: { left: 59, right: 59, bottom: 21 }, cutout: { shape: 'pill', width: 125, height: 37, top: 11 }, + shell: { screenRadius: 55 }, releaseYear: 2023, }, { @@ -175,7 +207,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 47, bottom: 34 }, safeAreaInsetsLandscape: { left: 47, right: 47, bottom: 21 }, - cutout: { shape: 'notch', width: 162, height: 34, top: 0 }, + cutout: NOTCH_13, + shell: { screenRadius: 47.33 }, releaseYear: 2025, }, { @@ -188,6 +221,7 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ safeAreaInsets: { top: 59, bottom: 34 }, safeAreaInsetsLandscape: { left: 59, right: 59, bottom: 21 }, cutout: { shape: 'pill', width: 125, height: 37, top: 11 }, + shell: { screenRadius: 55 }, releaseYear: 2024, }, { @@ -202,6 +236,7 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ safeAreaInsets: { top: 59, bottom: 34 }, safeAreaInsetsLandscape: { left: 59, right: 59, bottom: 21 }, cutout: { shape: 'pill', width: 125, height: 37, top: 11 }, + shell: { screenRadius: 55 }, releaseYear: 2024, }, { @@ -308,7 +343,7 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 44, bottom: 34 }, safeAreaInsetsLandscape: { left: 44, right: 44, bottom: 21 }, - cutout: { shape: 'notch', width: 209, height: 30, top: 0 }, + cutout: NOTCH_X, shell: { screenRadius: 39 }, releaseYear: 2017, }, @@ -323,7 +358,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 44, bottom: 34 }, safeAreaInsetsLandscape: { left: 44, right: 44, bottom: 21 }, - cutout: { shape: 'notch', width: 209, height: 30, top: 0 }, + cutout: NOTCH_XS_MAX, + shell: { screenRadius: 39 }, releaseYear: 2018, }, { @@ -337,7 +373,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 50, bottom: 34 }, safeAreaInsetsLandscape: { left: 50, right: 50, bottom: 21 }, - cutout: { shape: 'notch', width: 192, height: 30, top: 0 }, + cutout: NOTCH_13_MINI, + shell: { screenRadius: 44 }, releaseYear: 2020, }, { @@ -351,7 +388,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 47, bottom: 34 }, safeAreaInsetsLandscape: { left: 47, right: 47, bottom: 21 }, - cutout: { shape: 'notch', width: 162, height: 34, top: 0 }, + cutout: NOTCH_13, + shell: { screenRadius: 47.33 }, releaseYear: 2020, }, { @@ -365,7 +403,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 47, bottom: 34 }, safeAreaInsetsLandscape: { left: 47, right: 47, bottom: 21 }, - cutout: { shape: 'notch', width: 161, height: 34, top: 0 }, + cutout: NOTCH_13_MAX, + shell: { screenRadius: 53.33 }, releaseYear: 2020, }, { @@ -582,7 +621,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 48, bottom: 34 }, safeAreaInsetsLandscape: { left: 48, right: 48, bottom: 21 }, - cutout: { shape: 'notch', width: 231, height: 33, top: 0 }, + cutout: NOTCH_XR, + shell: { screenRadius: 41.5 }, releaseYear: 2019, }, { @@ -595,7 +635,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 44, bottom: 34 }, safeAreaInsetsLandscape: { left: 44, right: 44, bottom: 21 }, - cutout: { shape: 'notch', width: 209, height: 30, top: 0 }, + cutout: NOTCH_11_PRO, + shell: { screenRadius: 39 }, releaseYear: 2019, }, { @@ -609,7 +650,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 44, bottom: 34 }, safeAreaInsetsLandscape: { left: 44, right: 44, bottom: 21 }, - cutout: { shape: 'notch', width: 209, height: 30, top: 0 }, + cutout: NOTCH_11_PRO, + shell: { screenRadius: 39 }, releaseYear: 2019, }, { @@ -623,7 +665,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 47, bottom: 34 }, safeAreaInsetsLandscape: { left: 47, right: 47, bottom: 21 }, - cutout: { shape: 'notch', width: 210, height: 32, top: 0 }, + cutout: NOTCH_12, + shell: { screenRadius: 47.33 }, releaseYear: 2020, }, { @@ -637,7 +680,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 47, bottom: 34 }, safeAreaInsetsLandscape: { left: 47, right: 47, bottom: 21 }, - cutout: { shape: 'notch', width: 210, height: 32, top: 0 }, + cutout: NOTCH_12_MAX, + shell: { screenRadius: 53.33 }, releaseYear: 2020, }, { @@ -651,7 +695,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 50, bottom: 34 }, safeAreaInsetsLandscape: { left: 50, right: 50, bottom: 21 }, - cutout: { shape: 'notch', width: 192, height: 30, top: 0 }, + cutout: NOTCH_12_MINI, + shell: { screenRadius: 44 }, releaseYear: 2020, }, { @@ -665,7 +710,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 47, bottom: 34 }, safeAreaInsetsLandscape: { left: 47, right: 47, bottom: 21 }, - cutout: { shape: 'notch', width: 162, height: 34, top: 0 }, + cutout: NOTCH_13, + shell: { screenRadius: 47.33 }, releaseYear: 2021, }, { @@ -679,7 +725,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 47, bottom: 34 }, safeAreaInsetsLandscape: { left: 47, right: 47, bottom: 21 }, - cutout: { shape: 'notch', width: 162, height: 34, top: 0 }, + cutout: NOTCH_13, + shell: { screenRadius: 47.33 }, releaseYear: 2021, }, { @@ -693,7 +740,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 47, bottom: 34 }, safeAreaInsetsLandscape: { left: 47, right: 47, bottom: 21 }, - cutout: { shape: 'notch', width: 161, height: 34, top: 0 }, + cutout: NOTCH_13_MAX, + shell: { screenRadius: 53.33 }, releaseYear: 2021, }, { @@ -707,7 +755,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 50, bottom: 34 }, safeAreaInsetsLandscape: { left: 50, right: 50, bottom: 21 }, - cutout: { shape: 'notch', width: 192, height: 30, top: 0 }, + cutout: NOTCH_13_MINI, + shell: { screenRadius: 44 }, releaseYear: 2021, }, { @@ -720,6 +769,7 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ safeAreaInsets: { top: 62, bottom: 34 }, safeAreaInsetsLandscape: { left: 62, right: 62, bottom: 20 }, cutout: { shape: 'pill', width: 125, height: 37, top: 14 }, + shell: { screenRadius: 62 }, releaseYear: 2025, }, { @@ -732,6 +782,7 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ safeAreaInsets: { top: 68, bottom: 34 }, safeAreaInsetsLandscape: { left: 68, right: 68, bottom: 20 }, cutout: { shape: 'pill', width: 125, height: 37, top: 20 }, + shell: { screenRadius: 62 }, releaseYear: 2025, }, { @@ -757,6 +808,7 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ safeAreaInsets: { top: 62, bottom: 34 }, safeAreaInsetsLandscape: { left: 62, right: 62, bottom: 20 }, cutout: { shape: 'pill', width: 125, height: 37, top: 14 }, + shell: { screenRadius: 62 }, releaseYear: 2025, }, // Safe-area, Dynamic Island, and shell values follow the same-sized iPhone 17 Pro series; iOS 27 has not been measured in Xcode yet. @@ -786,13 +838,10 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ shell: { screenRadius: 62 }, 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). + // Layout points, safe area, and screen corners follow Xcode 27.1 DeviceHub / + // safearea.info measurements (not marketing panel÷3). Outer free-edge radius + // 59 continuous; hinge-side corners 8 (DeviceHub). Outer punch-hole stays + // idle Ø37 circle. Inner window is 669×951 @3. { name: 'iPhone Duo (outer)', os: 'ios', @@ -804,14 +853,15 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ statusBarHeightLandscape: 44, statusBarEdge: 'right', statusBarEdgeLandscape: 'right', - safeAreaInsets: { top: 12, right: 68, bottom: 12, left: 0 }, - safeAreaInsetsLandscape: { top: 12, right: 68, bottom: 12, left: 0 }, + safeAreaInsets: { top: 0, right: 84, bottom: 34, left: 0 }, + safeAreaInsetsLandscape: { top: 0, right: 84, bottom: 34, left: 0 }, 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 }, + screenRadius: 59, + // Hinge-side corners from DeviceHub / safearea.info (Xcode 27.1): 8. + screenCorners: { topLeft: 8, bottomLeft: 8 }, bezel: 6, bezelInsets: { left: 8 }, }, @@ -820,7 +870,7 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ { name: 'iPhone Duo (inner)', os: 'ios', - screen: { width: 626, height: 890 }, + screen: { width: 669, height: 951 }, pixelRatio: 3, formFactor: 'foldable', system: 'iOS 27.1', @@ -828,9 +878,10 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ statusBarHeightLandscape: 44, statusBarEdge: 'top', statusBarEdgeLandscape: 'right', - safeAreaInsets: { top: 68, bottom: 34 }, - safeAreaInsetsLandscape: { right: 68 }, - shell: { screenRadius: 38, bezel: 6 }, + safeAreaInsets: { top: 82, bottom: 34 }, + // Fully Open · Landscape · HT Off (Xcode 27.1 / safearea.info): right rail 84 + home indicator 34. + safeAreaInsetsLandscape: { top: 0, right: 84, bottom: 34, left: 0 }, + shell: { screenRadius: 59, bezel: 6 }, releaseYear: 2026, }, { @@ -844,7 +895,8 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ navigationBarHeightLandscape: 32, safeAreaInsets: { top: 47, bottom: 34 }, safeAreaInsetsLandscape: { left: 47, right: 47, bottom: 20 }, - cutout: { shape: 'notch', width: 162, height: 34, top: 0 }, + cutout: NOTCH_13, + shell: { screenRadius: 47.33 }, releaseYear: 2026, }, { @@ -909,6 +961,7 @@ export const IOS_DEVICES: readonly PresetDeviceProfile[] = [ safeAreaInsets: { top: 68, bottom: 34 }, safeAreaInsetsLandscape: { left: 68, right: 68, bottom: 20 }, cutout: { shape: 'pill', width: 125, height: 37, top: 20 }, + shell: { screenRadius: 62 }, releaseYear: 2025, }, { diff --git a/packages/devices/src/profile-validation.test.ts b/packages/devices/src/profile-validation.test.ts index 5ce33f2..29f1935 100644 --- a/packages/devices/src/profile-validation.test.ts +++ b/packages/devices/src/profile-validation.test.ts @@ -316,6 +316,21 @@ describe('assertDeviceProfile checks cutout when present', () => { 'deviceProfile.cutout.centerX', ) }) + + it('optional bottomRadius / topRadius are accepted when non-negative', () => { + expect(() => assertDeviceProfile({ + ...VALID, + cutout: { ...VALID_CUTOUT, bottomRadius: 21, topRadius: 6 }, + })).not.toThrow() + }) + + it('a negative cutout.bottomRadius is rejected', () => { + expectRejects( + { ...VALID, cutout: { ...VALID_CUTOUT, bottomRadius: -1 } }, + undefined, + 'deviceProfile.cutout.bottomRadius', + ) + }) }) describe('assertDeviceProfile checks shell when present', () => { diff --git a/packages/devices/src/validate.ts b/packages/devices/src/validate.ts index cb8b99d..0fd67ed 100644 --- a/packages/devices/src/validate.ts +++ b/packages/devices/src/validate.ts @@ -149,6 +149,12 @@ export function assertDeviceProfile(value: unknown, label = 'deviceProfile'): as if (cutout.centerX !== undefined) { expectFiniteBetween(`${label}.${field}.centerX`, cutout.centerX, 0, 1) } + if (cutout.bottomRadius !== undefined) { + expectFiniteAtLeast(`${label}.${field}.bottomRadius`, cutout.bottomRadius, 0, false) + } + if (cutout.topRadius !== undefined) { + expectFiniteAtLeast(`${label}.${field}.topRadius`, cutout.topRadius, 0, false) + } } if (value.shell !== undefined) { @@ -181,5 +187,8 @@ export function assertDeviceProfile(value: unknown, label = 'deviceProfile'): as 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) } + if (shell.cornerCurve !== undefined && shell.cornerCurve !== 'circular' && shell.cornerCurve !== 'continuous') { + throw new TypeError(`${label}.shell.cornerCurve must be one of "circular", "continuous", got ${JSON.stringify(shell.cornerCurve)}`) + } } } From 6d4a8bb03696fb4c0b122b962ffe1e5639f26ad7 Mon Sep 17 00:00:00 2001 From: Grok Bot Date: Fri, 30 Oct 2026 02:58:07 +0800 Subject: [PATCH 2/5] fix(frame): continuous chrome clip, circular Face ID notch, iOS-only HI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Screen + body Figma ξ≈0.6 clip-path (kill PNG white bleed / square chrome) - Face ID notch circular clip-path ears + bottom fillets (no ξ on notch) - Home indicator pill rendered for iOS presets only --- packages/frame/CHANGELOG.md | 13 + packages/frame/README.md | 14 + packages/frame/README.zh-CN.md | 14 + .../frame/src/continuous-corner-mask.test.ts | 197 +++++++ packages/frame/src/continuous-corner-mask.ts | 533 ++++++++++++++++++ packages/frame/src/cutout.test.ts | 71 ++- packages/frame/src/cutout.ts | 64 ++- packages/frame/src/device-frame.test.ts | 48 +- packages/frame/src/device-frame.ts | 56 +- packages/frame/src/figma-continuous-path.ts | 154 +++++ .../frame/src/figma-squircle-distribute.ts | 155 +++++ packages/frame/src/figma-squircle-draw.ts | 233 ++++++++ packages/frame/src/index.ts | 10 + .../frame/src/notch-continuous-path.test.ts | 157 ++++++ packages/frame/src/notch-continuous-path.ts | 134 +++++ packages/frame/src/reflect.ts | 19 + packages/frame/src/shell-radius.test.ts | 118 +++- .../frame/src/status-bar-hidden-clean.test.ts | 2 +- packages/frame/src/status-bar-styles.ts | 41 +- packages/frame/src/status-bar.ts | 52 +- packages/frame/src/styles-radius.test.ts | 24 +- packages/frame/src/styles.ts | 46 +- 22 files changed, 2104 insertions(+), 51 deletions(-) create mode 100644 packages/frame/src/continuous-corner-mask.test.ts create mode 100644 packages/frame/src/continuous-corner-mask.ts create mode 100644 packages/frame/src/figma-continuous-path.ts create mode 100644 packages/frame/src/figma-squircle-distribute.ts create mode 100644 packages/frame/src/figma-squircle-draw.ts create mode 100644 packages/frame/src/notch-continuous-path.test.ts create mode 100644 packages/frame/src/notch-continuous-path.ts diff --git a/packages/frame/CHANGELOG.md b/packages/frame/CHANGELOG.md index 47fc0a3..d0d965e 100644 --- a/packages/frame/CHANGELOG.md +++ b/packages/frame/CHANGELOG.md @@ -1,5 +1,18 @@ # @devicekit/frame +## Unreleased + +### Minor Changes + +- Screen continuous corners: Figma ξ≈0.6 `clip-path` on `.screen` + matching outer body chrome; Chromium `corner-shape: squircle` kept as progressive enhancement. Documented in the frame README. +- Face ID notch paint: circular `clip-path` ears + bottom fillets (`notch-continuous-path`); no Figma ξ on the notch. +- Home indicator: iOS-only — hidden for every non-iOS preset. + +### Patch Changes + +- Updated dependencies + - @devicekit/devices (screen-fidelity radii / Duo landscape / Android cutouts) + ## 0.4.1 ### Patch Changes diff --git a/packages/frame/README.md b/packages/frame/README.md index 3d71492..0474b0d 100644 --- a/packages/frame/README.md +++ b/packages/frame/README.md @@ -187,6 +187,8 @@ Resolved layout values are published on the element: | `--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-screen-corner-shape` | `squircle` when the profile uses `cornerCurve: continuous` (iOS default), otherwise `round`. Applied **only** to `.screen` — body / bezel stay circular. Native CSS `corner-shape` needs Chromium 139+; Safari / Firefox ignore it | +| `--device-screen-continuous-mask` | SVG `mask-image` approximating continuous corners for browsers without `corner-shape`. Set only for continuous profiles; consumed under `@supports not (corner-shape: squircle)` | | `--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 | @@ -203,6 +205,18 @@ Use these variables to change the frame appearance: | `--device-screen-background` | Empty screen and transparent status bar background | | `--device-status-bar-signal-image`, `--device-status-bar-wifi-image`, `--device-status-bar-battery-image` | Optional inherited mask-image overrides for a native host; the built-in project-owned paths remain the fallback | + +### Screen corner curve + +`cornerCurve` from `@devicekit/devices` is progressive enhancement on the **content clip** (`.screen`) only: + +1. Chromium 139+: `corner-shape: var(--device-screen-corner-shape)` draws a true squircle when the value is `squircle`. +2. Safari / Firefox / older engines: the same profiles publish `--device-screen-continuous-mask`, an SVG mask used only inside `@supports not (corner-shape: squircle)`. +3. Android and HarmonyOS stay `round` / circular — no continuous mask. +4. `:host([embedded]) .screen` clears radius, corner-shape, and mask. + +Do not treat body / bezel chrome as part of screen-fidelity acceptance. + If `--device-frame-border` changes the border width, set `--device-frame-border-width` to the same width so the radius calculation remains aligned. `` overrides the bottom safe-area inset. Setting `safeAreaInsets.bottom: 30` in `deviceProfile` does the same. Both update `metrics.safeAreaInsets.bottom` and `--device-safe-area-bottom`, and change the element's own bottom safe-area and home-indicator rendering. diff --git a/packages/frame/README.zh-CN.md b/packages/frame/README.zh-CN.md index 9e05101..fada589 100644 --- a/packages/frame/README.zh-CN.md +++ b/packages/frame/README.zh-CN.md @@ -372,12 +372,26 @@ frame 只帮这个槽让开状态栏。**横屏时它不处理左右安全区** | `--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-screen-corner-shape` | 配置 `cornerCurve: continuous`(iOS 默认)时为 `squircle`,否则 `round`。**只作用在 `.screen`**,机身 / bezel 仍是正圆。原生 CSS `corner-shape` 需 Chromium 139+;Safari / Firefox 会忽略 | +| `--device-screen-continuous-mask` | 给不支持 `corner-shape` 的浏览器用的 continuous 圆角 SVG `mask-image`;仅 continuous 机型会写出,并在 `@supports not (corner-shape: squircle)` 下生效 | | `--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` 仍然是这台机器自己的值,宿主画自己的外壳时照样读得到。 + +### 屏幕圆角曲率 + +`@devicekit/devices` 的 `cornerCurve` 是**屏幕内容裁切**(`.screen`)上的渐进增强: + +1. Chromium 139+:`corner-shape: var(--device-screen-corner-shape)` 在值为 `squircle` 时画真正的 continuous 圆角。 +2. Safari / Firefox / 旧引擎:同一批机型会发布 `--device-screen-continuous-mask`,只在 `@supports not (corner-shape: squircle)` 里当 SVG mask 用。 +3. 安卓与鸿蒙保持 `round` / 正圆,不写 continuous mask。 +4. `:host([embedded]) .screen` 会清掉圆角、corner-shape 和 mask。 + +屏幕保真验收不要拿机身 / bezel 外观打分。 + 外观也留了几个变量可以盖:`--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/continuous-corner-mask.test.ts b/packages/frame/src/continuous-corner-mask.test.ts new file mode 100644 index 0000000..f3173b0 --- /dev/null +++ b/packages/frame/src/continuous-corner-mask.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest' +import { + CONTINUOUS_SUPERELLIPSE_N, + applyContinuousScreenPaint, + continuousMidArcInsetRatio, + continuousRoundedRectPath, + continuousScreenMaskDataURL, + continuousScreenMaskImage, + continuousScreenClipPath, + continuousBodyCorners, + encodeAlphaMaskPng, + isInsideContinuousScreen, +} from './continuous-corner-mask.js' + +describe('continuousRoundedRectPath', () => { + it('returns a closed path that visits all four corners for a uniform radius', () => { + const d = continuousRoundedRectPath(100, 200, { topLeft: 20, topRight: 20, bottomRight: 20, bottomLeft: 20 }) + expect(d.startsWith('M ')).toBe(true) + expect(d.endsWith('Z')).toBe(true) + expect(d).toContain('L ') + }) + + it('degenerates zero-radius corners to a rectangular path', () => { + const d = continuousRoundedRectPath(100, 100, { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 }) + expect(d).toBe('M 0 0 L 100 0 L 100 100 L 0 100 L 0 0 Z') + }) + + it('clamps overlapping corner radii so the path stays inside the box', () => { + const d = continuousRoundedRectPath(40, 40, { topLeft: 30, topRight: 30, bottomRight: 30, bottomLeft: 30 }) + expect(d).toMatch(/M /) + expect(d).toContain('Z') + }) + + it('axis intercept equals the labeled radius on each edge (same footprint as circular)', () => { + const R = 62 + const w = 402 + const h = 874 + const d = continuousRoundedRectPath(w, h, { topLeft: R, topRight: R, bottomRight: R, bottomLeft: R }) + // Top edge starts at x=R and reaches the TR transition at x=w-R + expect(d.startsWith(`M ${R} 0`)).toBe(true) + expect(d).toContain(`L ${w - R} 0`) + // Right edge reaches BR at y=h-R + expect(d).toContain(`L ${w} ${h - R}`) + // Bottom edge reaches BL at x=R + expect(d).toContain(`L ${R} ${h}`) + // Left edge reaches TL at y=R + expect(d).toContain(`L 0 ${R}`) + }) + + it('Duo asymmetric corners keep distinct hinge vs free intercepts', () => { + const d = continuousRoundedRectPath(466, 678, { topLeft: 8, topRight: 59, bottomRight: 59, bottomLeft: 8 }) + expect(d.startsWith('M 8 0')).toBe(true) + expect(d).toContain('L 407 0') // 466-59 + expect(d).toContain('L 466 619') // 678-59 + expect(d).toContain('L 8 678') + expect(d).toContain('L 0 8') + }) +}) + +describe('continuousMidArcInsetRatio', () => { + it('continuous n=5 sits closer to the vertex than a circle (squarer mid-arc)', () => { + const circle = Math.SQRT2 - 1 // ≈0.414 + const continuous = continuousMidArcInsetRatio(CONTINUOUS_SUPERELLIPSE_N) + expect(continuous).toBeLessThan(circle - 0.05) + expect(continuous).toBeGreaterThan(0.15) + }) +}) + +describe('continuousScreenMaskImage', () => { + it('returns none for empty sizes', () => { + expect(continuousScreenMaskImage(0, 100, { topLeft: 10, topRight: 10, bottomRight: 10, bottomLeft: 10 })).toBe('none') + }) + + it('returns a data-URI SVG mask for a Duo-like asymmetric screen', () => { + const mask = continuousScreenMaskImage(466, 678, { topLeft: 8, topRight: 59, bottomRight: 59, bottomLeft: 8 }) + expect(mask.startsWith('url("data:image/svg+xml,')).toBe(true) + expect(mask).toContain('svg') + // Path must encode the free-side radius intercept, not a collapsed hinge-only shape. + expect(decodeURIComponent(mask)).toContain('L 407 0') + }) +}) + +describe('continuousScreenClipPath', () => { + it('returns CSS path() from Figma ξ smoothing (not circular polyline)', () => { + const clip = continuousScreenClipPath(402, 874, { topLeft: 62, topRight: 62, bottomRight: 62, bottomLeft: 62 }) + expect(clip.startsWith('path("')).toBe(true) + expect(clip).toContain('M ') + expect(clip).toMatch(/[cC]/) // cubic segments from Figma smoothing + expect(clip.endsWith('Z")')).toBe(true) + }) + + it('keeps Duo hinge/free asymmetry in the path budget', () => { + const clip = continuousScreenClipPath(466, 678, { topLeft: 8, topRight: 59, bottomRight: 59, bottomLeft: 8 }) + expect(clip.startsWith('path("')).toBe(true) + // Free-side corner uses a larger p than the hinge — path must mention both curves. + expect(clip).toMatch(/a 59\.0|a 8\.0/) + }) +}) + +describe('isInsideContinuousScreen', () => { + const corners = { topLeft: 50, topRight: 50, bottomRight: 50, bottomLeft: 50 } + + it('keeps axis intercept at labeled R (top-row content starts at x=R)', () => { + expect(isInsideContinuousScreen(49, 0, 200, 200, corners)).toBe(false) + expect(isInsideContinuousScreen(50, 0, 200, 200, corners)).toBe(true) + }) + + it('is squarer mid-arc than a circle of the same R', () => { + // Circle mid at 45° sits at (R - R/√2) ≈ 14.64 from vertex. + // Continuous n=5 mid sits closer to the vertex (~6.5). + expect(isInsideContinuousScreen(10, 10, 200, 200, corners)).toBe(true) + expect(isInsideContinuousScreen(3, 3, 200, 200, corners)).toBe(false) + }) + + it('honors Duo hinge asymmetry', () => { + const duo = { topLeft: 8, topRight: 59, bottomRight: 59, bottomLeft: 8 } + expect(isInsideContinuousScreen(7, 0, 466, 678, duo)).toBe(false) + expect(isInsideContinuousScreen(8, 0, 466, 678, duo)).toBe(true) + expect(isInsideContinuousScreen(465, 0, 466, 678, duo)).toBe(false) + expect(isInsideContinuousScreen(407, 0, 466, 678, duo)).toBe(true) + }) +}) + +describe('continuousScreenMaskDataURL (authoritative paint)', () => { + it('returns none for empty sizes', () => { + expect(continuousScreenMaskDataURL(0, 100, { topLeft: 10, topRight: 10, bottomRight: 10, bottomLeft: 10 })).toBe('none') + }) + + it('returns a PNG data URL whose alpha matches isInsideContinuousScreen intercepts', () => { + const corners = { topLeft: 20, topRight: 20, bottomRight: 20, bottomLeft: 20 } + const mask = continuousScreenMaskDataURL(60, 80, corners) + expect(mask.startsWith('url("data:image/png;base64,')).toBe(true) + // Axis intercept at labeled R + expect(isInsideContinuousScreen(19, 0, 60, 80, corners)).toBe(false) + expect(isInsideContinuousScreen(20, 0, 60, 80, corners)).toBe(true) + }) + + it('encodeAlphaMaskPng emits a valid PNG signature', () => { + const alpha = new Uint8Array(4) + alpha[0] = 255 + const png = encodeAlphaMaskPng(2, 2, alpha) + expect([...png.slice(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]) + }) +}) + +describe('applyContinuousScreenPaint', () => { + it('never leaves CSS radius at 0 when clip is none', () => { + const host = document.createElement('div') + const screen = document.createElement('div') + const body = document.createElement('div') + host.style.setProperty('--device-screen-css-border-radius', '0') + const mode = applyContinuousScreenPaint({ screen, body, hostStyle: host.style, clip: 'none' }) + expect(mode).toBe('circular-fallback') + expect(host.style.getPropertyValue('--device-screen-css-border-radius')).not.toBe('0') + expect(host.style.getPropertyValue('--device-screen-css-border-radius')).toBe('') + }) + + it('applies hard clip-path and clears mask to avoid alpha bleed', () => { + const host = document.createElement('div') + const screen = document.createElement('div') + const body = document.createElement('div') + const clip = continuousScreenClipPath(40, 60, { topLeft: 10, topRight: 10, bottomRight: 10, bottomLeft: 10 }) + const mode = applyContinuousScreenPaint({ screen, body, hostStyle: host.style, clip, bodyClip: clip }) + expect(mode).toBe('continuous-clip') + expect(screen.style.clipPath).toBe(clip) + expect(screen.style.getPropertyValue('mask-image')).toBe('none') + expect(host.style.getPropertyValue('--device-body-overflow')).toBe('hidden') + }) +}) + +describe('continuousBodyCorners', () => { + it('expands each corner by max adjacent bezel + border + underlap', () => { + const body = continuousBodyCorners( + { topLeft: 59, topRight: 59, bottomRight: 59, bottomLeft: 59 }, + { top: 6, right: 6, bottom: 6, left: 6 }, + 1, + 2, + ) + expect(body).toEqual({ + topLeft: 59 + 6 + 1 + 2, + topRight: 59 + 6 + 1 + 2, + bottomRight: 59 + 6 + 1 + 2, + bottomLeft: 59 + 6 + 1 + 2, + }) + }) + + it('Duo outer hinge uses left inset for TL/BL expansion', () => { + const body = continuousBodyCorners( + { topLeft: 8, topRight: 59, bottomRight: 59, bottomLeft: 8 }, + { top: 6, right: 6, bottom: 6, left: 8 }, + 1, + 2, + ) + expect(body.topLeft).toBe(8 + 8 + 1 + 2) + expect(body.topRight).toBe(59 + 6 + 1 + 2) + }) +}) diff --git a/packages/frame/src/continuous-corner-mask.ts b/packages/frame/src/continuous-corner-mask.ts new file mode 100644 index 0000000..1af51f0 --- /dev/null +++ b/packages/frame/src/continuous-corner-mask.ts @@ -0,0 +1,533 @@ +/** + * Continuous (Apple-like) screen-corner clip. + * + * Authoritative paint: inline CSS `clip-path: path("…")` built from Figma + * corner-smoothing (ξ≈0.6 → iOS-like), via {@link figmaContinuousClipPath}. + * Hard clip gives engine edge AA without PNG-mask alpha fringes (white bleed + * against dark chrome — user Duo edge bug). + * + * What does NOT drive paint: + * - Chromium `corner-shape: squircle` → wrong family / unreliable intercept + * - PNG / SVG `mask-image` → semi-transparent fringe bleed + * - `mask-image: var(--data-uri)` → Chromium no-op + * - n=5 Lamé superellipse polyline (kept for diagnostics / tests only) + * + * Body: zero circular `border-radius`, continuous outer `clip-path` at + * screenCorners + bezel + border (+ underlap), `overflow: hidden`. Outer chrome + * follows the screen silhouette (aesthetics) while staying outside the screen + * curve so dark bezel underlaps screen AA (no white page bleed). Round2 ate + * free corners via circular body radius + overflow — avoided. No side buttons. + * + * Fallback: if path is empty/`none`, keep labeled circular CSS radii. + */ +import type { CornerRadii } from '@devicekit/devices' +import { figmaContinuousClipPath, figmaContinuousRoundedRectPath, FIGMA_IOS_CORNER_SMOOTHING } from './figma-continuous-path.js' +export { FIGMA_IOS_CORNER_SMOOTHING, figmaContinuousClipPath, figmaContinuousRoundedRectPath } from './figma-continuous-path.js' + +/** Apple continuous screens match a superellipse near n=5 (approximation, not CoreAnimation). */ +export const CONTINUOUS_SUPERELLIPSE_N = 5 + +/** Subsamples per axis for mask-edge coverage AA (16 probes per pixel). */ +export const MASK_AA_SUBSAMPLES = 4 + +/** Samples per quarter-corner for deprecated path helpers / tests (not used for paint). */ +const SAMPLES_PER_CORNER = 16 + +function clampCorners(width: number, height: number, corners: CornerRadii): CornerRadii { + let { topLeft: tl, topRight: tr, bottomRight: br, bottomLeft: bl } = corners + tl = Math.max(0, tl) + tr = Math.max(0, tr) + br = Math.max(0, br) + bl = Math.max(0, bl) + + const scale = (a: number, b: number, limit: number): [number, number] => { + if (a + b <= limit || a + b === 0) return [a, b] + const s = limit / (a + b) + return [a * s, b * s] + } + ;[tl, tr] = scale(tl, tr, width) + ;[bl, br] = scale(bl, br, width) + ;[tl, bl] = scale(tl, bl, height) + ;[tr, br] = scale(tr, br, height) + return { topLeft: tl, topRight: tr, bottomRight: br, bottomLeft: bl } +} + +function fmt(n: number): string { + return String(Math.round(n * 1000) / 1000) +} + +function sampleSuperellipseQuarter(radius: number, n: number, samples: number): Array<{ x: number, y: number }> { + const pts: Array<{ x: number, y: number }> = [] + const exp = 2 / n + for (let i = 0; i <= samples; i++) { + const theta = (Math.PI / 2) * (1 - i / samples) + const c = Math.cos(theta) + const s = Math.sin(theta) + pts.push({ + x: radius * Math.pow(Math.abs(c), exp) * (c === 0 ? 0 : Math.sign(c)), + y: radius * Math.pow(Math.abs(s), exp) * (s === 0 ? 0 : Math.sign(s)), + }) + } + return pts +} + +function lineThrough(points: Array<{ x: number, y: number }>): string { + if (points.length === 0) return '' + return points.map(p => `L ${fmt(p.x)} ${fmt(p.y)}`).join(' ') +} + +/** + * SVG path for a continuous rounded rect (tests / diagnostics only). Prefer + * {@link continuousScreenMaskDataURL} for actual painting. + */ +export function continuousRoundedRectPath( + width: number, + height: number, + corners: CornerRadii, + n = CONTINUOUS_SUPERELLIPSE_N, +): string { + if (width <= 0 || height <= 0) return '' + const { topLeft: tl, topRight: tr, bottomRight: br, bottomLeft: bl } = clampCorners(width, height, corners) + + const parts: string[] = [`M ${fmt(tl)} 0`] + parts.push(`L ${fmt(width - tr)} 0`) + if (tr > 0.001) { + const local = sampleSuperellipseQuarter(tr, n, SAMPLES_PER_CORNER) + const mapped = local.slice(1).map(p => ({ x: width - tr + p.x, y: tr - p.y })) + parts.push(lineThrough(mapped)) + } + parts.push(`L ${fmt(width)} ${fmt(height - br)}`) + if (br > 0.001) { + const local = sampleSuperellipseQuarter(br, n, SAMPLES_PER_CORNER) + const mapped = local.slice(1).map(p => ({ x: width - br + p.y, y: height - br + p.x })) + parts.push(lineThrough(mapped)) + } + parts.push(`L ${fmt(bl)} ${fmt(height)}`) + if (bl > 0.001) { + const local = sampleSuperellipseQuarter(bl, n, SAMPLES_PER_CORNER) + const mapped = local.slice(1).map(p => ({ x: bl - p.x, y: height - bl + p.y })) + parts.push(lineThrough(mapped)) + } + parts.push(`L 0 ${fmt(tl)}`) + if (tl > 0.001) { + const local = sampleSuperellipseQuarter(tl, n, SAMPLES_PER_CORNER) + const mapped = local.slice(1).map(p => ({ x: tl - p.y, y: tl - p.x })) + parts.push(lineThrough(mapped)) + } + parts.push('Z') + return parts.join(' ') +} + +export function continuousMidArcInsetRatio(n = CONTINUOUS_SUPERELLIPSE_N): number { + const theta = Math.PI / 4 + const exp = 2 / n + const lx = Math.pow(Math.cos(theta), exp) + const ly = Math.pow(Math.sin(theta), exp) + return Math.hypot(1 - lx, 1 - ly) +} + +/** + * True if (x,y) is inside a continuous rounded rect (analytical superellipse + * corners). Integer pixel coords; used to paint the mask and by unit tests. + */ +export function isInsideContinuousScreen( + x: number, + y: number, + width: number, + height: number, + corners: CornerRadii, + n = CONTINUOUS_SUPERELLIPSE_N, +): boolean { + const { topLeft: tl, topRight: tr, bottomRight: br, bottomLeft: bl } = clampCorners(width, height, corners) + if (x < 0 || y < 0 || x >= width || y >= height) return false + + const inCorner = (R: number, dx: number, dy: number): boolean => { + if (R <= 0) return true + if (dx < 0 || dy < 0 || dx > R || dy > R) return true + return Math.pow(dx, n) + Math.pow(dy, n) <= Math.pow(R, n) + } + + if (x < tl && y < tl && !inCorner(tl, tl - x, tl - y)) return false + if (x > width - tr && y < tr && !inCorner(tr, x - (width - tr), tr - y)) return false + if (x > width - br && y > height - br && !inCorner(br, x - (width - br), y - (height - br))) return false + if (x < bl && y > height - bl && !inCorner(bl, bl - x, y - (height - bl))) return false + return true +} + +const maskCache = new Map() + +function maskCacheKey(width: number, height: number, corners: CornerRadii, n: number): string { + const c = clampCorners(width, height, corners) + return `${width}x${height}:${c.topLeft},${c.topRight},${c.bottomRight},${c.bottomLeft}:n${n}:aa${MASK_AA_SUBSAMPLES}` +} + +const PNG_SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]) + +const CRC_TABLE = (() => { + const table = new Uint32Array(256) + for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1) + table[n] = c >>> 0 + } + return table +})() + +function crc32(buf: Uint8Array): number { + let c = 0xffffffff + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]!) & 0xff]! ^ (c >>> 8) + return (c ^ 0xffffffff) >>> 0 +} + +function u32be(n: number): Uint8Array { + return new Uint8Array([(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255]) +} + +function concatBytes(parts: Uint8Array[]): Uint8Array { + let len = 0 + for (const part of parts) len += part.length + const out = new Uint8Array(len) + let offset = 0 + for (const part of parts) { + out.set(part, offset) + offset += part.length + } + return out +} + +function pngChunk(type: string, data: Uint8Array): Uint8Array { + const typeBytes = new Uint8Array([ + type.charCodeAt(0), + type.charCodeAt(1), + type.charCodeAt(2), + type.charCodeAt(3), + ]) + const crcInput = concatBytes([typeBytes, data]) + return concatBytes([u32be(data.length), typeBytes, data, u32be(crc32(crcInput))]) +} + +function adler32(data: Uint8Array): number { + let a = 1 + let b = 0 + for (let i = 0; i < data.length; i++) { + a = (a + data[i]!) % 65521 + b = (b + a) % 65521 + } + return ((b << 16) | a) >>> 0 +} + +/** zlib wrapper around stored (uncompressed) DEFLATE blocks — no canvas / CompressionStream. */ +function zlibStore(uncompressed: Uint8Array): Uint8Array { + // 0x78 0x01: CM=8, CINFO=7, FCHECK so (CMF*256+FLG) % 31 === 0 + const parts: Uint8Array[] = [new Uint8Array([0x78, 0x01])] + const max = 65535 + if (uncompressed.length === 0) { + parts.push(new Uint8Array([0x01, 0x00, 0x00, 0xff, 0xff])) + } + else { + let offset = 0 + while (offset < uncompressed.length) { + const end = Math.min(offset + max, uncompressed.length) + const slice = uncompressed.subarray(offset, end) + const isFinal = end === uncompressed.length + const header = new Uint8Array(5) + header[0] = isFinal ? 0x01 : 0x00 + header[1] = slice.length & 255 + header[2] = (slice.length >>> 8) & 255 + const nlen = (~slice.length) & 0xffff + header[3] = nlen & 255 + header[4] = (nlen >>> 8) & 255 + parts.push(header, slice) + offset = end + } + } + parts.push(u32be(adler32(uncompressed))) + return concatBytes(parts) +} + +function bytesToBase64(bytes: Uint8Array): string { + const g = globalThis as { + Buffer?: { from: (bytes: Uint8Array) => { toString: (enc: string) => string } } + btoa?: (data: string) => string + } + if (typeof g.Buffer !== 'undefined') return g.Buffer.from(bytes).toString('base64') + let binary = '' + // Chunk to avoid call-stack / argument limits on large masks. + const chunk = 0x8000 + for (let i = 0; i < bytes.length; i += chunk) { + const slice = bytes.subarray(i, Math.min(i + chunk, bytes.length)) + binary += String.fromCharCode(...slice) + } + if (typeof g.btoa !== 'function') throw new Error('no base64 encoder available') + return g.btoa(binary) +} + +/** Prefer Node zlib when available (vitest / SSR); else stored DEFLATE. */ +function compressZlib(data: Uint8Array): Uint8Array { + try { + // Avoid a static `node:zlib` import so browser bundles stay clean. + const proc = (globalThis as { process?: { versions?: { node?: string }, getBuiltinModule?: (name: string) => unknown } }).process + const getBuiltin = proc?.getBuiltinModule + if (typeof getBuiltin === 'function') { + const zlib = getBuiltin('node:zlib') as { deflateSync?: (data: Uint8Array) => Uint8Array } + if (typeof zlib?.deflateSync === 'function') return zlib.deflateSync(data) + } + } + catch { + // fall through to store encoding + } + return zlibStore(data) +} + +/** Encode white RGB + alpha mask as a PNG byte stream (no DOM canvas). */ +export function encodeAlphaMaskPng(width: number, height: number, alpha: Uint8Array): Uint8Array { + if (alpha.length < width * height) throw new Error('alpha buffer too short') + const stride = width * 4 + 1 + const raw = new Uint8Array(stride * height) + for (let y = 0; y < height; y++) { + const row = y * stride + raw[row] = 0 + for (let x = 0; x < width; x++) { + const i = row + 1 + x * 4 + const a = alpha[y * width + x]! + raw[i] = 255 + raw[i + 1] = 255 + raw[i + 2] = 255 + raw[i + 3] = a + } + } + const ihdr = concatBytes([ + u32be(width), + u32be(height), + new Uint8Array([8, 6, 0, 0, 0]), + ]) + return concatBytes([ + PNG_SIGNATURE, + pngChunk('IHDR', ihdr), + pngChunk('IDAT', compressZlib(raw)), + pngChunk('IEND', new Uint8Array(0)), + ]) +} + +/** + * Analytical alpha mask as a CSS `mask-image` value (`url("data:image/png;...")`). + * Built with a pure PNG encoder (no canvas). Returns `'none'` only for invalid sizes. + */ +/** + * Coverage (0..1) of pixel square [x,x+1)×[y,y+1) under the continuous shape. + * Axis intercept stays at labeled R: fully outside for x+1 ≤ R on the top edge + * when sampling the corner tile, fully inside once the pixel lies past R. + */ +function continuousPixelCoverage( + x: number, + y: number, + width: number, + height: number, + corners: CornerRadii, + n: number, +): number { + const subs = MASK_AA_SUBSAMPLES + let inside = 0 + const denom = subs * subs + for (let sy = 0; sy < subs; sy++) { + for (let sx = 0; sx < subs; sx++) { + const px = x + (sx + 0.5) / subs + const py = y + (sy + 0.5) / subs + if (isInsideContinuousScreen(px, py, width, height, corners, n)) inside++ + } + } + return inside / denom +} + +function buildContinuousAlphaMask( + w: number, + h: number, + corners: CornerRadii, + n: number, +): Uint8Array { + const clamped = clampCorners(w, h, corners) + const alpha = new Uint8Array(w * h) + alpha.fill(255) + // Soft-AA only the four corner tiles; the center rect stays fully opaque. + // Expand the punch by 1px so AA fringes at the tile boundary are covered. + const punch = (x0: number, y0: number, x1: number, y1: number): void => { + const left = Math.max(0, Math.floor(x0) - 1) + const top = Math.max(0, Math.floor(y0) - 1) + const right = Math.min(w, Math.ceil(x1) + 1) + const bottom = Math.min(h, Math.ceil(y1) + 1) + for (let y = top; y < bottom; y++) { + for (let x = left; x < right; x++) { + const cover = continuousPixelCoverage(x, y, w, h, clamped, n) + alpha[y * w + x] = cover >= 1 ? 255 : cover <= 0 ? 0 : Math.round(255 * cover) + } + } + } + const { topLeft: tl, topRight: tr, bottomRight: br, bottomLeft: bl } = clamped + punch(0, 0, tl, tl) + punch(w - tr, 0, w, tr) + punch(w - br, h - br, w, h) + punch(0, h - bl, bl, h) + return alpha +} + +export function continuousScreenMaskDataURL( + width: number, + height: number, + corners: CornerRadii, + n = CONTINUOUS_SUPERELLIPSE_N, +): string { + const w = Math.round(width) + const h = Math.round(height) + if (w <= 0 || h <= 0) return 'none' + + const key = maskCacheKey(w, h, corners, n) + const cached = maskCache.get(key) + if (cached) return cached + + const alpha = buildContinuousAlphaMask(w, h, corners, n) + const png = encodeAlphaMaskPng(w, h, alpha) + const url = `url("data:image/png;base64,${bytesToBase64(png)}")` + maskCache.set(key, url) + return url +} + +export type ContinuousPaintMode = 'continuous-clip' | 'circular-fallback' + +function clearElementClipAndMask(el: HTMLElement | null): void { + if (!el) return + el.style.removeProperty('clip-path') + el.style.removeProperty('-webkit-clip-path') + el.style.removeProperty('mask-image') + el.style.removeProperty('-webkit-mask-image') + el.style.removeProperty('mask-size') + el.style.removeProperty('-webkit-mask-size') + el.style.removeProperty('mask-mode') + el.style.removeProperty('-webkit-mask-source-type') + el.style.removeProperty('overflow') + el.style.removeProperty('isolation') + el.style.removeProperty('box-shadow') +} + +/** + * Apply continuous hard clip (Figma path), or fall back to circular CSS radii. + * + * Gate: only zero CSS radii when `clip` is a real `path(...)`. + * `bodyClip` is the outer continuous silhouette (screenCorners + bezel + border + * + underlap) so black chrome stays rounded and underlaps screen AA. + */ +export function applyContinuousScreenPaint(options: { + screen: HTMLElement + body: HTMLElement | null + hostStyle: CSSStyleDeclaration + /** @deprecated PNG mask ignored for paint — use `clip`. */ + mask?: string + clip?: string + /** Outer continuous clip for `.body` (bezel aesthetics). */ + bodyClip?: string + /** @deprecated use bodyClip */ + bodyMask?: string +}): ContinuousPaintMode { + const { screen, body, hostStyle } = options + const clip = options.clip + ?? (options.mask && options.mask.startsWith('path(') ? options.mask : undefined) + const bodyClip = options.bodyClip + ?? (options.bodyMask && options.bodyMask.startsWith('path(') ? options.bodyMask : undefined) + const ok = typeof clip === 'string' && clip !== 'none' && clip.startsWith('path(') + + if (!ok) { + clearElementClipAndMask(screen) + clearElementClipAndMask(body) + hostStyle.removeProperty('--device-screen-css-border-radius') + hostStyle.removeProperty('--device-body-css-border-radius') + hostStyle.removeProperty('--device-body-overflow') + hostStyle.setProperty('--device-screen-clip-path', 'none') + hostStyle.setProperty('--device-screen-continuous-mask', 'none') + return 'circular-fallback' + } + + // Hard clip — clear any mask so alpha fringes cannot reappear. + screen.style.setProperty('mask-image', 'none') + screen.style.setProperty('-webkit-mask-image', 'none') + screen.style.clipPath = clip + screen.style.setProperty('-webkit-clip-path', clip) + screen.style.setProperty('isolation', 'isolate') + hostStyle.setProperty('--device-screen-css-border-radius', '0') + hostStyle.setProperty('--device-body-css-border-radius', '0') + hostStyle.setProperty('--device-body-overflow', 'hidden') + hostStyle.setProperty('--device-screen-clip-path', 'none') + hostStyle.setProperty('--device-screen-continuous-mask', 'none') + + const bodyOk = typeof bodyClip === 'string' && bodyClip !== 'none' && bodyClip.startsWith('path(') + if (body && bodyOk) { + body.style.setProperty('mask-image', 'none') + body.style.setProperty('-webkit-mask-image', 'none') + body.style.clipPath = bodyClip + body.style.setProperty('-webkit-clip-path', bodyClip) + body.style.overflow = 'hidden' + } + else if (body) { + clearElementClipAndMask(body) + body.style.overflow = 'hidden' + } + return 'continuous-clip' +} + +/** Clear continuous paint (Android / embedded / non-continuous). */ +export function clearContinuousScreenPaint(options: { + screen: HTMLElement + body: HTMLElement | null + hostStyle: CSSStyleDeclaration +}): void { + const { screen, body, hostStyle } = options + clearElementClipAndMask(screen) + clearElementClipAndMask(body) + hostStyle.removeProperty('--device-screen-css-border-radius') + hostStyle.removeProperty('--device-body-css-border-radius') + hostStyle.removeProperty('--device-body-overflow') +} + +/** + * Outer continuous corners for `.body` (screen corners + bezel + hairline). + * Uses max(adjacent insets) so dark chrome fully underlaps the screen curve. + */ +export function continuousBodyCorners( + screenCorners: CornerRadii, + bezelInsets: { top: number, right: number, bottom: number, left: number }, + borderWidth: number, + /** Extra CSS-px so dark chrome underlaps screen clip AA against the page. */ + underlap = 2, +): CornerRadii { + const b = Math.max(0, borderWidth) + Math.max(0, underlap) + return { + topLeft: screenCorners.topLeft + Math.max(bezelInsets.left, bezelInsets.top) + b, + topRight: screenCorners.topRight + Math.max(bezelInsets.right, bezelInsets.top) + b, + bottomRight: screenCorners.bottomRight + Math.max(bezelInsets.right, bezelInsets.bottom) + b, + bottomLeft: screenCorners.bottomLeft + Math.max(bezelInsets.left, bezelInsets.bottom) + b, + } +} + +/** @deprecated SVG path mask — kept for tests; do not use for painting. */ +export function continuousScreenMaskImage( + width: number, + height: number, + corners: CornerRadii, +): string { + if (width <= 0 || height <= 0) return 'none' + const d = continuousRoundedRectPath(width, height, corners) + const svg = + `` + + `` + return `url("data:image/svg+xml,${encodeURIComponent(svg)}")` +} + +/** + * Authoritative continuous clip-path value (Figma ξ≈0.6). + * Prefer this over PNG mask for paint. + */ +export function continuousScreenClipPath( + width: number, + height: number, + corners: CornerRadii, +): string { + return figmaContinuousClipPath(width, height, corners) +} diff --git a/packages/frame/src/cutout.test.ts b/packages/frame/src/cutout.test.ts index 2850208..1edafff 100644 --- a/packages/frame/src/cutout.test.ts +++ b/packages/frame/src/cutout.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { statusBarEars } from './cutout.js' +import { cutoutBorderRadius, cutoutTopRadius, statusBarEars } from './cutout.js' import { DEVICES, findDevice, resolveDevice, statusBarEdgeFor } from '@devicekit/devices' import type { CutoutSpec } from '@devicekit/devices' @@ -27,19 +27,82 @@ describe('every top-edge tabled cutout leaves room for the horizontal status bar // and it needs an 8px margin from the ear's inner edge not to touch the cutout. // Duo's right-edge camera and vertical icon layout are covered by status-bar-edge and Chromium tests. const MIN_RIGHT_EAR = 70 + 8 + // iPhone 12 Mini's DeviceHub notch (226 on 375) leaves ~74.5pt ears — below the stock + // icon-group budget. Real SpringBoard packs tighter; do not invent a narrower mask. + const EAR_EXCEPTIONS: Record = { 'iPhone 12 Mini': 74 } - it('holds the icon group in the right ear for every top-edge device with a cutout', () => { + it('holds the icon group in the right ear for every top-edge device with a top-region cutout', () => { + // Bottom camera bars (e.g. Flip cover cutout.top near screen bottom) are not status-bar cutouts — + // they must not be held to status-icon ear clearance. const tooNarrow = DEVICES .filter((d) => { const resolved = resolveDevice(d) - return resolved.cutout !== null && statusBarEdgeFor(resolved, 'portrait') === 'top' + if (resolved.cutout === null || statusBarEdgeFor(resolved, 'portrait') !== 'top') return false + return resolved.cutout.top + resolved.cutout.height / 2 < resolved.screen.height / 2 }) .filter((d) => { const resolved = resolveDevice(d) const ears = statusBarEars(resolved.cutout, d.screen.width) - return ears === null || ears.right < MIN_RIGHT_EAR + const min = EAR_EXCEPTIONS[d.name] ?? MIN_RIGHT_EAR + return ears === null || ears.right < min }) .map((d) => d.name) expect(tooNarrow).toEqual([]) }) }) + + +describe('cutoutBorderRadius / cutoutTopRadius', () => { + it('uses measured bottomRadius for a notch when present', () => { + expect(cutoutBorderRadius({ + shape: 'notch', width: 227, height: 32, top: 0, bottomRadius: 21.5, + })).toBe('0 0 21.5px 21.5px') + }) + + it('falls back to round(height * 0.6) when bottomRadius is omitted', () => { + expect(cutoutBorderRadius({ shape: 'notch', width: 100, height: 30, top: 0 })).toBe('0 0 18px 18px') + }) + + it('paints a Dynamic Island as a stadium of height/2 (idle end-cap)', () => { + expect(cutoutBorderRadius({ shape: 'pill', width: 125, height: 37, top: 11 })).toBe('18.5px') + }) + + it('paints a punch-hole as a disc', () => { + expect(cutoutBorderRadius({ shape: 'circle', width: 37, height: 37, top: 26 })).toBe('50%') + }) + + it('uses measured topRadius for notch ears when present', () => { + expect(cutoutTopRadius({ + shape: 'notch', width: 209, height: 30, top: 0, bottomRadius: 20, topRadius: 6, + })).toBe(6) + }) + + it('defaults notch ear radius to 6 when topRadius is omitted', () => { + expect(cutoutTopRadius({ shape: 'notch', width: 100, height: 30, top: 0 })).toBe(6) + }) +}) + +describe('every iPhone notch preset carries cited corner radii', () => { + it('no notch iPhone is left on the global 0.6h heuristic (bottomRadius required)', () => { + const missing = DEVICES + .filter((d) => d.os === 'ios' && d.cutout?.shape === 'notch') + .filter((d) => d.cutout!.bottomRadius === undefined) + .map((d) => d.name) + expect(missing).toEqual([]) + }) + + it('PaintCode X-class sets ear topRadius=6; later gens leave ear unset (ADV null)', () => { + const xClass = ['iPhone X', 'iPhone XS Max', 'iPhone 11 Pro', 'iPhone 11 Pro Max'] + for (const name of xClass) { + const d = DEVICES.find((x) => x.name === name)! + expect(d.cutout!.topRadius, name).toBe(6) + expect(d.cutout!.bottomRadius, name).toBe(20) + } + const unsetEar = DEVICES + .filter((d) => d.os === 'ios' && d.cutout?.shape === 'notch') + .filter((d) => !xClass.includes(d.name)) + .filter((d) => d.cutout!.topRadius !== undefined) + .map((d) => d.name) + expect(unsetEar).toEqual([]) + }) +}) diff --git a/packages/frame/src/cutout.ts b/packages/frame/src/cutout.ts index bfa7b0d..da65aa7 100644 --- a/packages/frame/src/cutout.ts +++ b/packages/frame/src/cutout.ts @@ -1,4 +1,11 @@ import type { CutoutShape, CutoutSpec } from '@devicekit/devices' +import { + notchContinuousClipPath, + notchContinuousOuterWidth, + notchContinuousPathD, + NOTCH_CURVE_TOKEN, + NOTCH_PATH_DEFAULT_SMOOTHING, +} from './notch-continuous-path.js' /** * Stock geometry per shape, for a host that knows its phone has a notch but @@ -11,16 +18,26 @@ export const CUTOUT_PRESETS: Record = { circle: { shape: 'circle', width: 11, height: 11, top: 13 }, } +/** + * Default notch ear fillet when preset `topRadius` is omitted. + * PaintCode iPhone X labeled ear **6** — **legacy fallback only**. + * Uncalibrated families (XR/11, 12*, 13/14 narrow) keep `topRadius` unset + * rather than writing 6 into the preset (that would fake a calibration). + */ + +export const DEFAULT_NOTCH_TOP_RADIUS = 6 + /** * The cutout's border-radius, which follows from its shape. * - * A notch hangs off the top edge, so only its bottom corners are round. A pill - * and a punch-hole are free-floating and round the whole way. + * A notch hangs off the top edge, so only its bottom corners are round — prefer + * a measured `bottomRadius` when present. A pill and a punch-hole are + * free-floating and round the whole way (`height/2` stadium / `50%` disc). */ export function cutoutBorderRadius(cutout: CutoutSpec): string { switch (cutout.shape) { case 'notch': { - const r = Math.round(cutout.height * 0.6) + const r = cutout.bottomRadius ?? Math.round(cutout.height * 0.6) return `0 0 ${r}px ${r}px` } case 'circle': @@ -31,6 +48,11 @@ export function cutoutBorderRadius(cutout: CutoutSpec): string { } } +/** Notch↔top-edge ear fillet radius in CSS px. */ +export function cutoutTopRadius(cutout: CutoutSpec): number { + return cutout.topRadius ?? DEFAULT_NOTCH_TOP_RADIUS +} + /** Where the cutout's left edge sits, in CSS px from the screen's left edge. */ export function cutoutLeft(cutout: CutoutSpec, screenWidth: number): number { const center = (cutout.centerX ?? 0.5) * screenWidth @@ -47,3 +69,39 @@ export function statusBarEars(cutout: CutoutSpec | null, screenWidth: number): { const left = cutoutLeft(cutout, screenWidth) return { left, right: screenWidth - left - cutout.width } } + +/** + * Face ID notch clip-path (circular ears + bottom; ξ=0). + * + * Figma ξ≈0.6 continuous is NOT used — ADV found leftover sidewall=0 melt + + * anisotropic ear arcs (`NOTCH-CONTINUOUS-VISUAL-ADV.md`). Path default is + * circular quarter-circles matching PaintCode / Bezel radii. + */ +export function cutoutNotchContinuousClipPath(cutout: CutoutSpec): string | null { + if (cutout.shape !== 'notch') return null + const topR = cutout.topRadius ?? DEFAULT_NOTCH_TOP_RADIUS + const bottomR = cutout.bottomRadius ?? Math.round(cutout.height * 0.6) + const clip = notchContinuousClipPath(cutout.width, cutout.height, topR, bottomR) + return clip === 'none' ? null : clip +} + +/** Outer paint width including circular ears. */ +export function cutoutPaintWidth(cutout: CutoutSpec): number { + if (cutout.shape !== 'notch') return cutout.width + return notchContinuousOuterWidth(cutout.width, cutout.topRadius ?? DEFAULT_NOTCH_TOP_RADIUS) +} + +/** Left of paint box (shifts left by ear when notch path includes ears). */ +export function cutoutPaintLeft(cutout: CutoutSpec, screenWidth: number): number { + const left = cutoutLeft(cutout, screenWidth) + if (cutout.shape !== 'notch') return left + return left - (cutout.topRadius ?? DEFAULT_NOTCH_TOP_RADIUS) +} + +export { + notchContinuousPathD, + notchContinuousClipPath, + notchContinuousOuterWidth, + NOTCH_CURVE_TOKEN, + NOTCH_PATH_DEFAULT_SMOOTHING, +} diff --git a/packages/frame/src/device-frame.test.ts b/packages/frame/src/device-frame.test.ts index 3c25d4c..07ebc4d 100644 --- a/packages/frame/src/device-frame.test.ts +++ b/packages/frame/src/device-frame.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DEVICE_FRAME_TAG, defineDeviceFrame, type DeviceFrameElement } from './device-frame.js' +import { DEVICES, resolveDevice } from '@devicekit/devices' import { DEVICE_FRAME_STYLES } from './styles.js' defineDeviceFrame() @@ -179,8 +180,14 @@ describe('the cutout follows the current orientation', () => { it('shows the cutout in portrait, wired to the resolved metrics', () => { const el = mountFrame({ device: 'iPhone X' }) expect(cutoutEl(el).hidden).toBe(false) + // Legacy radial ears (::before/::after) + bottomRadius — clip-path path + // failed bidirectional image gate (NOTCH-BIDIRECTIONAL-IMAGE-ADV.md). + const ear = el.metrics.cutout!.topRadius ?? 6 expect(cutoutEl(el).style.width).toBe(`${el.metrics.cutout!.width}px`) expect(cutoutEl(el).style.height).toBe(`${el.metrics.cutout!.height}px`) + expect(cutoutEl(el).dataset.notchCurve).toBeUndefined() + expect(cutoutEl(el).style.clipPath || cutoutEl(el).style.getPropertyValue('clip-path')).toBe('') + expect(cutoutEl(el).style.getPropertyValue('--device-notch-top-radius')).toBe(`${ear}px`) }) // Android keeps its status bar visible in landscape, so this reaches the @@ -263,14 +270,51 @@ describe('the home indicator bar is paintable in either foreground color', () => }) describe('the home indicator', () => { - it('shows only on devices whose safe area reports a bottom inset', () => { + it('shows only on iOS devices whose safe area reports a bottom inset', () => { // iPhone X: portrait bottom inset 34 (gesture bar). iPhone SE: 0 (home button). expect(homeIndicator(mountFrame({ device: 'iPhone X' })).hidden).toBe(false) + expect(homeIndicator(mountFrame({ device: 'iPhone 16 Pro' })).hidden).toBe(false) expect(homeIndicator(mountFrame({ device: 'iPhone SE' })).hidden).toBe(true) }) - it('keeps showing in landscape, since the gesture bar does not rotate away', () => { + it('stays hidden on Android/Harmony even when bottom safe inset is > 0', () => { + // Galaxy S24 gesture inset ~15, Pixel 9 Pro ~24 — content keeps the inset, + // but the iOS 118×4 pill must not draw. + expect(homeIndicator(mountFrame({ device: 'Galaxy S24' })).hidden).toBe(true) + expect(homeIndicator(mountFrame({ device: 'Pixel 9 Pro' })).hidden).toBe(true) + }) + + it('hides on every android and harmony preset (no sampling)', () => { + const nonIos = DEVICES.filter((d) => d.os !== 'ios') + expect(nonIos.length).toBeGreaterThan(50) + for (const device of nonIos) { + const el = mountFrame({ device: device.name }) + expect(homeIndicator(el).hidden, device.name).toBe(true) + el.remove() + } + }) + + it('shows on every iOS preset with bottom safe inset > 0, including Duo', () => { + const iosWithBottom = DEVICES.filter((d) => { + if (d.os !== 'ios') return false + const resolved = resolveDevice(d) + return resolved.safeAreaInsets.bottom > 0 + }) + expect(iosWithBottom.map((d) => d.name)).toEqual( + expect.arrayContaining(['iPhone Duo (outer)', 'iPhone Duo (inner)']), + ) + for (const device of iosWithBottom) { + const el = mountFrame({ device: device.name }) + expect(homeIndicator(el).hidden, device.name).toBe(false) + el.remove() + } + }) + + it('keeps showing in landscape on iOS, since the gesture bar does not rotate away', () => { expect(homeIndicator(mountFrame({ device: 'iPhone X', orientation: 'landscape' })).hidden).toBe(false) + // Duo inner Fully Open landscape keeps bottom 34 (safearea.info HT Off); HI must not hide. + expect(homeIndicator(mountFrame({ device: 'iPhone Duo (inner)', orientation: 'landscape' })).hidden).toBe(false) + expect(homeIndicator(mountFrame({ device: 'iPhone Duo (outer)', orientation: 'landscape' })).hidden).toBe(false) }) }) diff --git a/packages/frame/src/device-frame.ts b/packages/frame/src/device-frame.ts index 91d385b..9bbbc88 100644 --- a/packages/frame/src/device-frame.ts +++ b/packages/frame/src/device-frame.ts @@ -48,8 +48,16 @@ import { import { CONTENT_RECT_CHANGE_EVENT, type DeviceFrameElementEventMap } from './element-events.js' import { computeDeviceMetrics, DEFAULT_TAB_BAR_HEIGHT, type DeviceMetrics, type StatusBarTextStyle } from './metrics.js' import { reflectAttribute, reflectFlag, reflectMetrics } from './reflect.js' +import { + applyContinuousScreenPaint, + clearContinuousScreenPaint, + continuousBodyCorners, + continuousScreenClipPath, +} from './continuous-corner-mask.js' +import { figmaContinuousClipPath } from './figma-continuous-path.js' +import { orientedShellCorners, orientedShellInsets } from './shell-insets.js' import { StatusBar, statusBarTextColor } from './status-bar.js' -import { adoptDeviceFrameStyles } from './styles.js' +import { adoptDeviceFrameStyles, DEVICE_FRAME_BORDER_WIDTH } from './styles.js' import { upgradeProperties } from './upgrade-properties.js' export { CONTENT_RECT_CHANGE_EVENT } @@ -411,6 +419,9 @@ export class DeviceFrameElement extends HTMLElementBase { this.dataset.devicekitInteractionMode = this.interactionMode const metrics = this.metrics reflectMetrics(this.style, metrics, this.embedded, this.profile) + // Continuous silhouette: inline analytical PNG mask on .screen (QA round3). + // CSS-var masks / clip-path are not the paint authority. + this.#applyScreenClip(metrics) const statusBarMode = this.getAttribute('status-bar') const showStatusBar = !this.embedded && statusBarMode !== 'hidden' && metrics.statusBarHeight > 0 @@ -428,10 +439,13 @@ export class DeviceFrameElement extends HTMLElementBase { 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. + // iOS-only chrome: Android/Harmony gesture nav does not draw the 118×4 home + // pill. Bottom safe-area inset still applies for content; only the visual is + // gated. Unconditional vs 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 + this.#homeIndicatorEl.hidden = + this.embedded || this.profile.os !== 'ios' || metrics.safeAreaInsets.bottom <= 0 const homeButton = metrics.shell.homeButton if (homeButton && !this.embedded) { if (!this.#homeButtonEl.isConnected) this.shadowRoot?.querySelector('.body')?.append(this.#homeButtonEl) @@ -477,6 +491,40 @@ export class DeviceFrameElement extends HTMLElementBase { * "only fire on an actual change" contract is the same one attribute-driven * re-renders already keep. A no-op while disconnected — see `#publishContentRect`. */ + + /** + * Continuous screens: inline Figma ξ≈0.6 `clip-path: path(...)` on `.screen` + * (hard clip — paint authority) and a matching outer continuous clip on + * `.body` so black chrome is rounded (bodyRadius ≈ screen + bezel) without + * drawing side buttons. Screen path is NOT inset: a 1px inset clipped the + * Face ID notch radial ears at y=0 (widest flare). Body corners include + * underlap so dark bezel covers screen AA (no white page bleed). + */ + #applyScreenClip(metrics: DeviceMetrics): void { + const screen = this.#screenEl + const body = this.shadowRoot?.querySelector('.body') as HTMLElement | null + if (!screen) return + if (this.embedded || metrics.shell.cornerCurve !== 'continuous') { + clearContinuousScreenPaint({ screen, body, hostStyle: this.style }) + return + } + const corners = orientedShellCorners(metrics.shell.screenCorners, metrics.orientation) + // Inset 0: notch ::before/::after ears paint at the top edge; body underlap + // covers continuous-corner AA against the page. + const clip = figmaContinuousClipPath( + metrics.screen.width, + metrics.screen.height, + corners, + ) + const insets = orientedShellInsets(metrics.shell.bezelInsets, metrics.orientation) + // Outer continuous corners: screen + adjacent bezel + hairline + underlap. + const bodyCorners = continuousBodyCorners(corners, insets, DEVICE_FRAME_BORDER_WIDTH) + const bodyW = metrics.screen.width + insets.left + insets.right + 2 * DEVICE_FRAME_BORDER_WIDTH + const bodyH = metrics.screen.height + insets.top + insets.bottom + 2 * DEVICE_FRAME_BORDER_WIDTH + const bodyClip = continuousScreenClipPath(bodyW, bodyH, bodyCorners) + applyContinuousScreenPaint({ screen, body, hostStyle: this.style, clip, bodyClip }) + } + refreshContentRect(): void { this.#publishContentRect() } diff --git a/packages/frame/src/figma-continuous-path.ts b/packages/frame/src/figma-continuous-path.ts new file mode 100644 index 0000000..4ce1710 --- /dev/null +++ b/packages/frame/src/figma-continuous-path.ts @@ -0,0 +1,154 @@ +/** + * Figma corner-smoothing path for continuous screen clip. + * + * Path math vendored from phamfoo/figma-squircle (MIT), which implements the + * curve from Figma's "Desperately seeking squircles" (ξ / cornerSmoothing). + * Default ξ=0.6 is the Figma value chosen to approximate iOS continuous corners. + * + * Authoritative paint for `cornerCurve: 'continuous'` is inline + * `clip-path: path(...)` — hard clip with engine edge AA — not a PNG mask + * (alpha fringes cause white bleed against dark chrome). + */ +import type { CornerRadii } from '@devicekit/devices' +import { distributeAndNormalize } from './figma-squircle-distribute.js' +import { getPathParamsForCorner, getSVGPathFromPathParams } from './figma-squircle-draw.js' + +/** Figma cornerSmoothing that targets iOS-like continuous corners. */ +export const FIGMA_IOS_CORNER_SMOOTHING = 0.6 + +function clampCorners(width: number, height: number, corners: CornerRadii): CornerRadii { + let { topLeft: tl, topRight: tr, bottomRight: br, bottomLeft: bl } = corners + tl = Math.max(0, tl) + tr = Math.max(0, tr) + br = Math.max(0, br) + bl = Math.max(0, bl) + + const scale = (a: number, b: number, limit: number): [number, number] => { + if (a + b <= limit || a + b === 0) return [a, b] + const s = limit / (a + b) + return [a * s, b * s] + } + ;[tl, tr] = scale(tl, tr, width) + ;[bl, br] = scale(bl, br, width) + ;[tl, bl] = scale(tl, bl, height) + ;[tr, br] = scale(tr, br, height) + return { topLeft: tl, topRight: tr, bottomRight: br, bottomLeft: bl } +} + +/** + * SVG path `d` for a continuous rounded rect (Figma ξ smoothing). + * Axis intercept stays at the labeled radius on each edge when budget allows. + */ +export function figmaContinuousRoundedRectPath( + width: number, + height: number, + corners: CornerRadii, + cornerSmoothing = FIGMA_IOS_CORNER_SMOOTHING, +): string { + if (width <= 0 || height <= 0) return '' + const c = clampCorners(width, height, corners) + const uniform = + c.topLeft === c.topRight + && c.topRight === c.bottomRight + && c.bottomRight === c.bottomLeft + + if (uniform) { + const budget = Math.min(width, height) / 2 + const radius = Math.min(c.topLeft, budget) + const pathParams = getPathParamsForCorner({ + cornerRadius: radius, + cornerSmoothing, + preserveSmoothing: true, + roundingAndSmoothingBudget: budget, + }) + return getSVGPathFromPathParams({ + width, + height, + topLeftPathParams: pathParams, + topRightPathParams: pathParams, + bottomLeftPathParams: pathParams, + bottomRightPathParams: pathParams, + }) + } + + const distributed = distributeAndNormalize({ + topLeftCornerRadius: c.topLeft, + topRightCornerRadius: c.topRight, + bottomRightCornerRadius: c.bottomRight, + bottomLeftCornerRadius: c.bottomLeft, + width, + height, + }) + + return getSVGPathFromPathParams({ + width, + height, + topLeftPathParams: getPathParamsForCorner({ + cornerSmoothing, + preserveSmoothing: true, + cornerRadius: distributed.topLeft.radius, + roundingAndSmoothingBudget: distributed.topLeft.roundingAndSmoothingBudget, + }), + topRightPathParams: getPathParamsForCorner({ + cornerSmoothing, + preserveSmoothing: true, + cornerRadius: distributed.topRight.radius, + roundingAndSmoothingBudget: distributed.topRight.roundingAndSmoothingBudget, + }), + bottomRightPathParams: getPathParamsForCorner({ + cornerSmoothing, + preserveSmoothing: true, + cornerRadius: distributed.bottomRight.radius, + roundingAndSmoothingBudget: distributed.bottomRight.roundingAndSmoothingBudget, + }), + bottomLeftPathParams: getPathParamsForCorner({ + cornerSmoothing, + preserveSmoothing: true, + cornerRadius: distributed.bottomLeft.radius, + roundingAndSmoothingBudget: distributed.bottomLeft.roundingAndSmoothingBudget, + }), + }) +} + +/** CSS `clip-path` value: `path("…")` for inline application on `.screen` / `.body`. */ +export function figmaContinuousClipPath( + width: number, + height: number, + corners: CornerRadii, + cornerSmoothing = FIGMA_IOS_CORNER_SMOOTHING, +): string { + const d = figmaContinuousRoundedRectPath(width, height, corners, cornerSmoothing) + if (!d) return 'none' + // CSS path() takes a quoted SVG path; keep double quotes for style.setProperty. + return `path("${d}")` +} + +/** + * Continuous clip inset by `insetPx` on every side (keeps white AA inside chrome). + * Builds the Figma path in the inset box, then shifts absolute M/L by inset; + * relative c/a segments stay unchanged. + */ +export function figmaContinuousClipPathInset( + width: number, + height: number, + corners: CornerRadii, + insetPx: number, + cornerSmoothing = FIGMA_IOS_CORNER_SMOOTHING, +): string { + const inset = Math.max(0, insetPx) + const w = width - 2 * inset + const h = height - 2 * inset + if (w <= 0 || h <= 0) return 'none' + const c = { + topLeft: Math.max(0, corners.topLeft - inset), + topRight: Math.max(0, corners.topRight - inset), + bottomRight: Math.max(0, corners.bottomRight - inset), + bottomLeft: Math.max(0, corners.bottomLeft - inset), + } + const d = figmaContinuousRoundedRectPath(w, h, c, cornerSmoothing) + if (!d) return 'none' + const shifted = d.replace(/([ML])\s+(-?\d*\.?\d+(?:e[-+]?\d+)?)\s+(-?\d*\.?\d+(?:e[-+]?\d+)?)/gi, (_m, cmd, x, y) => { + return `${cmd} ${(Number(x) + inset).toFixed(4)} ${(Number(y) + inset).toFixed(4)}` + }) + return `path("${shifted}")` +} diff --git a/packages/frame/src/figma-squircle-distribute.ts b/packages/frame/src/figma-squircle-distribute.ts new file mode 100644 index 0000000..eed9d18 --- /dev/null +++ b/packages/frame/src/figma-squircle-distribute.ts @@ -0,0 +1,155 @@ +/** Vendored from phamfoo/figma-squircle (MIT) — corner budget distribute. */ +interface RoundedRectangle { + topLeftCornerRadius: number + topRightCornerRadius: number + bottomRightCornerRadius: number + bottomLeftCornerRadius: number + width: number + height: number +} + +interface NormalizedCorner { + radius: number + roundingAndSmoothingBudget: number +} + +interface NormalizedCorners { + topLeft: NormalizedCorner + topRight: NormalizedCorner + bottomLeft: NormalizedCorner + bottomRight: NormalizedCorner +} + +type Corner = keyof NormalizedCorners + +type Side = 'top' | 'left' | 'right' | 'bottom' + +interface Adjacent { + side: Side + corner: Corner +} + +export function distributeAndNormalize({ + topLeftCornerRadius, + topRightCornerRadius, + bottomRightCornerRadius, + bottomLeftCornerRadius, + width, + height, +}: RoundedRectangle): NormalizedCorners { + const roundingAndSmoothingBudgetMap: Record = { + topLeft: -1, + topRight: -1, + bottomLeft: -1, + bottomRight: -1, + } + + const cornerRadiusMap: Record = { + topLeft: topLeftCornerRadius, + topRight: topRightCornerRadius, + bottomLeft: bottomLeftCornerRadius, + bottomRight: bottomRightCornerRadius, + } + + Object.entries(cornerRadiusMap) + // Let the bigger corners choose first + .sort(([, radius1], [, radius2]) => { + return radius2 - radius1 + }) + .forEach(([cornerName, radius]) => { + const corner = cornerName as Corner + const adjacents = adjacentsByCorner[corner] + + // Look at the 2 adjacent sides, figure out how much space we can have on both sides, + // then take the smaller one + const budget = Math.min( + ...adjacents.map((adjacent) => { + const adjacentCornerRadius = cornerRadiusMap[adjacent.corner] + if (radius === 0 && adjacentCornerRadius === 0) { + return 0 + } + + const adjacentCornerBudget = + roundingAndSmoothingBudgetMap[adjacent.corner] + + const sideLength = + adjacent.side === 'top' || adjacent.side === 'bottom' + ? width + : height + + // If the adjacent corner's already been given the rounding and smoothing budget, + // we'll just take the rest + if (adjacentCornerBudget >= 0) { + return sideLength - roundingAndSmoothingBudgetMap[adjacent.corner] + } else { + return (radius / (radius + adjacentCornerRadius)) * sideLength + } + }) + ) + + roundingAndSmoothingBudgetMap[corner] = budget + cornerRadiusMap[corner] = Math.min(radius, budget) + }) + + return { + topLeft: { + radius: cornerRadiusMap.topLeft, + roundingAndSmoothingBudget: roundingAndSmoothingBudgetMap.topLeft, + }, + topRight: { + radius: cornerRadiusMap.topRight, + roundingAndSmoothingBudget: roundingAndSmoothingBudgetMap.topRight, + }, + bottomLeft: { + radius: cornerRadiusMap.bottomLeft, + roundingAndSmoothingBudget: roundingAndSmoothingBudgetMap.bottomLeft, + }, + bottomRight: { + radius: cornerRadiusMap.bottomRight, + roundingAndSmoothingBudget: roundingAndSmoothingBudgetMap.bottomRight, + }, + } +} + +const adjacentsByCorner: Record> = { + topLeft: [ + { + corner: 'topRight', + side: 'top', + }, + { + corner: 'bottomLeft', + side: 'left', + }, + ], + topRight: [ + { + corner: 'topLeft', + side: 'top', + }, + { + corner: 'bottomRight', + side: 'right', + }, + ], + bottomLeft: [ + { + corner: 'bottomRight', + side: 'bottom', + }, + { + corner: 'topLeft', + side: 'left', + }, + ], + bottomRight: [ + { + corner: 'bottomLeft', + side: 'bottom', + }, + { + corner: 'topRight', + side: 'right', + }, + ], +} diff --git a/packages/frame/src/figma-squircle-draw.ts b/packages/frame/src/figma-squircle-draw.ts new file mode 100644 index 0000000..98e75fb --- /dev/null +++ b/packages/frame/src/figma-squircle-draw.ts @@ -0,0 +1,233 @@ +/** Vendored from phamfoo/figma-squircle (MIT) — path corner math. */ +interface CornerPathParams { + a: number + b: number + c: number + d: number + p: number + cornerRadius: number + arcSectionLength: number +} + +interface CornerParams { + cornerRadius: number + cornerSmoothing: number + preserveSmoothing: boolean + roundingAndSmoothingBudget: number +} + +// The article from figma's blog +// https://www.figma.com/blog/desperately-seeking-squircles/ +// +// The original code by MartinRGB +// https://github.com/MartinRGB/Figma_Squircles_Approximation/blob/bf29714aab58c54329f3ca130ffa16d39a2ff08c/js/rounded-corners.js#L64 +export function getPathParamsForCorner({ + cornerRadius, + cornerSmoothing, + preserveSmoothing, + roundingAndSmoothingBudget, +}: CornerParams): CornerPathParams { + // From figure 12.2 in the article + // p = (1 + cornerSmoothing) * q + // in this case q = R because theta = 90deg + let p = (1 + cornerSmoothing) * cornerRadius + + // When there's not enough space left (p > roundingAndSmoothingBudget), there are 2 options: + // + // 1. What figma's currently doing: limit the smoothing value to make sure p <= roundingAndSmoothingBudget + // But what this means is that at some point when cornerRadius is large enough, + // increasing the smoothing value wouldn't do anything + // + // 2. Keep the original smoothing value and use it to calculate the bezier curve normally, + // then adjust the control points to achieve similar curvature profile + // + // preserveSmoothing is a new option I added + // + // If preserveSmoothing is on then we'll just keep using the original smoothing value + // and adjust the bezier curve later + if (!preserveSmoothing) { + const maxCornerSmoothing = roundingAndSmoothingBudget / cornerRadius - 1 + cornerSmoothing = Math.min(cornerSmoothing, maxCornerSmoothing) + p = Math.min(p, roundingAndSmoothingBudget) + } + + // In a normal rounded rectangle (cornerSmoothing = 0), this is 90 + // The larger the smoothing, the smaller the arc + const arcMeasure = 90 * (1 - cornerSmoothing) + const arcSectionLength = + Math.sin(toRadians(arcMeasure / 2)) * cornerRadius * Math.sqrt(2) + + // In the article this is the distance between 2 control points: P3 and P4 + const angleAlpha = (90 - arcMeasure) / 2 + const p3ToP4Distance = cornerRadius * Math.tan(toRadians(angleAlpha / 2)) + + // a, b, c and d are from figure 11.1 in the article + const angleBeta = 45 * cornerSmoothing + const c = p3ToP4Distance * Math.cos(toRadians(angleBeta)) + const d = c * Math.tan(toRadians(angleBeta)) + + let b = (p - arcSectionLength - c - d) / 3 + let a = 2 * b + + // Adjust the P1 and P2 control points if there's not enough space left + if (preserveSmoothing && p > roundingAndSmoothingBudget) { + const p1ToP3MaxDistance = + roundingAndSmoothingBudget - d - arcSectionLength - c + + // Try to maintain some distance between P1 and P2 so the curve wouldn't look weird + const minA = p1ToP3MaxDistance / 6 + const maxB = p1ToP3MaxDistance - minA + + b = Math.min(b, maxB) + a = p1ToP3MaxDistance - b + p = Math.min(p, roundingAndSmoothingBudget) + } + + return { + a, + b, + c, + d, + p, + arcSectionLength, + cornerRadius, + } +} + +interface SVGPathInput { + width: number + height: number + topRightPathParams: CornerPathParams + bottomRightPathParams: CornerPathParams + bottomLeftPathParams: CornerPathParams + topLeftPathParams: CornerPathParams +} + +export function getSVGPathFromPathParams({ + width, + height, + topLeftPathParams, + topRightPathParams, + bottomLeftPathParams, + bottomRightPathParams, +}: SVGPathInput) { + return ` + M ${width - topRightPathParams.p} 0 + ${drawTopRightPath(topRightPathParams)} + L ${width} ${height - bottomRightPathParams.p} + ${drawBottomRightPath(bottomRightPathParams)} + L ${bottomLeftPathParams.p} ${height} + ${drawBottomLeftPath(bottomLeftPathParams)} + L 0 ${topLeftPathParams.p} + ${drawTopLeftPath(topLeftPathParams)} + Z + ` + .replace(/[\t\s\n]+/g, ' ') + .trim() +} + +function drawTopRightPath({ + cornerRadius, + a, + b, + c, + d, + p, + arcSectionLength, +}: CornerPathParams) { + if (cornerRadius) { + return rounded` + c ${a} 0 ${a + b} 0 ${a + b + c} ${d} + a ${cornerRadius} ${cornerRadius} 0 0 1 ${arcSectionLength} ${arcSectionLength} + c ${d} ${c} + ${d} ${b + c} + ${d} ${a + b + c}` + } else { + return rounded`l ${p} 0` + } +} + +function drawBottomRightPath({ + cornerRadius, + a, + b, + c, + d, + p, + arcSectionLength, +}: CornerPathParams) { + if (cornerRadius) { + return rounded` + c 0 ${a} + 0 ${a + b} + ${-d} ${a + b + c} + a ${cornerRadius} ${cornerRadius} 0 0 1 -${arcSectionLength} ${arcSectionLength} + c ${-c} ${d} + ${-(b + c)} ${d} + ${-(a + b + c)} ${d}` + } else { + return rounded`l 0 ${p}` + } +} + +function drawBottomLeftPath({ + cornerRadius, + a, + b, + c, + d, + p, + arcSectionLength, +}: CornerPathParams) { + if (cornerRadius) { + return rounded` + c ${-a} 0 + ${-(a + b)} 0 + ${-(a + b + c)} ${-d} + a ${cornerRadius} ${cornerRadius} 0 0 1 -${arcSectionLength} -${arcSectionLength} + c ${-d} ${-c} + ${-d} ${-(b + c)} + ${-d} ${-(a + b + c)}` + } else { + return rounded`l ${-p} 0` + } +} + +function drawTopLeftPath({ + cornerRadius, + a, + b, + c, + d, + p, + arcSectionLength, +}: CornerPathParams) { + if (cornerRadius) { + return rounded` + c 0 ${-a} + 0 ${-(a + b)} + ${d} ${-(a + b + c)} + a ${cornerRadius} ${cornerRadius} 0 0 1 ${arcSectionLength} -${arcSectionLength} + c ${c} ${-d} + ${b + c} ${-d} + ${a + b + c} ${-d}` + } else { + return rounded`l 0 ${-p}` + } +} + +function toRadians(degrees: number) { + return (degrees * Math.PI) / 180 +} + +function rounded(strings: TemplateStringsArray, ...values: number[]): string { + return strings.reduce((acc, str, i) => { + const value = values[i] + + if (typeof value === 'number') { + return acc + str + value.toFixed(4) + } else { + return acc + str + (value ?? '') + } + }, '') +} diff --git a/packages/frame/src/index.ts b/packages/frame/src/index.ts index 728e9fd..7ef1113 100644 --- a/packages/frame/src/index.ts +++ b/packages/frame/src/index.ts @@ -16,8 +16,18 @@ export type { DeviceFrameElementEventMap } from './element-events.js' export { CUTOUT_PRESETS, cutoutBorderRadius, + cutoutTopRadius, + DEFAULT_NOTCH_TOP_RADIUS, cutoutLeft, + cutoutNotchContinuousClipPath, + cutoutPaintLeft, + cutoutPaintWidth, statusBarEars, + notchContinuousPathD, + notchContinuousClipPath, + notchContinuousOuterWidth, + NOTCH_CURVE_TOKEN, + NOTCH_PATH_DEFAULT_SMOOTHING, } from './cutout.js' export { diff --git a/packages/frame/src/notch-continuous-path.test.ts b/packages/frame/src/notch-continuous-path.test.ts new file mode 100644 index 0000000..3476f2b --- /dev/null +++ b/packages/frame/src/notch-continuous-path.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { DEVICES } from '@devicekit/devices' +import { + cutoutNotchContinuousClipPath, + cutoutPaintLeft, + cutoutPaintWidth, +} from './cutout.js' +import { + notchContinuousClipPath, + notchContinuousOuterWidth, + notchContinuousPathD, + NOTCH_CURVE_TOKEN, + NOTCH_PATH_DEFAULT_SMOOTHING, +} from './notch-continuous-path.js' +import { FIGMA_IOS_CORNER_SMOOTHING } from './figma-continuous-path.js' + +describe('notchContinuousPathD', () => { + it('returns empty for a degenerate box', () => { + expect(notchContinuousPathD(0, 30, 6, 20)).toBe('') + expect(notchContinuousPathD(209, 0, 6, 20)).toBe('') + }) + + it('PaintCode X (209×30, ear 6, bottom 20): top edge then outward ear arcs (sweep 0)', () => { + const d = notchContinuousPathD(209, 30, 6, 20) + // Outer box 221×30 — start at left tip, line across top (NOT arc into body) + expect(d.startsWith('M 0.0000 0.0000 L 221.0000 0.0000')).toBe(true) + expect(d.endsWith('Z')).toBe(true) + // Right ear: A … 0 0 0 bodyRight,ear (sweep 0 = outward) + expect(d).toMatch(/A 6\.0000 6\.0000 0 0 0 215\.0000 6\.0000/) + // Left ear closes: A … 0 0 0 0 0 + expect(d).toMatch(/A 6\.0000 6\.0000 0 0 0 0\.0000 0\.0000 Z$/) + // MUST NOT be the concave scoop M 0,0 A … 6,6 with sweep 1 + expect(d).not.toMatch(/^M 0\.0000 0\.0000 A 6\.0000 6\.0000 0 0 1 6\.0000 6\.0000/) + expect(notchContinuousOuterWidth(209, 6)).toBe(221) + const clip = notchContinuousClipPath(209, 30, 6, 20) + expect(clip.startsWith('path("')).toBe(true) + expect(clip.endsWith('")')).toBe(true) + }) + + it('keeps a vertical mid sidewall on the body (L at x=ear between ear and bottom)', () => { + const d = notchContinuousPathD(209, 30, 6, 20, 0) + // Traverse: top → right ear → down right wall to y=10 → bottoms → up left wall L 6,6 + expect(d).toMatch(/L 215\.0000 10\.0000/) + expect(d).toMatch(/L 6\.0000 6\.0000/) + // Mid-wall length: from y=ear(6) to y=H-bot(10) = 4pt + expect(d).toContain('L 215.0000 10.0000 A 20.0000') + }) + + it('is not a literal trapezoid polygon (no bare 4-point L-only hull)', () => { + const d = notchContinuousPathD(209, 30, 6, 20) + const onlyLines = !/[cCaA]/.test(d) + expect(onlyLines).toBe(false) + }) + + it('defaults to circular paint; screen ξ stays 0.6 for screen corners only', () => { + expect(NOTCH_PATH_DEFAULT_SMOOTHING).toBe(0) + expect(NOTCH_CURVE_TOKEN).toBe('circular') + expect(FIGMA_IOS_CORNER_SMOOTHING).toBe(0.6) + const d = notchContinuousPathD(209, 30, 6, 20) + for (const m of d.matchAll(/A\s+([\d.]+)\s+([\d.]+)/g)) { + expect(Number(m[1])).toBe(Number(m[2])) + } + }) + + it('ear arcs use sweep 0 (outward); bottom fillets keep sweep 1', () => { + const d = notchContinuousPathD(209, 30, 6, 20) + const arcs = [...d.matchAll(/A\s+([\d.]+)\s+([\d.]+)\s+0\s+0\s+(\d)/g)] + expect(arcs.length).toBe(4) + // order: right ear, bottom-right, bottom-left, left ear + expect(arcs[0]![3]).toBe('0') // right ear outward + expect(arcs[1]![3]).toBe('1') // bottom + expect(arcs[2]![3]).toBe('1') // bottom + expect(arcs[3]![3]).toBe('0') // left ear outward + }) +}) + +describe('cutout continuous wiring', () => { + it('builds a circular clip for every iPhone notch preset and leaves pill/circle on radius paint', () => { + const notches = DEVICES.filter((d) => d.os === 'ios' && d.cutout?.shape === 'notch') + expect(notches.length).toBeGreaterThan(10) + for (const d of notches) { + const clip = cutoutNotchContinuousClipPath(d.cutout!) + expect(clip, d.name).toMatch(/^path\("/) + const ear = d.cutout!.topRadius ?? 6 + expect(cutoutPaintWidth(d.cutout!)).toBe(d.cutout!.width + 2 * ear) + expect(cutoutPaintLeft(d.cutout!, d.screen.width)).toBe( + (d.screen.width - d.cutout!.width) / 2 - ear, + ) + // Every notch path must use outward ear sweep 0, not concave scoop + expect(clip, d.name).toMatch(/A [\d.]+ [\d.]+ 0 0 0 /) + expect(clip, d.name).not.toMatch(/^path\("M 0\.0000 0\.0000 A /) + } + + const island = DEVICES.find((d) => d.name === 'iPhone 15')! + expect(cutoutNotchContinuousClipPath(island.cutout!)).toBeNull() + expect(cutoutPaintWidth(island.cutout!)).toBe(island.cutout!.width) + + const duo = DEVICES.find((d) => d.name === 'iPhone Duo (outer)')! + expect(cutoutNotchContinuousClipPath(duo.cutout!)).toBeNull() + }) + + it('covers the four Face ID families with cited radii', () => { + const families: Array<{ name: string, w: number, h: number, ear: number, bot: number }> = [ + { name: 'iPhone X', w: 209, h: 30, ear: 6, bot: 20 }, + { name: 'iPhone 11', w: 227, h: 32, ear: 6, bot: 21.5 }, + { name: 'iPhone 12', w: 210, h: 32, ear: 6, bot: 20 }, + { name: 'iPhone 14', w: 162, h: 34, ear: 6, bot: 21 }, + ] + for (const f of families) { + const d = notchContinuousPathD(f.w, f.h, f.ear, f.bot) + expect(d.length, f.name).toBeGreaterThan(40) + expect(notchContinuousOuterWidth(f.w, f.ear)).toBe(f.w + 2 * f.ear) + expect(d, f.name).toMatch(new RegExp(`L ${(f.w + 2 * f.ear).toFixed(4)} 0\\.0000`)) + } + }) +}) + +describe('continuous path stays inside the outer box', () => { + it('X / XR / 12 / 14 family paths keep absolute M/L coords in [0,W]×[0,H] (±0.5)', () => { + const families = [ + [209, 30, 6, 20], + [227, 32, 6, 21.5], + [210, 32, 6, 20], + [162, 34, 6, 21], + ] as const + for (const [w, h, ear, bot] of families) { + const W = w + 2 * ear + const d = notchContinuousPathD(w, h, ear, bot) + const abs = [...d.matchAll(/[ML]\s+(-?\d+\.\d+)\s+(-?\d+\.\d+)/g)] + expect(abs.length, `family ${w}`).toBeGreaterThan(2) + for (const m of abs) { + const x = Number(m[1]) + const y = Number(m[2]) + expect(x).toBeGreaterThanOrEqual(-0.5) + expect(x).toBeLessThanOrEqual(W + 0.5) + expect(y).toBeGreaterThanOrEqual(-0.5) + expect(y).toBeLessThanOrEqual(h + 0.5) + } + } + }) +}) + +describe('circular path keeps a mid sidewall', () => { + it('PaintCode X / XR / 12 / 14 families have ear+bot ≤ height (positive mid wall)', () => { + const families = [ + [209, 30, 6, 20], + [227, 32, 6, 21.5], + [210, 32, 6, 20], + [162, 34, 6, 21], + ] as const + for (const [w, h, ear, bot] of families) { + expect(ear + bot, `family ${w}`).toBeLessThanOrEqual(h) + const d = notchContinuousPathD(w, h, ear, bot) + expect(d, `family ${w}`).toMatch(new RegExp(`L ${ear.toFixed(4)} `)) + } + }) +}) diff --git a/packages/frame/src/notch-continuous-path.ts b/packages/frame/src/notch-continuous-path.ts new file mode 100644 index 0000000..eb73a20 --- /dev/null +++ b/packages/frame/src/notch-continuous-path.ts @@ -0,0 +1,134 @@ +/** + * Face ID notch silhouette path (ears + body + bottom fillets). + * + * Radii from PaintCode / Apple Bezel are **circular equivalents**. + * Paint is circular quarter-circles. + * + * Ear geometry matches the legacy CSS radial ears: + * ::before { left:-ear; radial-gradient(circle at bottom left, transparent ear, color) } + * Black fill is OUTSIDE the ear circle (center at the inner-bottom of each ear + * square), so the filled notch gets **outward** top fillets — not concave bites. + * + * Do **not** apply Figma screen-corner ξ≈0.6 here (budget collision → melt). + * Do **not** use `M 0,0 A … bodyLeft,ear` with sweep 1 — that scoops INTO the + * fill (凹进去). + * + * Coordinate system: (0,0) = top-left of the OUTER box including ears. + * Outer size = (bodyWidth + 2·topRadius) × height. + */ + +function fmt(n: number): string { + return n.toFixed(4) +} + +/** + * Circular silhouette — CSS radial / PaintCode faithful. + * + * Outline (nonzero fill): + * top edge across full outer width + * → right ear arc, center (W, ear), (W,0) → (W−ear, ear), sweep 0 + * → body right wall → bottom fillets (sweep 1) + * → body left wall + * → left ear arc, center (0, ear), (ear,ear) → (0,0), sweep 0 + */ +function circularNotchPathD( + bodyWidth: number, + height: number, + ear: number, + bot: number, +): string { + const W = bodyWidth + 2 * ear + const H = height + const bodyLeft = ear + const bodyRight = ear + bodyWidth + const parts: string[] = [`M ${fmt(0)} ${fmt(0)}`] + + // Full top edge of outer box (includes both ear tips). + parts.push(`L ${fmt(W)} ${fmt(0)}`) + + if (ear > 0) { + // Right outward ear: center (W, ear), sweep 0 through the ear square. + parts.push(`A ${fmt(ear)} ${fmt(ear)} 0 0 0 ${fmt(bodyRight)} ${fmt(ear)}`) + } + + if (bot > 0) { + const botStartY = H - bot + if (botStartY > ear + 1e-6) { + parts.push(`L ${fmt(bodyRight)} ${fmt(botStartY)}`) + } + parts.push( + `A ${fmt(bot)} ${fmt(bot)} 0 0 1 ${fmt(bodyRight - bot)} ${fmt(H)}`, + `L ${fmt(bodyLeft + bot)} ${fmt(H)}`, + `A ${fmt(bot)} ${fmt(bot)} 0 0 1 ${fmt(bodyLeft)} ${fmt(botStartY)}`, + ) + if (botStartY > ear + 1e-6) { + parts.push(`L ${fmt(bodyLeft)} ${fmt(ear)}`) + } + } else { + parts.push(`L ${fmt(bodyRight)} ${fmt(H)}`, `L ${fmt(bodyLeft)} ${fmt(H)}`) + if (ear > 0) { + parts.push(`L ${fmt(bodyLeft)} ${fmt(ear)}`) + } + } + + if (ear > 0) { + // Left outward ear: center (0, ear), sweep 0 through the ear square back to tip. + parts.push(`A ${fmt(ear)} ${fmt(ear)} 0 0 0 ${fmt(0)} ${fmt(0)}`) + } + + parts.push('Z') + return parts.join(' ').replace(/\s+/g, ' ').trim() +} + +/** + * SVG path `d` for the filled notch (includes outward ears). + * Empty string when the box is degenerate. + * + * `cornerSmoothing` is accepted for API stability but **ignored**: notch paint + * is circular. Passing screen ξ here previously caused MUST-FIX kinks. + */ +export function notchContinuousPathD( + bodyWidth: number, + height: number, + topRadius: number, + bottomRadius: number, + _cornerSmoothing = 0, +): string { + void _cornerSmoothing + const ear = Math.max(0, topRadius) + let bot = Math.max(0, bottomRadius) + const W = bodyWidth + 2 * ear + const H = height + if (bodyWidth <= 0 || W <= 0 || H <= 0) return '' + + // Keep a non-negative mid wall: bottom fillet cannot eat the ear's vertical budget. + if (ear + bot > H) { + bot = Math.max(0, H - ear) + } + + return circularNotchPathD(bodyWidth, H, ear, bot) +} + +/** Outer box width including both ears. */ +export function notchContinuousOuterWidth(bodyWidth: number, topRadius: number): number { + return bodyWidth + 2 * Math.max(0, topRadius) +} + +/** CSS `clip-path: path("…")` value, or `none` when empty. */ +export function notchContinuousClipPath( + bodyWidth: number, + height: number, + topRadius: number, + bottomRadius: number, + cornerSmoothing = 0, +): string { + const d = notchContinuousPathD(bodyWidth, height, topRadius, bottomRadius, cornerSmoothing) + if (!d) return 'none' + return `path("${d}")` +} + +/** Curve token written on the cutout element when path paint is active. */ +export const NOTCH_CURVE_TOKEN = 'circular' as const + +/** Default smoothing for notch path paint (circular — not screen ξ). */ +export const NOTCH_PATH_DEFAULT_SMOOTHING = 0 diff --git a/packages/frame/src/reflect.ts b/packages/frame/src/reflect.ts index 73f38da..8c9d8c5 100644 --- a/packages/frame/src/reflect.ts +++ b/packages/frame/src/reflect.ts @@ -69,6 +69,25 @@ export function reflectMetrics(style: CSSStyleDeclaration, metrics: DeviceMetric 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`) + // Continuous applies only via DeviceFrame `#applyScreenClip` (inline Figma + // `clip-path` on `.screen` / `.body`) — not via these CSS vars. + // + // Do NOT zero CSS radius here. That path is gated on a successful clip; if + // the clip is `'none'`, circular labeled radii must remain. + if (shell.cornerCurve === 'continuous') { + style.setProperty('--device-screen-corner-shape', 'round') + style.removeProperty('--device-screen-css-border-radius') + style.removeProperty('--device-body-overflow') + style.setProperty('--device-screen-clip-path', 'none') + style.setProperty('--device-screen-continuous-mask', 'none') + } + else { + style.setProperty('--device-screen-corner-shape', 'round') + style.removeProperty('--device-screen-css-border-radius') + style.removeProperty('--device-screen-clip-path') + style.removeProperty('--device-body-overflow') + style.removeProperty('--device-screen-continuous-mask') + } style.setProperty('--device-bezel', `${shell.bezel}px`) const shellInsets = orientedShellInsets(shell.bezelInsets, metrics.orientation) style.setProperty('--device-bezel-top', `${shellInsets.top}px`) diff --git a/packages/frame/src/shell-radius.test.ts b/packages/frame/src/shell-radius.test.ts index 1efe631..54a0519 100644 --- a/packages/frame/src/shell-radius.test.ts +++ b/packages/frame/src/shell-radius.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { DEVICE_FRAME_TAG, defineDeviceFrame, type DeviceFrameElement } from './device-frame.js' import { findDevice, resolveDevice } from '@devicekit/devices' +import { + applyContinuousScreenPaint, + clearContinuousScreenPaint, + continuousScreenClipPath, +} from './continuous-corner-mask.js' defineDeviceFrame() @@ -31,29 +36,29 @@ 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(74) + expect(resolveDevice(findDevice('iPhone Duo (outer)')!).shell.bodyRadius).toBe(67) const el = mount({ device: 'iPhone Duo (outer)' }) expect(radiusVariables(el)).toEqual({ - x: '21px 73px 73px 21px', - y: '19px 73px 73px 19px', + x: '17px 66px 66px 17px', + y: '15px 66px 66px 15px', }) }) it('Duo outer rotates the same per-edge rule in landscape', () => { - expect(resolveDevice(findDevice('iPhone Duo (outer)')!).shell.bodyRadius).toBe(74) + expect(resolveDevice(findDevice('iPhone Duo (outer)')!).shell.bodyRadius).toBe(67) const el = mount({ device: 'iPhone Duo (outer)', orientation: 'landscape' }) expect(radiusVariables(el)).toEqual({ - x: '19px 19px 73px 73px', - y: '21px 21px 73px 73px', + x: '15px 15px 66px 66px', + y: '17px 17px 66px 66px', }) }) 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' }) + expect(cornerVariables(portrait)).toEqual({ topLeft: '8px', topRight: '59px', bottomRight: '59px', bottomLeft: '8px' }) const landscape = mount({ device: 'iPhone Duo (outer)', orientation: 'landscape' }) - expect(cornerVariables(landscape)).toEqual({ topLeft: '12px', topRight: '12px', bottomRight: '66px', bottomLeft: '66px' }) + expect(cornerVariables(landscape)).toEqual({ topLeft: '8px', topRight: '8px', bottomRight: '59px', bottomLeft: '59px' }) }) it('publishes body-radius-x/y for a non-uniform screenCorners profile even when bezelInsets stay uniform', () => { @@ -112,3 +117,100 @@ describe('legacy shell bodyRadius remains scalar', () => { expect(radiusVariables(el)).toEqual({ x: '', y: '' }) }) }) + + +describe('screen continuous paint path', () => { + it('iOS continuous applies inline Figma clip-path on screen and body (rounded chrome)', () => { + const ios = mount({ device: 'iPhone 17 Pro' }) + expect(ios.style.getPropertyValue('--device-screen-corner-shape')).toBe('round') + expect(ios.style.getPropertyValue('--device-screen-css-border-radius')).toBe('0') + expect(ios.style.getPropertyValue('--device-body-css-border-radius')).toBe('0') + expect(ios.style.getPropertyValue('--device-body-overflow')).toBe('hidden') + const iosScreen = ios.shadowRoot?.querySelector('.screen') as HTMLElement + const clip = iosScreen.style.clipPath || iosScreen.style.getPropertyValue('clip-path') + expect(clip.startsWith('path(')).toBe(true) + expect(iosScreen.style.getPropertyValue('mask-image')).toBe('none') + const body = ios.shadowRoot?.querySelector('.body') as HTMLElement + expect(body.style.overflow).toBe('hidden') + const bodyClip = body.style.clipPath || body.style.getPropertyValue('clip-path') + expect(bodyClip.startsWith('path(')).toBe(true) + + const android = mount({ device: 'Galaxy S24' }) + expect(android.style.getPropertyValue('--device-screen-corner-shape')).toBe('round') + expect(android.style.getPropertyValue('--device-screen-css-border-radius')).toBe('') + expect(android.style.getPropertyValue('--device-body-overflow')).toBe('') + const androidScreen = android.shadowRoot?.querySelector('.screen') as HTMLElement + expect(androidScreen.style.clipPath || androidScreen.style.getPropertyValue('clip-path')).toBe('') + }) + + it('clip builder returns a Figma path() clip for continuous paint', () => { + const clip = continuousScreenClipPath(80, 120, { topLeft: 20, topRight: 20, bottomRight: 20, bottomLeft: 20 }) + expect(clip.startsWith('path("')).toBe(true) + expect(clip).toContain('M ') + expect(continuousScreenClipPath(0, 10, { topLeft: 1, topRight: 1, bottomRight: 1, bottomLeft: 1 })).toBe('none') + }) +}) + +describe('applyContinuousScreenPaint fallback gate', () => { + function paintHost() { + const host = document.createElement('div') + const screen = document.createElement('div') + const body = document.createElement('div') + document.body.append(host, screen, body) + return { host, screen, body } + } + + it('when clip is none: keeps circular CSS radius (not 0) and clears body overrides', () => { + const { host, screen, body } = paintHost() + host.style.setProperty('--device-screen-css-border-radius', '0') + host.style.setProperty('--device-body-css-border-radius', '0') + host.style.setProperty('--device-body-overflow', 'hidden') + body.style.overflow = 'hidden' + + const mode = applyContinuousScreenPaint({ + screen, + body, + hostStyle: host.style, + clip: 'none', + }) + expect(mode).toBe('circular-fallback') + expect(host.style.getPropertyValue('--device-screen-css-border-radius')).toBe('') + expect(host.style.getPropertyValue('--device-body-css-border-radius')).toBe('') + expect(host.style.getPropertyValue('--device-body-overflow')).toBe('') + expect(body.style.overflow).toBe('') + expect(screen.style.clipPath || screen.style.getPropertyValue('clip-path')).toBe('') + }) + + it('when clip is path(): applies hard clip, zeroes CSS radii, applies bodyClip when provided', () => { + const { host, screen, body } = paintHost() + const clip = continuousScreenClipPath(40, 60, { topLeft: 10, topRight: 10, bottomRight: 10, bottomLeft: 10 }) + const bodyClip = continuousScreenClipPath(52, 72, { topLeft: 16, topRight: 16, bottomRight: 16, bottomLeft: 16 }) + expect(clip.startsWith('path("')).toBe(true) + const mode = applyContinuousScreenPaint({ + screen, + body, + hostStyle: host.style, + clip, + bodyClip, + }) + expect(mode).toBe('continuous-clip') + expect(host.style.getPropertyValue('--device-screen-css-border-radius')).toBe('0') + expect(host.style.getPropertyValue('--device-body-css-border-radius')).toBe('0') + expect(host.style.getPropertyValue('--device-body-overflow')).toBe('hidden') + expect(body.style.overflow).toBe('hidden') + expect(screen.style.clipPath).toBe(clip) + expect(screen.style.getPropertyValue('mask-image')).toBe('none') + expect(body.style.clipPath).toBe(bodyClip) + }) + + it('clearContinuousScreenPaint removes clip and continuous radius overrides', () => { + const { host, screen, body } = paintHost() + const clip = continuousScreenClipPath(40, 60, { topLeft: 10, topRight: 10, bottomRight: 10, bottomLeft: 10 }) + applyContinuousScreenPaint({ screen, body, hostStyle: host.style, clip }) + clearContinuousScreenPaint({ screen, body, hostStyle: host.style }) + expect(host.style.getPropertyValue('--device-screen-css-border-radius')).toBe('') + expect(host.style.getPropertyValue('--device-body-overflow')).toBe('') + expect(screen.style.clipPath || screen.style.getPropertyValue('clip-path')).toBe('') + expect(body.style.overflow).toBe('') + }) +}) diff --git a/packages/frame/src/status-bar-hidden-clean.test.ts b/packages/frame/src/status-bar-hidden-clean.test.ts index 0cf6a19..f1ae9d8 100644 --- a/packages/frame/src/status-bar-hidden-clean.test.ts +++ b/packages/frame/src/status-bar-hidden-clean.test.ts @@ -26,7 +26,7 @@ function cutoutEl(el: DeviceFrameElement): HTMLElement { return el.shadowRoot!.querySelector('.status-bar__notch')! } -const GEOMETRY_PROPS = ['height', 'width', 'top', 'left', 'border-radius'] as const +const GEOMETRY_PROPS = ['height', 'width', 'top', 'left', 'border-radius', 'clip-path'] as const afterEach(() => { document.body.innerHTML = '' diff --git a/packages/frame/src/status-bar-styles.ts b/packages/frame/src/status-bar-styles.ts index cc8e9b9..e4084b8 100644 --- a/packages/frame/src/status-bar-styles.ts +++ b/packages/frame/src/status-bar-styles.ts @@ -170,30 +170,49 @@ export const STATUS_BAR_STYLES = ` } /* - * A notch's bottom corners are rounded (cutoutBorderRadius), but where it meets - * the screen's top edge a real phone curves the OTHER way — the edge sweeps - * outward around the notch rather than cutting a straight corner. Two 6px - * squares astride the top corners, each a radial gradient centered on the - * corner nearest the notch, draw that outward curve; a pill or punch-hole + * A notch's bottom corners are rounded (cutoutBorderRadius / bottomRadius), but + * where it meets the screen's top edge a real phone curves the OTHER way — the + * edge sweeps outward around the notch rather than cutting a straight corner. + * Two squares astride the top corners, sized by --device-notch-top-radius + * (from CutoutSpec.topRadius, default 6), each a radial gradient centered on + * the corner nearest the notch, draw that outward curve; a pill or punch-hole * floats clear of the edge and needs none of this. */ + +/* Continuous clip-path bakes ears into the silhouette — hide legacy radial patches. */ +.status-bar__notch[data-notch-curve="circular"]::before, +.status-bar__notch[data-notch-curve="circular"]::after, +.status-bar__notch[data-notch-curve="continuous"]::before, +.status-bar__notch[data-notch-curve="continuous"]::after { + content: none; + display: none; +} + .status-bar__notch[data-shape="notch"]::before, .status-bar__notch[data-shape="notch"]::after { content: ""; position: absolute; top: 0; - width: 6px; - height: 6px; + width: var(--device-notch-top-radius, 6px); + height: var(--device-notch-top-radius, 6px); } .status-bar__notch[data-shape="notch"]::before { - left: -6px; - background: radial-gradient(circle at bottom left, transparent 6px, var(--device-cutout-color) 6.5px); + left: calc(-1 * var(--device-notch-top-radius, 6px)); + background: radial-gradient( + circle at bottom left, + transparent var(--device-notch-top-radius, 6px), + var(--device-cutout-color) calc(var(--device-notch-top-radius, 6px) + 0.5px) + ); } .status-bar__notch[data-shape="notch"]::after { - right: -6px; - background: radial-gradient(circle at bottom right, transparent 6px, var(--device-cutout-color) 6.5px); + right: calc(-1 * var(--device-notch-top-radius, 6px)); + background: radial-gradient( + circle at bottom right, + transparent var(--device-notch-top-radius, 6px), + var(--device-cutout-color) calc(var(--device-notch-top-radius, 6px) + 0.5px) + ); } /* Right-anchored at --sb-trailing — the battery host's own visual box. */ diff --git a/packages/frame/src/status-bar.ts b/packages/frame/src/status-bar.ts index ca9a648..5d69392 100644 --- a/packages/frame/src/status-bar.ts +++ b/packages/frame/src/status-bar.ts @@ -7,7 +7,15 @@ * leaves the document is easier to get right when one object starts and stops * it. */ -import { cutoutBorderRadius, cutoutLeft } from './cutout.js' +import { + cutoutBorderRadius, + cutoutLeft, + cutoutNotchContinuousClipPath, + cutoutPaintLeft, + cutoutPaintWidth, + cutoutTopRadius, + NOTCH_CURVE_TOKEN, +} from './cutout.js' import { computeStatusBarLayout, type StatusBarLayout } from './status-bar-layout.js' import { setStatusBarIconMetrics } from './status-bar-icons.js' import { cutoutFor, type Orientation, type ResolvedDevice } from '@devicekit/devices' @@ -214,22 +222,50 @@ export class StatusBar { this.#cutoutEl.style.removeProperty('top') this.#cutoutEl.style.removeProperty('left') this.#cutoutEl.style.removeProperty('border-radius') + this.#cutoutEl.style.removeProperty('--device-notch-top-radius') + this.#cutoutEl.style.removeProperty('clip-path') + this.#cutoutEl.style.removeProperty('-webkit-clip-path') + delete this.#cutoutEl.dataset.notchCurve return } - // The stylesheet keys shape-specific paint (the notch's outward-curving - // ears) off this attribute; geometry alone can't tell a notch from a pill. + // The stylesheet keys shape-specific paint (legacy radial ears) off this + // attribute; geometry alone can't tell a notch from a pill. this.#cutoutEl.dataset.shape = cutout.shape - this.#cutoutEl.style.width = `${cutout.width}px` this.#cutoutEl.style.height = `${cutout.height}px` this.#cutoutEl.style.top = `${cutout.top}px` const screenWidth = orientation === 'landscape' ? device.screen.height : device.screen.width - const left = cutoutLeft(cutout, screenWidth) - this.#cutoutEl.style.left = `${layout.edge === 'right' ? left - (screenWidth - layout.height) : left}px` - this.#cutoutEl.style.borderRadius = cutoutBorderRadius(cutout) + // Face ID notch: single circular clip-path (ears + bottomRadius). Pixel-gated + // vs CSS radial+radius gold — outward ears (sweep 0) + bottom fillets. + // Avoids CSS ::before/::after seam bias at the sharp body top corners. + // Legacy radial kept as stylesheet fallback when data-notch-curve is unset. + const notchClip = cutout.shape === 'notch' ? cutoutNotchContinuousClipPath(cutout) : null + if (notchClip) { + this.#cutoutEl.dataset.notchCurve = NOTCH_CURVE_TOKEN + this.#cutoutEl.style.width = `${cutoutPaintWidth(cutout)}px` + // Integer snap: fractional left made L/R ear AA look biased in crops. + const rawLeft = cutoutPaintLeft(cutout, screenWidth) + const left = Math.round(rawLeft) + this.#cutoutEl.style.left = `${layout.edge === 'right' ? left - (screenWidth - layout.height) : left}px` + // Shape comes from clip-path; zero CSS radius so it can't fight the path. + this.#cutoutEl.style.borderRadius = '0' + this.#cutoutEl.style.clipPath = notchClip + this.#cutoutEl.style.setProperty('-webkit-clip-path', notchClip) + this.#cutoutEl.style.setProperty('--device-notch-top-radius', `${cutoutTopRadius(cutout)}px`) + } else { + delete this.#cutoutEl.dataset.notchCurve + this.#cutoutEl.style.width = `${cutout.width}px` + // Keep fractional left for pill/circle (Duo punch-hole centering). + const left = cutoutLeft(cutout, screenWidth) + this.#cutoutEl.style.left = `${layout.edge === 'right' ? left - (screenWidth - layout.height) : left}px` + this.#cutoutEl.style.borderRadius = cutoutBorderRadius(cutout) + this.#cutoutEl.style.removeProperty('clip-path') + this.#cutoutEl.style.removeProperty('-webkit-clip-path') + this.#cutoutEl.style.removeProperty('--device-notch-top-radius') + } } - #tick(): void { + #tick(): void { this.#timeEl.textContent = currentClockText(new Date()) } diff --git a/packages/frame/src/styles-radius.test.ts b/packages/frame/src/styles-radius.test.ts index 48f628a..359b768 100644 --- a/packages/frame/src/styles-radius.test.ts +++ b/packages/frame/src/styles-radius.test.ts @@ -34,13 +34,13 @@ describe('DEVICE_FRAME_STYLES corner radius variables', () => { it('.body border-radius accepts per-corner elliptical variables with scalar fallback', () => { expect(ruleBody('.body')).toMatch( - /border-radius:\s*var\(--device-frame-radius,\s*var\(--device-body-radius-x,\s*calc\(var\(--device-body-radius\)\s*\+\s*var\(--device-frame-border-width\)\)\)\s*\/\s*var\(--device-body-radius-y,\s*calc\(var\(--device-body-radius\)\s*\+\s*var\(--device-frame-border-width\)\)\)\)/, + /border-radius:\s*var\(--device-body-css-border-radius,\s*var\(--device-frame-radius,\s*var\(--device-body-radius-x,\s*calc\(var\(--device-body-radius\)\s*\+\s*var\(--device-frame-border-width\)\)\)\s*\/\s*var\(--device-body-radius-y,\s*calc\(var\(--device-body-radius\)\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\)/, + /border-radius:\s*var\(--device-screen-css-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( @@ -49,4 +49,24 @@ describe('DEVICE_FRAME_STYLES corner radius variables', () => { expect(DEVICE_FRAME_STYLES).toMatch(pattern) } }) + + it('.screen declares corner-shape from --device-screen-corner-shape (continuous only on screen)', () => { + expect(ruleBody('.screen')).toMatch(/corner-shape:\s*var\(--device-screen-corner-shape\)/) + expect(ruleBody('.body')).not.toMatch(/corner-shape/) + }) + + it('keeps circular border-radius fallback vars; continuous paint is inline Figma clip-path', () => { + const screen = ruleBody('.screen') + // CSS-var clip-path hook stays present but inert for continuous; + // authoritative paint is inline Figma clip-path from DeviceFrame. + expect(screen).toMatch(/clip-path:\s*var\(--device-screen-clip-path/) + expect(screen).toMatch(/mask-image:\s*none/) + expect(screen).toMatch(/border-radius:\s*var\(--device-screen-css-border-radius,/) + expect(DEVICE_FRAME_STYLES).toMatch(/inline.*Figma|Figma.*clip-path/i) + expect(DEVICE_FRAME_STYLES).not.toMatch(/@supports\s+not\s*\(corner-shape:\s*squircle\)/) + const body = ruleBody('.body') + expect(body).toMatch(/overflow:\s*var\(--device-body-overflow,\s*hidden\)/) + expect(body).toMatch(/--device-body-css-border-radius/) + }) }) + diff --git a/packages/frame/src/styles.ts b/packages/frame/src/styles.ts index 6ad165b..d1b45c3 100644 --- a/packages/frame/src/styles.ts +++ b/packages/frame/src/styles.ts @@ -44,6 +44,10 @@ export const DEVICE_FRAME_STYLES = ` --device-screen-radius-top-right: 38px; --device-screen-radius-bottom-right: 38px; --device-screen-radius-bottom-left: 38px; + --device-screen-corner-shape: round; + --device-screen-continuous-mask: none; + --device-screen-clip-path: none; + --device-body-overflow: hidden; --device-bezel: 0px; --device-bezel-top: 0px; --device-bezel-right: 0px; @@ -65,7 +69,10 @@ export const DEVICE_FRAME_STYLES = ` radius math below stays sized for the old, thinner border. */ --device-frame-border: var(--device-frame-border-width) solid rgba(255, 255, 255, 0.08); --device-frame-background: #0b0b0c; + /* Legacy box-shadow token kept for hosts; continuous chrome uses filter + (--device-frame-filter) so the outer glow follows the rounded clip-path. */ --device-frame-shadow: 0 18px 50px rgba(16, 20, 24, 0.22); + --device-frame-filter: drop-shadow(0 18px 50px rgba(16, 20, 24, 0.22)); --device-cutout-color: #000; /* The status bar is transparent by default, so without a navigation-bar slot this is what shows behind the clock — a real screen is never the bezel @@ -94,11 +101,19 @@ export const DEVICE_FRAME_STYLES = ` height: var(--device-height); padding: var(--device-bezel-top) var(--device-bezel-right) var(--device-bezel-bottom) var(--device-bezel-left); min-height: 0; - overflow: hidden; + /* Continuous screens: --device-body-overflow: hidden + --device-body-css-border-radius: 0 + with an inline continuous clip-path on .body (outer chrome rounded to + screenRadius + bezel; no side buttons). Rectangular overflow alone left a + square bezel; circular body radius + overflow ate free corners (round2). */ + overflow: var(--device-body-overflow, hidden); border: var(--device-frame-border); - border-radius: var(--device-frame-radius, var(--device-body-radius-x, calc(var(--device-body-radius) + var(--device-frame-border-width))) / var(--device-body-radius-y, calc(var(--device-body-radius) + var(--device-frame-border-width)))); + border-radius: var(--device-body-css-border-radius, var(--device-frame-radius, var(--device-body-radius-x, calc(var(--device-body-radius) + var(--device-frame-border-width))) / var(--device-body-radius-y, calc(var(--device-body-radius) + var(--device-frame-border-width))))); background: var(--device-frame-background); - box-shadow: var(--device-frame-shadow); + /* drop-shadow follows clip-path silhouette; box-shadow would be clipped away + once continuous outer clip is applied. Hosts can still zero it via the var + by setting --device-frame-shadow: none (filter mirrors that when none). */ + box-shadow: none; + filter: var(--device-frame-filter, drop-shadow(0 18px 50px rgba(16, 20, 24, 0.22))); } /* Fills its container with no phone pretense: no bezel, no status bar, no home @@ -117,10 +132,15 @@ export const DEVICE_FRAME_STYLES = ` border: 0; border-radius: 0; box-shadow: none; + filter: none; } :host([embedded]) .screen { border-radius: 0; + corner-shape: round; + clip-path: none; + -webkit-mask-image: none; + mask-image: none; } :host([hidden]) { @@ -137,8 +157,17 @@ export const DEVICE_FRAME_STYLES = ` background: var(--device-screen-background); /* 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-top-left) var(--device-screen-radius-effective-top-right) var(--device-screen-radius-effective-bottom-right) var(--device-screen-radius-effective-bottom-left); + --device-frame-radius override never asks for a negative radius. + Continuous screens: paint authority is an **inline** Figma ξ clip-path set + by DeviceFrame (not these CSS vars). When that clip is applied, + --device-screen-css-border-radius is 0; if unavailable, labeled circular + effective radii below remain (never a square clip). */ + border-radius: var(--device-screen-css-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)); + corner-shape: var(--device-screen-corner-shape); + /* Inert for continuous — inline clip-path on .screen is authoritative. */ + clip-path: var(--device-screen-clip-path, none); + -webkit-mask-image: none; + mask-image: none; } /* A centered ring makes a desktop mouse read as a touch point while it is @@ -233,9 +262,10 @@ export const DEVICE_FRAME_STYLES = ` ${STATUS_BAR_STYLES} /* - * Home-indicator pill — an absolute overlay at the device bottom, present only - * on gesture-bar devices (the home-button class has bottom inset 0). It is NOT - * in flow and is transparent, so whatever the screen renders behind it shows + * Home-indicator pill — iOS-only absolute overlay at the device bottom (Android + * / Harmony gesture nav do not use this chrome). Present only when os === 'ios' + * and bottom safe inset > 0 (home-button iPhones have inset 0). It is NOT in + * flow and is transparent, so whatever the screen renders behind it shows * through; content reserves bottom space via its own safe-area handling. */ .home-indicator { From 605a98924671c5e6e5ccd914a7ddb6290507797e Mon Sep 17 00:00:00 2001 From: Grok Bot Date: Fri, 30 Oct 2026 02:58:07 +0800 Subject: [PATCH 3/5] feat(screen-qa): all-device render harness and shot/audit scripts - packages/frame/screen-qa Vite page rendering every DEVICES preset - scripts/shot-all-devices.mjs headless screenshots - scripts/audit-non-ios-home-indicator.mjs asserts no HI on non-iOS --- packages/frame/screen-qa/README.md | 38 ++++ packages/frame/screen-qa/index.html | 26 +++ packages/frame/screen-qa/main.ts | 137 ++++++++++++++ packages/frame/screen-qa/style.css | 74 ++++++++ packages/frame/screen-qa/vite.config.ts | 18 ++ scripts/audit-non-ios-home-indicator.mjs | 216 ++++++++++++++++++++++ scripts/shot-all-devices.mjs | 224 +++++++++++++++++++++++ 7 files changed, 733 insertions(+) create mode 100644 packages/frame/screen-qa/README.md create mode 100644 packages/frame/screen-qa/index.html create mode 100644 packages/frame/screen-qa/main.ts create mode 100644 packages/frame/screen-qa/style.css create mode 100644 packages/frame/screen-qa/vite.config.ts create mode 100755 scripts/audit-non-ios-home-indicator.mjs create mode 100755 scripts/shot-all-devices.mjs diff --git a/packages/frame/screen-qa/README.md b/packages/frame/screen-qa/README.md new file mode 100644 index 0000000..be40cb7 --- /dev/null +++ b/packages/frame/screen-qa/README.md @@ -0,0 +1,38 @@ +# Screen-fidelity QA page + +Renders `.screen` with solid colored pads into every screen corner so continuous / +circular clip fidelity is visible in screenshots. + +Frames use **`immersive`** so pads extend under the transparent status bar. Without +that, top content starts below the status strip and continuous corners read as a +white horizontal cap. + +## URL modes + +| URL | What renders | +| --- | --- | +| `/` | Classic P0 six (iPhone 17 Pro, Duo outer/inner, Galaxy S24 / Ultra, Pixel 9 Pro) | +| `/?all=1` | Every preset in `@devicekit/devices` `DEVICES` | +| `/?device=` | One device (exact `DeviceProfile.name`, URL-encoded) | + +## Serve locally + +From the repo root (uses workspace TypeScript sources via Vite aliases — no build step): + +```sh +pnpm --filter @devicekit/frame exec vite --config screen-qa/vite.config.ts --host 127.0.0.1 +``` + +Then open **http://127.0.0.1:5179/** (or `/?all=1`, `/?device=iPhone%2017%20Pro`). + +From `packages/frame`: + +```sh +../../node_modules/.bin/vite --config screen-qa/vite.config.ts --host 127.0.0.1 +``` + +Accept on the **screen content continuous/circular silhouette** (color meets black +bezel along the corner). Outer chrome should be rounded and concentric. No side buttons. + +Headless capture of every preset writes to `/workspace/devicekit-qa-shots/all-devices/` +via `scripts/shot-all-devices.mjs` (separate Chrome process, not the desktop browser). diff --git a/packages/frame/screen-qa/index.html b/packages/frame/screen-qa/index.html new file mode 100644 index 0000000..dca802e --- /dev/null +++ b/packages/frame/screen-qa/index.html @@ -0,0 +1,26 @@ + + + + + + devicekit screen-fidelity QA + + + + +
+

devicekit screen-fidelity QA

+

+ immersive content pads fill screen corners (including under the status bar). + Accept continuous silhouette on .screen; outer black chrome should be concentric. + No side buttons. +

+
+ + + + diff --git a/packages/frame/screen-qa/main.ts b/packages/frame/screen-qa/main.ts new file mode 100644 index 0000000..31236e9 --- /dev/null +++ b/packages/frame/screen-qa/main.ts @@ -0,0 +1,137 @@ +/** + * Renders device screens with content painted into every corner so a headless + * Chrome (or browser) can screenshot continuous/circular clip fidelity. + * + * Uses `immersive` so the default slot covers the full screen (including under + * the transparent status bar). Without that, top content starts below the + * status strip and the continuous corner reads as a white horizontal "cap". + * + * URL modes: + * / — Classic P0 six (default, fast local check) + * /?all=1 — every preset in DEVICES + * /?device= — single device (exact `DeviceProfile.name`) + * + * Serve: from repo root, + * pnpm --filter @devicekit/frame exec vite --config screen-qa/vite.config.ts + * or see ./README.md. + */ +import { defineDeviceFrame, type DeviceFrameElement } from '@devicekit/frame' +import { DEVICES, findDevice, resolveDevice } from '@devicekit/devices' + +defineDeviceFrame() + +const CLASSIC_DEFAULT = [ + 'iPhone 17 Pro', + 'iPhone Duo (outer)', + 'iPhone Duo (inner)', + 'Galaxy S24', + 'Galaxy S24 Ultra', + 'Pixel 9 Pro', +] as const + +/** Corner pad must exceed the largest continuous screen radius (~62). */ +const CORNER_PAD_PX = 140 + +function namesFromQuery(): string[] { + const params = new URLSearchParams(location.search) + const single = params.get('device') + if (single !== null && single !== '') { + return [single] + } + if (params.get('all') === '1' || params.get('all') === 'true') { + return DEVICES.map((d) => d.name) + } + return [...CLASSIC_DEFAULT] +} + +function buildCard(name: string): HTMLElement { + const profile = findDevice(name) + if (!profile) { + const missing = document.createElement('div') + missing.className = 'card card--missing' + missing.dataset.device = name + missing.dataset.error = 'missing' + missing.textContent = `missing preset: ${name}` + return missing + } + const resolved = resolveDevice(profile) + + const card = document.createElement('div') + card.className = 'card' + card.dataset.device = name + card.dataset.os = resolved.os + card.dataset.width = String(resolved.screen.width) + card.dataset.height = String(resolved.screen.height) + card.dataset.radius = String(resolved.shell.screenRadius) + card.dataset.curve = resolved.shell.cornerCurve + + const label = document.createElement('div') + label.className = 'card__label' + const corners = resolved.shell.screenCorners + label.textContent = + `${name} · ${resolved.screen.width}×${resolved.screen.height}` + + ` · r=${resolved.shell.screenRadius}` + + (corners.topLeft !== corners.topRight || + corners.topRight !== corners.bottomRight || + corners.bottomRight !== corners.bottomLeft + ? ` (${corners.topLeft}/${corners.topRight}/${corners.bottomRight}/${corners.bottomLeft})` + : '') + + ` · ${resolved.shell.cornerCurve}` + + const frame = document.createElement('device-frame') as DeviceFrameElement + frame.setAttribute('device', name) + frame.setAttribute('status-bar', '9:41') + // Edge-to-edge: colored pads fill under the status bar into the squircle. + frame.setAttribute('immersive', '') + // Fallback if a pad fails to cover — mid stripe, not pure white. + frame.style.setProperty('--device-screen-background', '#e9ecef') + frame.style.setProperty('--qa-corner-pad', `${CORNER_PAD_PX}px`) + + const fill = document.createElement('div') + fill.className = 'corner-fill' + fill.style.position = 'relative' + const meta = document.createElement('div') + meta.className = 'corner-fill__meta' + meta.textContent = + `safe ${resolved.safeAreaInsets.top}/${resolved.safeAreaInsets.right}/` + + `${resolved.safeAreaInsets.bottom}/${resolved.safeAreaInsets.left}` + + (resolved.cutout + ? ` · cutout ${resolved.cutout.shape} ${resolved.cutout.width}×${resolved.cutout.height}` + : ' · no cutout') + + ' · immersive' + fill.append(meta) + frame.append(fill) + + card.append(label, frame) + return card +} + +const gallery = document.getElementById('gallery') +if (!gallery) throw new Error('#gallery missing') + +const names = namesFromQuery() +document.documentElement.dataset.mode = + new URLSearchParams(location.search).get('device') + ? 'single' + : new URLSearchParams(location.search).get('all') + ? 'all' + : 'classic' + +const headerMeta = document.querySelector('header p') +if (headerMeta && names.length) { + headerMeta.append( + document.createTextNode(` · rendering ${names.length} device${names.length === 1 ? '' : 's'}`), + ) +} + +for (const name of names) gallery.append(buildCard(name)) + +// Signal for headless capture scripts once custom elements + layout settle. +void customElements.whenDefined('device-frame').then(async () => { + await document.fonts.ready + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))) + document.documentElement.dataset.qaReady = '1' + document.documentElement.dataset.qaCount = String( + gallery.querySelectorAll('.card[data-device]:not([data-error])').length, + ) +}) diff --git a/packages/frame/screen-qa/style.css b/packages/frame/screen-qa/style.css new file mode 100644 index 0000000..0521fc9 --- /dev/null +++ b/packages/frame/screen-qa/style.css @@ -0,0 +1,74 @@ +* { box-sizing: border-box; } +body { + margin: 0; + padding: 24px; + font: 14px/1.4 system-ui, sans-serif; + background: #1a1b1e; + color: #e8e8ea; +} +header { margin-bottom: 20px; max-width: 720px; } +h1 { font-size: 20px; margin: 0 0 8px; } +code { background: #2a2b30; padding: 1px 4px; border-radius: 4px; } +.gallery { + display: flex; + flex-wrap: wrap; + gap: 28px 32px; + align-items: flex-end; +} +.card { + display: flex; + flex-direction: column; + gap: 8px; +} +.card__label { + font-size: 12px; + color: #a0a2a8; +} +/* + * Solid rectangular pads (not diagonal wedges) so the continuous clip silhouette + * is unambiguous: color meets the bezel along the full squircle arc. Pad size is + * set on the host as --qa-corner-pad (default 140px > max screenRadius 62). + */ +.corner-fill { + width: 100%; + height: 100%; + --pad: var(--qa-corner-pad, 140px); + background: + linear-gradient(#ff4d6d, #ff4d6d) top left / var(--pad) var(--pad) no-repeat, + linear-gradient(#4dabf7, #4dabf7) top right / var(--pad) var(--pad) no-repeat, + linear-gradient(#69db7c, #69db7c) bottom left / var(--pad) var(--pad) no-repeat, + linear-gradient(#ffd43b, #ffd43b) bottom right / var(--pad) var(--pad) no-repeat, + repeating-linear-gradient( + 0deg, + #f8f9fa 0, + #f8f9fa 24px, + #e9ecef 24px, + #e9ecef 48px + ); +} +.corner-fill__meta { + position: absolute; + left: 12px; + right: 12px; + bottom: 48px; + padding: 8px 10px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.55); + color: #fff; + font: 11px/1.35 ui-monospace, monospace; + pointer-events: none; +} + +html[data-mode='single'] body { + padding: 16px; +} +html[data-mode='single'] header { + display: none; +} +.card--missing { + padding: 24px; + border: 1px solid #ff6b6b; + border-radius: 8px; + color: #ffc9c9; + max-width: 420px; +} diff --git a/packages/frame/screen-qa/vite.config.ts b/packages/frame/screen-qa/vite.config.ts new file mode 100644 index 0000000..310bf74 --- /dev/null +++ b/packages/frame/screen-qa/vite.config.ts @@ -0,0 +1,18 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' + +const root = path.dirname(fileURLToPath(import.meta.url)) +const frameSrc = path.resolve(root, '../src') +const devicesSrc = path.resolve(root, '../../devices/src') + +export default defineConfig({ + root, + server: { host: '127.0.0.1', port: 5179, strictPort: true }, + resolve: { + alias: { + '@devicekit/frame': frameSrc + '/index.ts', + '@devicekit/devices': devicesSrc + '/index.ts', + }, + }, +}) diff --git a/scripts/audit-non-ios-home-indicator.mjs b/scripts/audit-non-ios-home-indicator.mjs new file mode 100755 index 0000000..746d4c2 --- /dev/null +++ b/scripts/audit-non-ios-home-indicator.mjs @@ -0,0 +1,216 @@ +#!/usr/bin/env node +/** + * Full non-iOS home-indicator audit via headless Chrome (puppeteer-core). + * For every android+harmony device: open screen-qa, assert .home-indicator is + * hidden in shadow DOM, and record bottomSafe / statusBarStyle / cornerCurve. + * Also probes iOS Duo outer+inner for expected visible pill. + * + * Writes: + * /workspace/devicekit-qa-shots/all-devices/NON-IOS-HOME-INDICATOR.json + * /workspace/devicekit-qa-shots/all-devices/NON-IOS-CHROME-AUDIT.md (partial; research appended later) + */ +import fs from 'node:fs/promises' +import path from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' + +const require = createRequire('/tmp/shot-tools/package.json') +const puppeteer = require('puppeteer-core') + +const repoRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))) +const BASE_URL = process.env.BASE_URL ?? 'http://127.0.0.1:5179' +const OUT_DIR = process.env.OUT_DIR ?? '/workspace/devicekit-qa-shots/all-devices' +const CHROME = process.env.CHROME ?? '/usr/bin/google-chrome' +const CONCURRENCY = Number(process.env.CONCURRENCY ?? 4) + +async function loadDeviceNames() { + const genPath = path.join(repoRoot, 'packages/devices/src/device-names.generated.ts') + const text = await fs.readFile(genPath, 'utf8') + const start = text.indexOf('{') + const end = text.lastIndexOf('}') + const body = text.slice(start, end + 1) + return [...body.matchAll(/:\s*'((?:\\'|[^'])*)'/g)].map((m) => m[1].replace(/\\'/g, "'")) +} + +async function loadOsMap() { + // Parse presets lightly: name + os from android/harmony/ios.ts + const map = new Map() + for (const file of ['android.ts', 'harmony.ts', 'ios.ts']) { + const text = await fs.readFile(path.join(repoRoot, 'packages/devices/src/presets', file), 'utf8') + const osDefault = file.startsWith('android') ? 'android' : file.startsWith('harmony') ? 'harmony' : 'ios' + // Each device block has name: '...' and os: '...' + const blocks = text.split(/\n \{\n/) + for (const block of blocks) { + const nameM = block.match(/name:\s*'((?:\\'|[^'])*)'/) + if (!nameM) continue + const osM = block.match(/os:\s*'(\w+)'/) + map.set(nameM[1].replace(/\\'/g, "'"), osM?.[1] ?? osDefault) + } + } + return map +} + +async function waitQaReady(page, timeout = 45000) { + await page.waitForFunction( + () => document.documentElement.dataset.qaReady === '1', + { timeout }, + ) +} + +async function probeOne(browser, name, expectedOs) { + const page = await browser.newPage() + try { + await page.setViewport({ width: 900, height: 1600, deviceScaleFactor: 1 }) + const url = `${BASE_URL}/?device=${encodeURIComponent(name)}` + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) + await waitQaReady(page) + const missing = await page.$('.card--missing') + if (missing) { + return { name, os: expectedOs, pass: false, error: 'missing preset' } + } + const info = await page.evaluate(() => { + const host = document.querySelector('device-frame') + if (!host?.shadowRoot) return { error: 'no device-frame shadow' } + const hi = host.shadowRoot.querySelector('.home-indicator') + const sb = host.shadowRoot.querySelector('.status-bar') + const screen = host.shadowRoot.querySelector('.screen') + const bottomSafe = host.style.getPropertyValue('--device-safe-area-bottom') + const curve = host.dataset?.curve + ?? host.style.getPropertyValue('--device-corner-curve') + ?? '' + // computed visibility of ::before is not directly available; use hidden + getComputedStyle + const hiHidden = Boolean(!hi || hi.hidden || hi.hasAttribute('hidden')) + let beforeDisplay = null + let beforeContent = null + if (hi && !hi.hidden) { + const cs = getComputedStyle(hi, '::before') + beforeDisplay = cs.display + beforeContent = cs.content + } + const statusStyle = sb?.dataset?.style ?? null + const notch = host.shadowRoot.querySelector('.status-bar__notch') + const notchHidden = !notch || notch.hidden + return { + hiHidden, + beforeDisplay, + beforeContent, + bottomSafe, + statusStyle, + notchHidden, + osAttr: host.getAttribute('os') || host.dataset?.os || null, + cardOs: document.querySelector('.card[data-device]')?.dataset?.os ?? null, + cardCurve: document.querySelector('.card[data-device]')?.dataset?.curve ?? null, + screenMask: screen?.style?.webkitMaskImage || screen?.style?.maskImage || '', + } + }) + if (info.error) return { name, os: expectedOs, pass: false, error: info.error } + + const bottomPx = Number.parseFloat(String(info.bottomSafe || '0')) || 0 + const isIos = expectedOs === 'ios' + // Non-iOS must hide; iOS with bottom>0 must show + let pass + let expectHidden + if (!isIos) { + expectHidden = true + pass = info.hiHidden === true + } else { + expectHidden = bottomPx <= 0 + pass = info.hiHidden === expectHidden + } + + // Chrome bleed flags for non-iOS + const bleed = [] + if (!isIos) { + if (info.cardCurve === 'continuous') bleed.push('cornerCurve=continuous') + if (info.statusStyle === 'ios') bleed.push('statusBarStyle=ios') + if (!info.hiHidden) bleed.push('home-indicator-visible') + } + + return { + name, + os: expectedOs, + bottomSafe: bottomPx, + hiHidden: info.hiHidden, + expectHidden, + pass, + statusBarStyle: info.statusStyle, + cornerCurve: info.cardCurve, + notchHidden: info.notchHidden, + bleed, + } + } catch (err) { + return { name, os: expectedOs, pass: false, error: String(err?.message ?? err) } + } finally { + await page.close().catch(() => {}) + } +} + +async function mapPool(items, limit, fn) { + const results = new Array(items.length) + let i = 0 + async function worker() { + while (i < items.length) { + const idx = i++ + results[idx] = await fn(items[idx], idx) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker())) + return results +} + +async function main() { + const names = await loadDeviceNames() + const osMap = await loadOsMap() + await fs.mkdir(OUT_DIR, { recursive: true }) + + const nonIos = names.filter((n) => (osMap.get(n) ?? 'ios') !== 'ios') + const duo = names.filter((n) => n.startsWith('iPhone Duo')) + const targets = [...nonIos, ...duo] + + console.log(`nonIos=${nonIos.length} duo=${duo.length} base=${BASE_URL}`) + + const browser = await puppeteer.launch({ + executablePath: CHROME, + headless: true, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }) + + let results + try { + results = await mapPool(targets, CONCURRENCY, async (name) => { + const os = osMap.get(name) ?? 'unknown' + const r = await probeOne(browser, name, os) + const mark = r.pass ? 'PASS' : 'FAIL' + console.log(`${mark} ${name} hiHidden=${r.hiHidden} bottom=${r.bottomSafe} ${r.bleed?.length ? 'bleed=' + r.bleed.join(',') : ''}${r.error ? ' err=' + r.error : ''}`) + return r + }) + } finally { + await browser.close() + } + + const nonIosResults = results.filter((r) => r.os !== 'ios') + const duoResults = results.filter((r) => r.os === 'ios') + const pass = nonIosResults.filter((r) => r.pass) + const fail = nonIosResults.filter((r) => !r.pass) + const bleedHits = nonIosResults.filter((r) => (r.bleed?.length ?? 0) > 0) + + const json = { + generated: new Date().toISOString(), + baseUrl: BASE_URL, + nonIosTotal: nonIosResults.length, + nonIosPass: pass.length, + nonIosFail: fail.length, + bleedHits: bleedHits.length, + duo: duoResults, + devices: nonIosResults, + } + await fs.writeFile(path.join(OUT_DIR, 'NON-IOS-HOME-INDICATOR.json'), JSON.stringify(json, null, 2)) + console.log(`\nnon-iOS home-indicator: ${pass.length}/${nonIosResults.length} pass, bleed=${bleedHits.length}`) + console.log('Duo:', duoResults.map((r) => `${r.name}: hiHidden=${r.hiHidden} pass=${r.pass}`).join('; ')) + if (fail.length) process.exitCode = 1 +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/scripts/shot-all-devices.mjs b/scripts/shot-all-devices.mjs new file mode 100755 index 0000000..119549e --- /dev/null +++ b/scripts/shot-all-devices.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node +/** + * Headless Chrome capture of every DEVICES preset via screen-qa. + * Uses a separate google-chrome process (puppeteer-core), not the desktop browser. + * + * Usage: + * node scripts/shot-all-devices.mjs + * BASE_URL=http://127.0.0.1:5179 OUT_DIR=/workspace/devicekit-qa-shots/all-devices \ + * node scripts/shot-all-devices.mjs + */ +import fs from 'node:fs/promises' +import path from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { pathToFileURL } from 'node:url' + +const require = createRequire('/tmp/shot-tools/package.json') +const puppeteer = require('puppeteer-core') + +const repoRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))) +const BASE_URL = process.env.BASE_URL ?? 'http://127.0.0.1:5179' +const OUT_DIR = process.env.OUT_DIR ?? '/workspace/devicekit-qa-shots/all-devices' +const INVENTORY = + process.env.INVENTORY ?? '/workspace/devicekit-qa-shots/full-device-audit/INVENTORY.json' +const CHROME = process.env.CHROME ?? '/usr/bin/google-chrome' +const CONCURRENCY = Number(process.env.CONCURRENCY ?? 4) + +function slugify(name) { + return name + .normalize('NFKD') + .replace(/[^\w.\-+]+/g, '-') + .replace(/^-+|-+$/g, '') + .replace(/-+/g, '-') + .toLowerCase() +} + +async function loadDeviceNames() { + // Prefer generated names (always in sync with DEVICES) without a TS loader. + // device-names.generated.ts exports `DEVICE_NAMES = { Key: 'Display Name', ... }`. + const genPath = path.join(repoRoot, 'packages/devices/src/device-names.generated.ts') + const text = await fs.readFile(genPath, 'utf8') + const start = text.indexOf('{') + const end = text.lastIndexOf('}') + const body = text.slice(start, end + 1) + // Values only: lines like ` iPhone_SE: 'iPhone SE',` + return [...body.matchAll(/:\s*'((?:\\'|[^'])*)'/g)].map((m) => m[1].replace(/\\'/g, "'")) +} + +async function loadInventory() { + try { + const raw = JSON.parse(await fs.readFile(INVENTORY, 'utf8')) + return new Map(raw.map((row) => [row.name, row])) + } catch { + return new Map() + } +} + +async function waitQaReady(page, timeout = 45000) { + await page.waitForFunction( + () => document.documentElement.dataset.qaReady === '1', + { timeout }, + ) +} + +async function shotOne(browser, name, outPath) { + const page = await browser.newPage() + try { + await page.setViewport({ width: 900, height: 1600, deviceScaleFactor: 2 }) + const url = `${BASE_URL}/?device=${encodeURIComponent(name)}` + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) + await waitQaReady(page) + const card = await page.$('.card[data-device]:not([data-error])') + if (!card) { + const missing = await page.$('.card--missing') + if (missing) { + return { name, ok: false, error: 'missing preset', path: null } + } + return { name, ok: false, error: 'card not found', path: null } + } + // Pad viewport if the device (tablet / fold inner) is taller than default. + const box = await card.boundingBox() + if (box) { + const needW = Math.ceil(box.x + box.width + 32) + const needH = Math.ceil(box.y + box.height + 32) + const vp = page.viewport() + if (needW > vp.width || needH > vp.height) { + await page.setViewport({ + width: Math.max(vp.width, needW), + height: Math.max(vp.height, needH), + deviceScaleFactor: 2, + }) + await waitQaReady(page) + } + } + await card.screenshot({ path: outPath, type: 'png' }) + const st = await fs.stat(outPath) + if (st.size < 500) { + return { name, ok: false, error: `png too small (${st.size} B)`, path: outPath } + } + const meta = await page.$eval('.card[data-device]:not([data-error])', (el) => ({ + width: el.dataset.width, + height: el.dataset.height, + radius: el.dataset.radius, + curve: el.dataset.curve, + os: el.dataset.os, + })) + return { name, ok: true, path: outPath, ...meta, bytes: st.size } + } catch (err) { + return { name, ok: false, error: String(err?.message ?? err), path: null } + } finally { + await page.close().catch(() => {}) + } +} + +async function mapPool(items, limit, fn) { + const results = new Array(items.length) + let i = 0 + async function worker() { + while (i < items.length) { + const idx = i++ + results[idx] = await fn(items[idx], idx) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker())) + return results +} + +async function main() { + const names = await loadDeviceNames() + const inventory = await loadInventory() + await fs.mkdir(OUT_DIR, { recursive: true }) + + // Clear prior PNGs only when FORCE=1 + if (process.env.FORCE === '1') { + for (const ent of await fs.readdir(OUT_DIR)) { + if (ent.endsWith('.png')) await fs.unlink(path.join(OUT_DIR, ent)) + } + } + + console.log(`devices=${names.length} base=${BASE_URL} out=${OUT_DIR} chrome=${CHROME}`) + + const browser = await puppeteer.launch({ + executablePath: CHROME, + headless: true, + args: [ + '--no-sandbox', + '--disable-gpu', + '--disable-dev-shm-usage', + '--font-render-hinting=none', + ], + }) + + let results + try { + results = await mapPool(names, CONCURRENCY, async (name) => { + const slug = slugify(name) + const outPath = path.join(OUT_DIR, `${slug}.png`) + try { + const st = await fs.stat(outPath) + if (st.size >= 500 && process.env.FORCE !== '1') { + console.log(`SKIP ${name} → ${path.basename(outPath)}`) + // Probe meta from inventory later; mark ok with existing file + return { name, ok: true, path: outPath, slug, skipped: true, bytes: st.size } + } + } catch { /* missing */ } + const r = await shotOne(browser, name, outPath) + const mark = r.ok ? 'OK' : 'FAIL' + console.log(`${mark} ${name} → ${r.ok ? path.basename(outPath) : r.error}`) + return { ...r, slug } + }) + } finally { + await browser.close() + } + + const ok = results.filter((r) => r.ok) + const fail = results.filter((r) => !r.ok) + + const lines = [ + '# All-device screen-qa screenshots', + '', + `Generated: ${new Date().toISOString()} (box local Asia/Shanghai)`, + `Source: \`${BASE_URL}/?device=\` (immersive + corner pads)`, + `Count: **${ok.length}** OK / **${fail.length}** failed / **${names.length}** DEVICES`, + '', + '| Device | PNG | Screen | Radius | Curve | Verification |', + '| --- | --- | ---: | ---: | --- | --- |', + ] + + for (const r of results) { + const inv = inventory.get(r.name) + const ver = inv?.verification ?? '—' + const screen = + r.width && r.height ? `${r.width}×${r.height}` : inv ? `${inv.width}×${inv.height}` : '—' + const radius = r.radius ?? inv?.screenRadius ?? '—' + const curve = r.curve ?? inv?.cornerCurve ?? '—' + const png = r.ok ? `\`${r.slug}.png\`` : `FAIL: ${r.error}` + lines.push(`| ${r.name} | ${png} | ${screen} | ${radius} | ${curve} | ${ver} |`) + } + + if (fail.length) { + lines.push('', '## Failures', '') + for (const f of fail) lines.push(`- **${f.name}**: ${f.error}`) + } + + await fs.writeFile(path.join(OUT_DIR, 'INDEX.md'), lines.join('\n') + '\n') + await fs.writeFile( + path.join(OUT_DIR, 'results.json'), + JSON.stringify({ count: names.length, ok: ok.length, fail: fail.length, results }, null, 2), + ) + + console.log(`\nDone: ${ok.length}/${names.length} PNGs in ${OUT_DIR}`) + if (fail.length) { + console.error('Failures:', fail.map((f) => f.name).join(', ')) + process.exitCode = 1 + } + if (ok.length !== names.length) { + process.exitCode = 1 + } +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From c39c0db97393851ac13e9113856a58d89695add3 Mon Sep 17 00:00:00 2001 From: Grok Bot Date: Fri, 30 Oct 2026 02:59:00 +0800 Subject: [PATCH 4/5] test(frame): align cutout assertions with circular notch paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expect paint width = body + 2·ear and data-notch-curve=circular - Skip right-ear icon budget for right-edge punch holes (centerX ≳ 0.9) --- packages/frame/src/cutout.test.ts | 2 ++ packages/frame/src/cutout.ts | 2 +- packages/frame/src/device-frame.test.ts | 11 ++++++----- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/frame/src/cutout.test.ts b/packages/frame/src/cutout.test.ts index 1edafff..622f76f 100644 --- a/packages/frame/src/cutout.test.ts +++ b/packages/frame/src/cutout.test.ts @@ -34,10 +34,12 @@ describe('every top-edge tabled cutout leaves room for the horizontal status bar it('holds the icon group in the right ear for every top-edge device with a top-region cutout', () => { // Bottom camera bars (e.g. Flip cover cutout.top near screen bottom) are not status-bar cutouts — // they must not be held to status-icon ear clearance. + // Right-edge punch holes (centerX ≳ 0.9) put icons in the LEFT ear — skip right-ear budget. const tooNarrow = DEVICES .filter((d) => { const resolved = resolveDevice(d) if (resolved.cutout === null || statusBarEdgeFor(resolved, 'portrait') !== 'top') return false + if ((resolved.cutout.centerX ?? 0.5) >= 0.9) return false return resolved.cutout.top + resolved.cutout.height / 2 < resolved.screen.height / 2 }) .filter((d) => { diff --git a/packages/frame/src/cutout.ts b/packages/frame/src/cutout.ts index da65aa7..0afead7 100644 --- a/packages/frame/src/cutout.ts +++ b/packages/frame/src/cutout.ts @@ -74,7 +74,7 @@ export function statusBarEars(cutout: CutoutSpec | null, screenWidth: number): { * Face ID notch clip-path (circular ears + bottom; ξ=0). * * Figma ξ≈0.6 continuous is NOT used — ADV found leftover sidewall=0 melt + - * anisotropic ear arcs (`NOTCH-CONTINUOUS-VISUAL-ADV.md`). Path default is + * anisotropic ear arcs. Path default is * circular quarter-circles matching PaintCode / Bezel radii. */ export function cutoutNotchContinuousClipPath(cutout: CutoutSpec): string | null { diff --git a/packages/frame/src/device-frame.test.ts b/packages/frame/src/device-frame.test.ts index 07ebc4d..26aa901 100644 --- a/packages/frame/src/device-frame.test.ts +++ b/packages/frame/src/device-frame.test.ts @@ -180,13 +180,14 @@ describe('the cutout follows the current orientation', () => { it('shows the cutout in portrait, wired to the resolved metrics', () => { const el = mountFrame({ device: 'iPhone X' }) expect(cutoutEl(el).hidden).toBe(false) - // Legacy radial ears (::before/::after) + bottomRadius — clip-path path - // failed bidirectional image gate (NOTCH-BIDIRECTIONAL-IMAGE-ADV.md). + // Circular Face ID clip-path: paint box expands by ear fillets on each side. const ear = el.metrics.cutout!.topRadius ?? 6 - expect(cutoutEl(el).style.width).toBe(`${el.metrics.cutout!.width}px`) + const bodyW = el.metrics.cutout!.width + expect(cutoutEl(el).style.width).toBe(`${bodyW + 2 * ear}px`) expect(cutoutEl(el).style.height).toBe(`${el.metrics.cutout!.height}px`) - expect(cutoutEl(el).dataset.notchCurve).toBeUndefined() - expect(cutoutEl(el).style.clipPath || cutoutEl(el).style.getPropertyValue('clip-path')).toBe('') + expect(cutoutEl(el).dataset.notchCurve).toBe('circular') + const clip = cutoutEl(el).style.clipPath || cutoutEl(el).style.getPropertyValue('clip-path') + expect(clip.startsWith('path(')).toBe(true) expect(cutoutEl(el).style.getPropertyValue('--device-notch-top-radius')).toBe(`${ear}px`) }) From b5bd5879b2fc96bd3bf4d9df05e4e99ca1e10858 Mon Sep 17 00:00:00 2001 From: Grok Bot Date: Fri, 30 Oct 2026 18:53:50 +0800 Subject: [PATCH 5/5] chore: sync after rebase onto main (oxfmt, device-pages, lint nits) - Reformat iOS/Android presets for oxfmt from main - Regenerate docs/device-pages.md for radius/cutout updates - Drop unused imports / Array constructor lint nits in QA scripts --- docs/device-pages.md | 30 +++++++++---------- packages/devices/src/presets/android.ts | 5 +++- packages/devices/src/presets/ios.ts | 27 +++++++++++++++-- .../frame/src/continuous-corner-mask.test.ts | 2 +- packages/frame/src/continuous-corner-mask.ts | 2 +- scripts/audit-non-ios-home-indicator.mjs | 6 ++-- scripts/shot-all-devices.mjs | 3 +- 7 files changed, 48 insertions(+), 27 deletions(-) diff --git a/docs/device-pages.md b/docs/device-pages.md index e0d707c..9f2d799 100644 --- a/docs/device-pages.md +++ b/docs/device-pages.md @@ -64,7 +64,7 @@ Do not edit this file by hand. Run `pnpm generate:device-pages` after changing t | iPhone 18 Pro | ios | 402 × 874 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/iphone-18-pro/) | | iPhone 18 Pro Max | ios | 440 × 956 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/iphone-18-pro-max/) | | iPhone Duo (outer) | ios | 466 × 678 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/iphone-duo-outer/) | -| iPhone Duo (inner) | ios | 626 × 890 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/iphone-duo-inner/) | +| iPhone Duo (inner) | ios | 669 × 951 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/iphone-duo-inner/) | | iPhone 17e | ios | 390 × 844 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/iphone-17e/) | | iPhone 4 | ios | 320 × 480 | 2× | [Open demo](https://echotechfe.github.io/devicekit/devices/iphone-4/) | | iPhone 5/SE | ios | 320 × 568 | 2× | [Open demo](https://echotechfe.github.io/devicekit/devices/iphone-5-se/) | @@ -82,10 +82,10 @@ Do not edit this file by hand. Run `pnpm generate:device-pages` after changing t | Pixel 9 Pro XL | android | 448 × 997 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/pixel-9-pro-xl/) | | Pixel 10 | android | 412 × 924 | 2.625× | [Open demo](https://echotechfe.github.io/devicekit/devices/pixel-10/) | | Samsung Galaxy S8+ | android | 360 × 740 | 4× | [Open demo](https://echotechfe.github.io/devicekit/devices/samsung-galaxy-s8/) | -| Samsung Galaxy S20 Ultra | android | 412 × 915 | 3.5× | [Open demo](https://echotechfe.github.io/devicekit/devices/samsung-galaxy-s20-ultra/) | +| Samsung Galaxy S20 Ultra | android | 412 × 915 | 2.625× | [Open demo](https://echotechfe.github.io/devicekit/devices/samsung-galaxy-s20-ultra/) | | Surface Duo | android | 540 × 720 | 2.5× | [Open demo](https://echotechfe.github.io/devicekit/devices/surface-duo/) | | Surface Duo (inner) | android | 720 × 1114 | 2.5× | [Open demo](https://echotechfe.github.io/devicekit/devices/surface-duo-inner/) | -| Samsung Galaxy A55 | android | 360 × 800 | 2.25× | [Open demo](https://echotechfe.github.io/devicekit/devices/samsung-galaxy-a55/) | +| Samsung Galaxy A55 | android | 384 × 832 | 2.8125× | [Open demo](https://echotechfe.github.io/devicekit/devices/samsung-galaxy-a55/) | | Pixel 9 Pro Fold | android | 412 × 922 | 2.625× | [Open demo](https://echotechfe.github.io/devicekit/devices/pixel-9-pro-fold/) | | Pixel 9 Pro Fold (inner) | android | 836 × 842 | 2.625× | [Open demo](https://echotechfe.github.io/devicekit/devices/pixel-9-pro-fold-inner/) | | Galaxy Z Fold 6 | android | 412 × 968 | 2.625× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-z-fold-6/) | @@ -112,7 +112,7 @@ Do not edit this file by hand. Run `pnpm generate:device-pages` after changing t | Galaxy Z Flip 6 | android | 360 × 880 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-z-flip-6/) | | Galaxy Z Flip 6 Cover | android | 360 × 374 | 2× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-z-flip-6-cover/) | | Galaxy Z Flip 7 | android | 360 × 840 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-z-flip-7/) | -| Galaxy Z Flip 7 Cover | android | 474 × 524 | 2× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-z-flip-7-cover/) | +| Galaxy Z Flip 7 Cover | android | 361 × 399 | 2.625× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-z-flip-7-cover/) | | LG Optimus L70 | android | 384 × 640 | 1.25× | [Open demo](https://echotechfe.github.io/devicekit/devices/lg-optimus-l70/) | | Nexus 10 | android | 800 × 1280 | 2× | [Open demo](https://echotechfe.github.io/devicekit/devices/nexus-10/) | | Nexus 4 | android | 384 × 640 | 2× | [Open demo](https://echotechfe.github.io/devicekit/devices/nexus-4/) | @@ -138,20 +138,20 @@ Do not edit this file by hand. Run `pnpm generate:device-pages` after changing t | Galaxy Fold | android | 280 × 653 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-fold/) | | Galaxy S21 | android | 360 × 800 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s21/) | | Galaxy S21 Plus | android | 384 × 854 | 2.8125× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s21-plus/) | -| Galaxy S21 Ultra | android | 412 × 915 | 2.625× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s21-ultra/) | +| Galaxy S21 Ultra | android | 384 × 853 | 2.8125× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s21-ultra/) | | Galaxy S20 | android | 360 × 800 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s20/) | -| Galaxy S20 Plus | android | 384 × 854 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s20-plus/) | -| Samsung S21 FE | android | 360 × 800 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/samsung-s21-fe/) | +| Galaxy S20 Plus | android | 384 × 854 | 2.8125× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s20-plus/) | +| Samsung S21 FE | android | 360 × 780 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/samsung-s21-fe/) | | Galaxy Fold3 (Folded) | android | 320 × 872 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-fold3-folded/) | | Galaxy Fold3 (Unfolded) | android | 590 × 736 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-fold3-unfolded/) | -| Galaxy S22 | android | 360 × 800 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s22/) | -| Galaxy S22 Plus | android | 384 × 854 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s22-plus/) | -| Galaxy S22 Ultra | android | 412 × 915 | 2.625× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s22-ultra/) | -| Galaxy S23 | android | 360 × 800 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s23/) | -| Galaxy S23 Plus | android | 384 × 854 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s23-plus/) | -| Galaxy S23 Ultra | android | 412 × 915 | 2.625× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s23-ultra/) | -| Galaxy S24 Plus | android | 384 × 854 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s24-plus/) | -| Galaxy S24 Ultra | android | 412 × 915 | 2.625× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s24-ultra/) | +| Galaxy S22 | android | 360 × 780 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s22/) | +| Galaxy S22 Plus | android | 384 × 832 | 2.8125× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s22-plus/) | +| Galaxy S22 Ultra | android | 384 × 823 | 2.8125× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s22-ultra/) | +| Galaxy S23 | android | 360 × 780 | 3× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s23/) | +| Galaxy S23 Plus | android | 384 × 832 | 2.8125× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s23-plus/) | +| Galaxy S23 Ultra | android | 384 × 823 | 2.8125× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s23-ultra/) | +| Galaxy S24 Plus | android | 384 × 832 | 2.8125× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s24-plus/) | +| Galaxy S24 Ultra | android | 384 × 832 | 2.8125× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-s24-ultra/) | | Galaxy Z Flip 5 | android | 264 × 844 | 2.8× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-z-flip-5/) | | Galaxy Z Fold 4 | android | 344 × 882 | 2.8× | [Open demo](https://echotechfe.github.io/devicekit/devices/galaxy-z-fold-4/) | | OnePlus 12 | android | 412 × 915 | 2.625× | [Open demo](https://echotechfe.github.io/devicekit/devices/oneplus-12/) | diff --git a/packages/devices/src/presets/android.ts b/packages/devices/src/presets/android.ts index 03da536..ed01949 100644 --- a/packages/devices/src/presets/android.ts +++ b/packages/devices/src/presets/android.ts @@ -588,7 +588,10 @@ export const ANDROID_DEVICES: readonly PresetDeviceProfile[] = [ safeAreaInsets: { top: 0, bottom: 84 }, safeAreaInsetsLandscape: { right: 0 }, cutout: { shape: 'pill', width: 198, height: 84, top: 315, centerX: 0.726 }, - shell: { screenRadius: 6.1, screenCorners: { topLeft: 6.1, topRight: 6.1, bottomRight: 41.14, bottomLeft: 41.14 } }, + shell: { + screenRadius: 6.1, + screenCorners: { topLeft: 6.1, topRight: 6.1, bottomRight: 41.14, bottomLeft: 41.14 }, + }, userAgent: 'Mozilla/5.0 (Linux; Android 16; SM-F761U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.7827.55 Mobile Safari/537.36', releaseYear: 2025, diff --git a/packages/devices/src/presets/ios.ts b/packages/devices/src/presets/ios.ts index edeecf7..28e7455 100644 --- a/packages/devices/src/presets/ios.ts +++ b/packages/devices/src/presets/ios.ts @@ -23,11 +23,32 @@ const LEGACY_IPHONE_SHELL = { * only**, not a per-gen calibration. * - Bboxes: safearea / DeviceHub where cited (XR/11 **227×32**; minis unlocked). */ -const NOTCH_X = { shape: 'notch' as const, width: 209, height: 30, top: 0, bottomRadius: 20, topRadius: 6 } -const NOTCH_XS_MAX = { shape: 'notch' as const, width: 209, height: 30, top: 0, bottomRadius: 20, topRadius: 6 } +const NOTCH_X = { + shape: 'notch' as const, + width: 209, + height: 30, + top: 0, + bottomRadius: 20, + topRadius: 6, +} +const NOTCH_XS_MAX = { + shape: 'notch' as const, + width: 209, + height: 30, + top: 0, + bottomRadius: 20, + topRadius: 6, +} /** ear unset → DEFAULT_NOTCH_TOP_RADIUS legacy fallback (uncalibrated) */ const NOTCH_XR = { shape: 'notch' as const, width: 227, height: 32, top: 0, bottomRadius: 21.5 } -const NOTCH_11_PRO = { shape: 'notch' as const, width: 209, height: 30, top: 0, bottomRadius: 20, topRadius: 6 } +const NOTCH_11_PRO = { + shape: 'notch' as const, + width: 209, + height: 30, + top: 0, + bottomRadius: 20, + topRadius: 6, +} /** ear unset → DEFAULT 6 legacy; not calibrated */ const NOTCH_12 = { shape: 'notch' as const, width: 210, height: 32, top: 0, bottomRadius: 20 } const NOTCH_12_MAX = { shape: 'notch' as const, width: 210, height: 32, top: 0, bottomRadius: 20 } diff --git a/packages/frame/src/continuous-corner-mask.test.ts b/packages/frame/src/continuous-corner-mask.test.ts index f3173b0..e7d6907 100644 --- a/packages/frame/src/continuous-corner-mask.test.ts +++ b/packages/frame/src/continuous-corner-mask.test.ts @@ -139,7 +139,7 @@ describe('continuousScreenMaskDataURL (authoritative paint)', () => { const alpha = new Uint8Array(4) alpha[0] = 255 const png = encodeAlphaMaskPng(2, 2, alpha) - expect([...png.slice(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]) + expect(Array.from(png.slice(0, 8))).toEqual([137, 80, 78, 71, 13, 10, 26, 10]) }) }) diff --git a/packages/frame/src/continuous-corner-mask.ts b/packages/frame/src/continuous-corner-mask.ts index 1af51f0..9a59e84 100644 --- a/packages/frame/src/continuous-corner-mask.ts +++ b/packages/frame/src/continuous-corner-mask.ts @@ -21,7 +21,7 @@ * Fallback: if path is empty/`none`, keep labeled circular CSS radii. */ import type { CornerRadii } from '@devicekit/devices' -import { figmaContinuousClipPath, figmaContinuousRoundedRectPath, FIGMA_IOS_CORNER_SMOOTHING } from './figma-continuous-path.js' +import { figmaContinuousClipPath } from './figma-continuous-path.js' export { FIGMA_IOS_CORNER_SMOOTHING, figmaContinuousClipPath, figmaContinuousRoundedRectPath } from './figma-continuous-path.js' /** Apple continuous screens match a superellipse near n=5 (approximation, not CoreAnimation). */ diff --git a/scripts/audit-non-ios-home-indicator.mjs b/scripts/audit-non-ios-home-indicator.mjs index 746d4c2..7510261 100755 --- a/scripts/audit-non-ios-home-indicator.mjs +++ b/scripts/audit-non-ios-home-indicator.mjs @@ -75,9 +75,7 @@ async function probeOne(browser, name, expectedOs) { const sb = host.shadowRoot.querySelector('.status-bar') const screen = host.shadowRoot.querySelector('.screen') const bottomSafe = host.style.getPropertyValue('--device-safe-area-bottom') - const curve = host.dataset?.curve - ?? host.style.getPropertyValue('--device-corner-curve') - ?? '' + // computed visibility of ::before is not directly available; use hidden + getComputedStyle const hiHidden = Boolean(!hi || hi.hidden || hi.hasAttribute('hidden')) let beforeDisplay = null @@ -146,7 +144,7 @@ async function probeOne(browser, name, expectedOs) { } async function mapPool(items, limit, fn) { - const results = new Array(items.length) + const results = Array.from({ length: items.length }) let i = 0 async function worker() { while (i < items.length) { diff --git a/scripts/shot-all-devices.mjs b/scripts/shot-all-devices.mjs index 119549e..461501f 100755 --- a/scripts/shot-all-devices.mjs +++ b/scripts/shot-all-devices.mjs @@ -12,7 +12,6 @@ import fs from 'node:fs/promises' import path from 'node:path' import { createRequire } from 'node:module' import { fileURLToPath } from 'node:url' -import { pathToFileURL } from 'node:url' const require = createRequire('/tmp/shot-tools/package.json') const puppeteer = require('puppeteer-core') @@ -113,7 +112,7 @@ async function shotOne(browser, name, outPath) { } async function mapPool(items, limit, fn) { - const results = new Array(items.length) + const results = Array.from({ length: items.length }) let i = 0 async function worker() { while (i < items.length) {