diff --git a/.changeset/uikit-keyboard-notification.md b/.changeset/uikit-keyboard-notification.md new file mode 100644 index 0000000000000..3d24a3142448d --- /dev/null +++ b/.changeset/uikit-keyboard-notification.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the keyboard notifications' duration has its own export in the UIKit timing table, so code that reads only it bundles no other timing value. Not exported from the package entry point. + +@vjeux diff --git a/packages/core/src/motion/animator.test.ts b/packages/core/src/motion/animator.test.ts index 93a573ddc7304..1e28e9d96c05c 100644 --- a/packages/core/src/motion/animator.test.ts +++ b/packages/core/src/motion/animator.test.ts @@ -2542,6 +2542,8 @@ describe('decay: momentum curves (a coast)', () => { ]) { expect(bundle).not.toContain(row); } + // Nor the keyboard's duration, which shares the table's composition. + expect(bundle).not.toContain('383.3'); }); it('imports neither the coast math nor the scroll table', () => { diff --git a/packages/core/src/motion/uikit/keyboardNotification.test.ts b/packages/core/src/motion/uikit/keyboardNotification.test.ts new file mode 100644 index 0000000000000..e10d257fa86c8 --- /dev/null +++ b/packages/core/src/motion/uikit/keyboardNotification.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file keyboardNotification.test.ts + * @input timing.ts, bundled on its own with esbuild + * @output Proof that the keyboard notifications' duration is its own export: + * the timing table composes it, and a bundle that reads only it carries no + * other row of the table + * @position Guards architecture:component-style-authoring INV6 for the + * keyboard layout guide's one read of the timing table + */ + +import {execFileSync} from 'node:child_process'; +import {describe, expect, it} from 'vitest'; +import {keyboardNotification, timing} from './timing'; + +describe('keyboardNotification', () => { + it('is the timing table\u2019s keyboard duration', () => { + expect(timing.keyboardNotificationMs).toBe(keyboardNotification.durationMs); + }); + + it('a bundle that reads only the keyboard\u2019s duration drops the rest of the table', () => { + // esbuild runs in its own Node process: jsdom's TextEncoder fails its + // startup check. + const code = execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + [ + "import {build} from 'esbuild';", + 'const result = await build({', + " stdin: {contents: \"import {keyboardNotification} from './timing'; export const ms = keyboardNotification.durationMs;\", resolveDir: process.cwd(), loader: 'ts'},", + " bundle: true, write: false, format: 'esm', minify: true, logLevel: 'silent',", + '});', + 'process.stdout.write(result.outputFiles[0].text);', + ].join('\n'), + ], + {cwd: __dirname, encoding: 'utf8'}, + ); + expect(code).toContain('383.3'); + // No other row: not the navigation bar's duration, not the press clocks, + // not the Reduce Motion fade that shares the table's composition. + expect(code).not.toContain('navigationBarHideShowMs'); + expect(code).not.toContain('buttonReleaseMs'); + // The fade's length and curve, however esbuild prints them: inline in its + // group or through a shared constant. + expect(code).not.toMatch(/\b200\b/); + expect(code).not.toContain('.42,0,.58,1'); + }); +}); diff --git a/packages/core/src/motion/uikit/timing.ts b/packages/core/src/motion/uikit/timing.ts index b4edbf7fad3dd..f62746b60bdeb 100644 --- a/packages/core/src/motion/uikit/timing.ts +++ b/packages/core/src/motion/uikit/timing.ts @@ -83,6 +83,58 @@ export const press = { buttonReleaseMs: 250, } as const; +// The groups with their own export (the keyboard's duration, the Reduce +// Motion fade) and `timing` share plain constants, not bindings or property +// reads: esbuild keeps a top-level destructuring in every bundle, so each +// group's binding would carry the other group into a bundle that reads only +// one (`architecture:component-style-authoring` INV6). + +/** + * The duration the keyboard notifications report + * (UIKeyboardAnimationDurationUserInfoKey, with curve 7), show and hide + * alike, in ms. The frame follows `springs.keyboard`, cut at this time. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, UIKeyboardWillShow / WillHide / WillChangeFrame read, + * Oct 23 2026 + */ +const KEYBOARD_NOTIFICATION_MS = 383.3; + +/** + * The keyboard notifications' duration, its own export so the keyboard layout + * guide reads it without the rest of the table + * (`architecture:component-style-authoring` INV6). `timing` composes it under + * its table path, `keyboardNotificationMs`, which stays out of the roles. + */ +export const keyboardNotification = { + durationMs: KEYBOARD_NOTIFICATION_MS, +} as const; + +/** + * The Reduced Motion cross-fade, in ms, on `curves.move`: under + * Reduce Motion an entrance, exit, presentation switch or state change + * becomes an opacity-only cross-fade of this length on a coarse pointer + * (design:reduced-motion DR2 caps it at 200 ms, by owner decision DEC-1 + * of 2026-10-24, departing from UIKit's own navigation cross-fade, + * measured below at 0.5 s). Not a theme role: no theme lengthens it (DR4). + * @source apple-docs https://developer.apple.com/documentation/uikit/uiaccessibility/preferscrossfadetransitions + * (the setting) + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, CAAnimation dump with Reduce Motion and Prefer + * Cross-Fade Transitions on (opacity CABasicAnimation 0.5 s, + * (0.42, 0, 0.58, 1)), Oct 23 2026 + */ +const REDUCED_MOTION_CROSS_FADE_MS = 200; + +/** + * `easeInEaseOut`; UIKit's default curve (`UIView.AnimationCurve + * .easeInOut`, "the default curve for most animations"). + * @source apple-docs https://developer.apple.com/documentation/quartzcore/camediatimingfunctionname/easeineaseout + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, CAMediaTimingFunction read from UIView.animate, + * Oct 23 2026 + */ +const REDUCED_MOTION_CROSS_FADE_CURVE = [0.42, 0, 0.58, 1] as const; + /** * The Reduce Motion fade, its own export so the animator reads it without * the rest of the table (`architecture:component-style-authoring` INV6). @@ -90,37 +142,10 @@ export const press = { * `curves.move`, so their roles are unchanged. */ export const reducedMotionCrossFade = { - /** - * The Reduced Motion cross-fade, in ms, on `curves.move`: under - * Reduce Motion an entrance, exit, presentation switch or state change - * becomes an opacity-only cross-fade of this length on a coarse pointer - * (design:reduced-motion DR2 caps it at 200 ms, by owner decision DEC-1 - * of 2026-10-24, departing from UIKit's own navigation cross-fade, - * measured below at 0.5 s). Not a theme role: no theme lengthens it (DR4). - * @source apple-docs https://developer.apple.com/documentation/uikit/uiaccessibility/preferscrossfadetransitions - * (the setting) - * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, - * iPhone 17 Pro, CAAnimation dump with Reduce Motion and Prefer - * Cross-Fade Transitions on (opacity CABasicAnimation 0.5 s, - * (0.42, 0, 0.58, 1)), Oct 23 2026 - */ - durationMs: 200, - /** - * `easeInEaseOut`; UIKit's default curve (`UIView.AnimationCurve - * .easeInOut`, "the default curve for most animations"). - * @source apple-docs https://developer.apple.com/documentation/quartzcore/camediatimingfunctionname/easeineaseout - * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, - * iPhone 17 Pro, CAMediaTimingFunction read from UIView.animate, - * Oct 23 2026 - */ - curve: [0.42, 0, 0.58, 1], + durationMs: REDUCED_MOTION_CROSS_FADE_MS, + curve: REDUCED_MOTION_CROSS_FADE_CURVE, } as const; -// `timing` composes the fade from bindings, not property reads, so a bundle -// that reads only the fade drops the rest of the table. -const {durationMs: reducedMotionCrossFadeMs, curve: move} = - reducedMotionCrossFade; - export const timing = { press, curves: { @@ -133,8 +158,8 @@ export const timing = { * Oct 23 2026 */ viewChange: [0.25, 0.1, 0.25, 1], - // The Reduce Motion fade's curve, sourced on `reducedMotionCrossFade`. - move, + // The Reduce Motion fade's curve, sourced on its constant. + move: REDUCED_MOTION_CROSS_FADE_CURVE, /** * `easeIn`. * @source apple-docs https://developer.apple.com/documentation/quartzcore/camediatimingfunctionname/easein @@ -160,15 +185,8 @@ export const timing = { * iPhone 17 Pro, read, Oct 23 2026 */ navigationBarHideShowMs: 200, - /** - * The duration the keyboard notifications report - * (UIKeyboardAnimationDurationUserInfoKey, with curve 7), show and hide - * alike, in ms. The frame follows `springs.keyboard`, cut at this time. - * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, - * iPhone 17 Pro, UIKeyboardWillShow / WillHide / WillChangeFrame read, - * Oct 23 2026 - */ - keyboardNotificationMs: 383.3, + // The keyboard's duration, sourced on its constant. + keyboardNotificationMs: KEYBOARD_NOTIFICATION_MS, /** * Core Animation's implicit animation duration * (`CATransaction.animationDuration()`), in ms. @@ -176,8 +194,8 @@ export const timing = { * iPhone 17 Pro, read, Oct 23 2026 */ stateChangeMs: 250, - // The Reduce Motion fade's length, sourced on `reducedMotionCrossFade`. - reducedMotionCrossFadeMs, + // The Reduce Motion fade's length, sourced on its constant. + reducedMotionCrossFadeMs: REDUCED_MOTION_CROSS_FADE_MS, } as const; /** diff --git a/packages/core/src/motion/uikit/uikit.test.ts b/packages/core/src/motion/uikit/uikit.test.ts index 9b97ab9b79751..75206eb71004f 100644 --- a/packages/core/src/motion/uikit/uikit.test.ts +++ b/packages/core/src/motion/uikit/uikit.test.ts @@ -68,6 +68,19 @@ function leavesOf(file: string): Leaf[] { // of them is checked where it is declared. const exported = new Set(); lines.forEach((line, index) => { + // A sourced constant (`const NAME = value;` under an `@source` doc), which + // a group and the table both compose: a row of its own, so a leaf that + // only names it is checked here. Plain constants, unlike a destructured + // binding, leave a bundle that reads one group free of the others. + const constant = /^const (\w+) = /.exec(line); + if (constant != null) { + const doc = docAbove(lines, index); + if (/^@source /m.test(doc)) { + exported.add(constant[1]); + rows.push({file, key: constant[1], doc}); + } + return; + } const match = /^export const (\w+) = \{/.exec(line); if (match == null) { return;