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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/uikit-keyboard-notification.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions packages/core/src/motion/animator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/motion/uikit/keyboardNotification.test.ts
Original file line number Diff line number Diff line change
@@ -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');
Comment on lines +44 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this head this build is var e={durationMs:383.3},{durationMs:s}=e,o={durationMs:500,curve:[.42,0,.58,1]},…: it carries the Reduce Motion fade row through the binding at timing.ts:144-145, which these two assertions don't name, so the test passes while the bundle still carries another row. Asserting, for example, expect(code).not.toContain('durationMs:500') makes it fail today.

// 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');
});
});
102 changes: 60 additions & 42 deletions packages/core/src/motion/uikit/timing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,44 +83,69 @@ 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).
* `timing` composes it under its table paths, `reducedMotionCrossFadeMs` and
* `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: {
Expand All @@ -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
Expand All @@ -160,24 +185,17 @@ 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.
* @source measured: UIKit lab (injected host app), iOS 26.2 simulator,
* 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;

/**
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/motion/uikit/uikit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ function leavesOf(file: string): Leaf[] {
// of them is checked where it is declared.
const exported = new Set<string>();
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;
Expand Down
Loading