From 3f83540fac6b421fbf8b95f897a1bd4d572b2e85 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 25 Oct 2026 20:26:05 -0700 Subject: [PATCH 1/3] perf(core): the keyboard notifications' duration has its own export The keyboard layout guide reads only the keyboard's duration, so it is its own export, keyboardNotification.durationMs, the way press is; timing composes it from a binding under its table path, keyboardNotificationMs, so a bundle that reads only the keyboard's duration drops the rest of the table (architecture:component-style-authoring INV6). The table test checks a value bound from an exported group where the group declares it. A test bundles that one read with esbuild and finds no other timing row; both new tests fail on the previous code. --- .changeset/uikit-keyboard-notification.md | 7 +++ .../motion/uikit/keyboardNotification.test.ts | 46 +++++++++++++++++++ packages/core/src/motion/uikit/timing.ts | 32 +++++++++---- packages/core/src/motion/uikit/uikit.test.ts | 10 ++++ 4 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 .changeset/uikit-keyboard-notification.md create mode 100644 packages/core/src/motion/uikit/keyboardNotification.test.ts 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/uikit/keyboardNotification.test.ts b/packages/core/src/motion/uikit/keyboardNotification.test.ts new file mode 100644 index 0000000000000..61c46f6dce41f --- /dev/null +++ b/packages/core/src/motion/uikit/keyboardNotification.test.ts @@ -0,0 +1,46 @@ +// 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. + expect(code).not.toContain('navigationBarHideShowMs'); + expect(code).not.toContain('buttonReleaseMs'); + }); +}); diff --git a/packages/core/src/motion/uikit/timing.ts b/packages/core/src/motion/uikit/timing.ts index c3c4c461d716e..99353fc85c79d 100644 --- a/packages/core/src/motion/uikit/timing.ts +++ b/packages/core/src/motion/uikit/timing.ts @@ -72,6 +72,28 @@ export const press = { buttonReleaseMs: 250, } as const; +/** + * 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 = { + /** + * 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 + */ + durationMs: 383.3, +} as const; + +// Composed from a binding, so a bundle that reads only the keyboard's +// duration drops the rest of the table. +const {durationMs: keyboardNotificationMs} = keyboardNotification; + export const timing = { press, curves: { @@ -118,15 +140,7 @@ 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, + keyboardNotificationMs, /** * Core Animation's implicit animation duration * (`CATransaction.animationDuration()`), in ms. diff --git a/packages/core/src/motion/uikit/uikit.test.ts b/packages/core/src/motion/uikit/uikit.test.ts index ce0accc192029..cd09ba56584b1 100644 --- a/packages/core/src/motion/uikit/uikit.test.ts +++ b/packages/core/src/motion/uikit/uikit.test.ts @@ -73,6 +73,16 @@ function leavesOf(file: string): Leaf[] { return match == null ? [] : [match[1]]; }), ); + // Values bound from one of those groups (`const {curve: move} = group;`) + // are checked where the group declares them too. + const bindings = /^const \{([^}]+)\} =\s*(?:\n\s*)?(\w+);/gm; + for (const [, names, group] of text.matchAll(bindings)) { + if (exported.has(group)) { + for (const name of names.split(',')) { + exported.add(name.split(':').pop()!.trim()); + } + } + } return starts.flatMap(start => leavesFrom(file, lines, start, exported)); } From 9865edfc596f078fa727f2698d86b4be10b88acc Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 26 Oct 2026 07:06:10 -0700 Subject: [PATCH 2/3] fix(core): the timing table composes its own-export groups from plain constants timing.ts filled its table from top-level destructuring bindings of the keyboard notification group and of the Reduce Motion fade group (main's). esbuild keeps a top-level destructuring in every bundle, since a property read may run a getter, so each binding carried its group into a bundle that read only the other: the keyboard layout guide's bundle carried the fade, and the animator's carried the keyboard's duration. Each value is now one sourced constant that its group and the table both name, and the table test's source check takes a constant documented with @source as a row of its own. The keyboard bundle test now names the fade's row and the animator's bundle test the keyboard's, and both fail on the previous code. --- packages/core/src/motion/animator.test.ts | 2 + .../motion/uikit/keyboardNotification.test.ts | 4 +- packages/core/src/motion/uikit/timing.ts | 98 ++++++++++--------- packages/core/src/motion/uikit/uikit.test.ts | 13 +++ 4 files changed, 69 insertions(+), 48 deletions(-) diff --git a/packages/core/src/motion/animator.test.ts b/packages/core/src/motion/animator.test.ts index eaf35560ee24f..aeff3af12f127 100644 --- a/packages/core/src/motion/animator.test.ts +++ b/packages/core/src/motion/animator.test.ts @@ -996,6 +996,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 index 61c46f6dce41f..9adcc898ab63f 100644 --- a/packages/core/src/motion/uikit/keyboardNotification.test.ts +++ b/packages/core/src/motion/uikit/keyboardNotification.test.ts @@ -39,8 +39,10 @@ describe('keyboardNotification', () => { {cwd: __dirname, encoding: 'utf8'}, ); expect(code).toContain('383.3'); - // No other row: not the navigation bar's duration, not the press clocks. + // 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'); + expect(code).not.toContain('durationMs:500'); }); }); diff --git a/packages/core/src/motion/uikit/timing.ts b/packages/core/src/motion/uikit/timing.ts index 33b5a08b013a8..b94b70d98e4d1 100644 --- a/packages/core/src/motion/uikit/timing.ts +++ b/packages/core/src/motion/uikit/timing.ts @@ -83,6 +83,22 @@ 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 @@ -90,20 +106,35 @@ export const press = { * its table path, `keyboardNotificationMs`, which stays out of the roles. */ export const keyboardNotification = { - /** - * 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 - */ - durationMs: 383.3, + durationMs: KEYBOARD_NOTIFICATION_MS, } as const; -// Composed from a binding, so a bundle that reads only the keyboard's -// duration drops the rest of the table. -const {durationMs: keyboardNotificationMs} = keyboardNotification; +/** + * Reduce Motion with Prefer Cross-Fade Transitions: a navigation push or pop + * becomes a cross-fade of this length on `curves.move` (the new view + * fades in while the old one fades out, with no travel), in ms. Sheets, + * full-screen modals and alerts fade on their own presentation spring + * instead. A reference reading only: under reduced motion a touch + * presentation follows `design:reduced-motion` DR2 and DR4, not this clock + * (`spec:AST-094` FR8). + * @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 = 500; + +/** + * `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 @@ -112,38 +143,10 @@ const {durationMs: keyboardNotificationMs} = keyboardNotification; * `curves.move`, so their roles are unchanged. */ export const reducedMotionCrossFade = { - /** - * Reduce Motion with Prefer Cross-Fade Transitions: a navigation push or pop - * becomes a cross-fade of this length on `curves.move` (the new view - * fades in while the old one fades out, with no travel), in ms. Sheets, - * full-screen modals and alerts fade on their own presentation spring - * instead. A reference reading only: under reduced motion a touch - * presentation follows `design:reduced-motion` DR2 and DR4, not this clock - * (`spec:AST-094` FR8). - * @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: 500, - /** - * `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: { @@ -156,8 +159,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 @@ -183,7 +186,8 @@ export const timing = { * iPhone 17 Pro, read, Oct 23 2026 */ navigationBarHideShowMs: 200, - keyboardNotificationMs, + // The keyboard's duration, sourced on its constant. + keyboardNotificationMs: KEYBOARD_NOTIFICATION_MS, /** * Core Animation's implicit animation duration * (`CATransaction.animationDuration()`), in ms. @@ -191,8 +195,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; From 1adf75cbe4c18dafdc497af62fbb97aaf3c8176d Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 26 Oct 2026 08:30:38 -0700 Subject: [PATCH 3/3] test(core): the keyboard bundle test catches the fade however esbuild prints it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the fade's values now shared constants, a returning destructuring of the fade group bundles as `s=500,…{durationMs:s,…}`, which the `durationMs:500` check missed. The test now looks for the fade's length as a bare 500 and for its curve, and fails when a destructuring of the fade group is put back. --- packages/core/src/motion/uikit/keyboardNotification.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/motion/uikit/keyboardNotification.test.ts b/packages/core/src/motion/uikit/keyboardNotification.test.ts index 9adcc898ab63f..5ea2fb7cc7de1 100644 --- a/packages/core/src/motion/uikit/keyboardNotification.test.ts +++ b/packages/core/src/motion/uikit/keyboardNotification.test.ts @@ -43,6 +43,9 @@ describe('keyboardNotification', () => { // not the Reduce Motion fade that shares the table's composition. expect(code).not.toContain('navigationBarHideShowMs'); expect(code).not.toContain('buttonReleaseMs'); - expect(code).not.toContain('durationMs:500'); + // The fade's length and curve, however esbuild prints them: inline in its + // group or through a shared constant. + expect(code).not.toMatch(/\b500\b/); + expect(code).not.toContain('.42,0,.58,1'); }); });