diff --git a/.changeset/build-stylex-cross-file-consts.md b/.changeset/build-stylex-cross-file-consts.md new file mode 100644 index 0000000000000..2d4cb0278765a --- /dev/null +++ b/.changeset/build-stylex-cross-file-consts.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/build': patch +--- + +[fix] `astryxStylex` resolves `defineConsts` values read from another file. With the `{stylexOptions}` form (the one Storybook uses), a file that read only constants from a `.stylex.ts` module lost that import once StyleX compiled it, so the constants never reached the CSS: a constant used as a media-query key failed the build ("Invalid empty selector") and one used as a value shipped as a custom property nothing declares. A constant defined on the library side of the layer split and read on the product side stayed unresolved in either form. A production build now also warns, naming the variable and the rule that reads it, when its stylesheet reads a StyleX-generated custom property that nothing declares; set `undefinedVariables: 'error'` to fail the build instead. + +@vjeux diff --git a/.changeset/gesture-browser-negotiation.md b/.changeset/gesture-browser-negotiation.md new file mode 100644 index 0000000000000..a3a60bd9ca8a8 --- /dev/null +++ b/.changeset/gesture-browser-negotiation.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Touch-action derivation, the native scroll probe and haptics for the gesture system. Not exported from the package root; touch presentations reach them through the experimental subpaths (see their entry). No component uses them yet. + +@vjeux diff --git a/.changeset/gesture-host.md b/.changeset/gesture-host.md new file mode 100644 index 0000000000000..6a2b0a3bfe4f8 --- /dev/null +++ b/.changeset/gesture-host.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] The gesture system's DOM host (claims, click suppression, the browser's takeover, the pointer behind a contact). Not exported from the package root; touch presentations reach it through the experimental subpaths (see their entry). No component uses it yet. + +@vjeux diff --git a/.changeset/gesture-pan.md b/.changeset/gesture-pan.md new file mode 100644 index 0000000000000..2375420d9fb36 --- /dev/null +++ b/.changeset/gesture-pan.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] The pan gesture recognizer, with UIKit's measured hysteresis and velocity filter. Not exported from the package root; touch presentations reach it through the experimental subpaths (see their entry). No component uses it yet. + +@vjeux diff --git a/.changeset/gesture-press-tracking.md b/.changeset/gesture-press-tracking.md new file mode 100644 index 0000000000000..f8cda44a6f605 --- /dev/null +++ b/.changeset/gesture-press-tracking.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] UIControl press tracking with control and cell profiles, the shared press paint, delegated press tracking and the gesture hooks. Not exported from the package root; touch presentations reach them through the experimental subpaths (see their entry). No component uses them yet. + +@vjeux diff --git a/.changeset/gesture-readme.md b/.changeset/gesture-readme.md new file mode 100644 index 0000000000000..2fdf3749a9a1c --- /dev/null +++ b/.changeset/gesture-readme.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] The gesture system's README for component authors, with its browser proof in Chromium and WebKit. + +@vjeux diff --git a/.changeset/gesture-recognizer-core.md b/.changeset/gesture-recognizer-core.md new file mode 100644 index 0000000000000..a8781ced8fc24 --- /dev/null +++ b/.changeset/gesture-recognizer-core.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] The gesture recognizer core for touch presentations: UIKit's recognizer state machine and the arena that settles which recognizer owns a touch. Not exported from the package root; touch presentations reach it through the experimental subpaths (see their entry). No component uses it yet. + +@vjeux diff --git a/.changeset/gesture-swipe-pinch-rotation-edge-hover.md b/.changeset/gesture-swipe-pinch-rotation-edge-hover.md new file mode 100644 index 0000000000000..3cfce543fe18b --- /dev/null +++ b/.changeset/gesture-swipe-pinch-rotation-edge-hover.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Swipe, pinch, rotation, screen-edge pan and hover gesture recognizers. Not exported from the package root; touch presentations reach them through the experimental subpaths (see their entry). No component uses them yet. + +@vjeux diff --git a/.changeset/gesture-tap-long-press.md b/.changeset/gesture-tap-long-press.md new file mode 100644 index 0000000000000..a9d005e04c89d --- /dev/null +++ b/.changeset/gesture-tap-long-press.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Tap and long-press gesture recognizers with UIKit's measured thresholds. Not exported from the package root; touch presentations reach them through the experimental subpaths (see their entry). No component uses them yet. + +@vjeux diff --git a/.changeset/reduced-motion-record.md b/.changeset/reduced-motion-record.md new file mode 100644 index 0000000000000..17ebb3fdc326d --- /dev/null +++ b/.changeset/reduced-motion-record.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] The experimental touch animator follows design:reduced-motion. Under Reduce Motion every `animate` and `play` (travel or opacity, on a spring or any curve) becomes an opacity-only cross-fade of 200 ms on a coarse primary pointer, or for a call marked `touchRelease`, and completes at once on any other pointer; nothing moves, scales or springs, and travel lands with a plain style write while the element is transparent, never as an animated transform. `timing.reducedMotionCrossFadeMs` is now 200 ms and is no longer a theme role. The `reducedMotion` and `crossfade` call options are removed; `crossfadeAs` stays. `animator.reducedMotion()` reports the mode for feedback with no fade of its own, `reducedMotionFade()` gives the same fade to code beside the animator, and `reducedMotion.stylex.ts` holds its constants for StyleX transitions. + +@vjeux diff --git a/.changeset/scrollable-area-touch-indicator.md b/.changeset/scrollable-area-touch-indicator.md new file mode 100644 index 0000000000000..1d35124e3d031 --- /dev/null +++ b/.changeset/scrollable-area-touch-indicator.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] On touch screens, `ScrollableArea` keeps the platform's own scroll indicator. Its neutral `scrollbar-color` replaced iOS Safari's indicator with a faint bar that was hard to see while scrolling and also showed in the gap a pull opens; iOS now draws its own indicator, only while scrolling. Mouse and trackpad scrollbars keep the neutral colour. + +@vjeux diff --git a/.changeset/touch-experimental-subpaths.md b/.changeset/touch-experimental-subpaths.md new file mode 100644 index 0000000000000..449e5b8fe53a9 --- /dev/null +++ b/.changeset/touch-experimental-subpaths.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[experimental] Two experimental subpaths for building touch presentations, outside the stable compatibility promise (they may change or be removed in a patch release). `@astryxdesign/core/motion/experimental` exports the compositor animator (`createAnimator`, `useAnimator`), UIKit's spring, rubber-band and cubic-bézier math, `resolveMotion` (a motion role's value under the element's theme), and the UIKit constants table as the read-only `uikit` namespace. `@astryxdesign/core/gestures/experimental` exports UIKit-style gesture recognizers (tap, long press, pan, screen-edge pan, swipe, pinch, rotation, hover), `useGestureRecognizer`, `usePressTracker`, `useTouchPresentation`, `readTouchPresentation`, `playHaptic` and `attachTapHaptic`, with the `GestureArena` and `VelocityStrategy` types their options reach. Nothing is added to the package root or to any other subpath. + +@vjeux diff --git a/.changeset/touch-motion-theme-roles.md b/.changeset/touch-motion-theme-roles.md new file mode 100644 index 0000000000000..feeef3c8207aa --- /dev/null +++ b/.changeset/touch-motion-theme-roles.md @@ -0,0 +1,8 @@ +--- +'@astryxdesign/core': patch +'@astryxdesign/cli': patch +--- + +[feat] Themes can retune touch motion roles. `defineTheme({motion})` takes `springs`, `durations` and `curves` keyed by motion role, the table path of a measured touch motion value (`springs.sheetPresent`, `navigation.transitionMs`), at the root or in an adaptation rule, and writes one `--motion-*` variable per role it sets. A role the theme leaves out keeps its measured default, an unknown role id is refused, and the duration scale (`fast`, `medium`, `ratio`) is optional when a theme sets only roles. No shipped component reads the roles yet. `astryx docs motion` lists every motion role with its CSS variable and default, and the theme authoring guide and template describe the new fields. + +@vjeux diff --git a/.changeset/touch-presentation.md b/.changeset/touch-presentation.md new file mode 100644 index 0000000000000..1859208111f8d --- /dev/null +++ b/.changeset/touch-presentation.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] `presentation="adaptive"` on DropdownMenu, ContextMenu, MoreMenu, Selector and MultiSelector, and a DropdownMenu's default sub-menu drill-in, now use the theme's compact definition with a coarse primary pointer: a viewport narrower than the theme's `md` point (768px by default) or shorter than 500px. This is a behavior change. The old default was a bottom sheet at 768px wide and below on a coarse pointer. Now a coarse-pointer screen exactly 768px wide keeps the anchored popover, and a phone in landscape (wider than 768px but shorter than 500px) gets the bottom sheet and the drilled-in sub-menu. To pin a component's presentation regardless of the rule, pass `presentation="popover"` or `presentation="bottom-sheet"`. Mouse and keyboard users on desktop see no change, because every case requires a coarse pointer. + +@vjeux diff --git a/.changeset/uikit-constants-table.md b/.changeset/uikit-constants-table.md new file mode 100644 index 0000000000000..c2ca32afb0d29 --- /dev/null +++ b/.changeset/uikit-constants-table.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] The UIKit constants table for touch presentations (springs, scroll physics, timing curves and navigation values), each value with its source and a checked list of the simulator measurements still owed. Not exported from the package entry point; no component reads it yet. + +@vjeux diff --git a/.changeset/uikit-motion-animator-curves.md b/.changeset/uikit-motion-animator-curves.md new file mode 100644 index 0000000000000..97283ce7db12c --- /dev/null +++ b/.changeset/uikit-motion-animator-curves.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] The touch presentations' animator plays any closed-form curve (such as a spring chasing a moving target), drives a pseudo-element such as a dialog's ::backdrop, fades on a presentation's own spring under Reduce Motion (with an entrance or exit role for a slide that changes no opacity), and starts a call from rest when the browser commits it, as Core Animation does. Not exported from the package root; touch presentations reach it through the experimental subpaths (see their entry). + +@vjeux diff --git a/.changeset/uikit-motion-animator.md b/.changeset/uikit-motion-animator.md new file mode 100644 index 0000000000000..5b35556dce1d9 --- /dev/null +++ b/.changeset/uikit-motion-animator.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] A compositor animator for touch presentations. It plays UIKit's springs, scroll coasts and any closed-form curve as Web Animations on translate, rotate, scale and opacity, so they keep running while JavaScript is busy, and reads values and velocities back from the curves for interruption, retargeting and Reduce Motion cross-fades. Not exported from the package root; touch presentations reach it through the experimental subpaths (see their entry). No component uses it yet. + +@vjeux diff --git a/.changeset/uikit-motion-math.md b/.changeset/uikit-motion-math.md new file mode 100644 index 0000000000000..08bab4d21a4d0 --- /dev/null +++ b/.changeset/uikit-motion-math.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] The spring, decay, rubber-band and projection math of UIKit for touch presentations, as pure functions checked against an independent integration and against values read from UIKit. Not exported from the package root; touch presentations reach it through the experimental subpaths (see their entry). No component uses it yet. + +@vjeux diff --git a/.changeset/uikit-motion-use-animator.md b/.changeset/uikit-motion-use-animator.md new file mode 100644 index 0000000000000..3246592bba81a --- /dev/null +++ b/.changeset/uikit-motion-use-animator.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] UseAnimator, a React wrapper around the touch presentations' animator that never re-renders per frame, and browser proofs on the interaction harness that the animator's motion keeps running through a blocked main thread in Chromium and WebKit. Not exported from the package root; touch presentations reach it through the experimental subpaths (see their entry). + +@vjeux diff --git a/.github/scripts/ci-test-routing.test.mjs b/.github/scripts/ci-test-routing.test.mjs index d5cc531118122..df91e3838a882 100644 --- a/.github/scripts/ci-test-routing.test.mjs +++ b/.github/scripts/ci-test-routing.test.mjs @@ -612,3 +612,52 @@ describe('deploy.yml push gating', () => { } }); }); + +describe('ci.yml interaction proofs', () => { + const workflow = load('ci.yml'); + const job = workflow.jobs['pr-interaction']; + const step = name => job.steps.find(candidate => candidate.name === name); + + it('runs the interaction config in Chromium against the exact PR head', () => { + expect(KNOWN_RUNNERS.has(job['runs-on'])).toBe(true); + expect(job.needs).toEqual( + expect.arrayContaining(['check-scope', 'build-storybook']), + ); + const checkout = job.steps.find(candidate => + candidate.uses?.startsWith('actions/checkout@'), + ); + expect(checkout?.with?.ref).toBe( + '${{ github.event.pull_request.head.sha }}', + ); + const run = step('Run interaction proofs').run; + expect(run).toContain('--config playwright.interaction.config.ts'); + expect(run).toContain('--project chromium'); + const packageJson = JSON.parse(read('package.json')); + expect(packageJson.scripts['test:interaction']).toBe( + 'playwright test --config playwright.interaction.config.ts', + ); + }); + + it('runs whenever the harness, its config, a component or a story changes', () => { + const scope = step('Check interaction scope').run; + expect(scope).toContain('.github/scripts/pr-scope-touches.sh'); + for (const path of [ + 'internal/interaction-harness/', + 'playwright\\.interaction\\.config\\.ts', + 'packages/[^/]+/src/', + 'apps/storybook/', + ]) { + expect(scope).toContain(path); + } + // Unknown scope (exit 2) runs the proofs; only "out of scope" skips them. + expect(scope).toContain('if [ "$scope" = 1 ]; then'); + }); + + it('typechecks the harness in both workflows', () => { + for (const file of ['ci.yml', 'deploy.yml']) { + expect(runLines(load(file).jobs.typecheck), file).toContain( + '@astryxdesign/interaction-harness typecheck', + ); + } + }); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55d437a675c85..0b4e913e72e80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1270,6 +1270,12 @@ jobs: if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' run: pnpm -F @astryxdesign/native typecheck:types + # The interaction harness is consumed as TypeScript source by Vitest and + # Playwright, which do not typecheck; this is the only gate over it. + - name: Typecheck interaction harness + if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' + run: pnpm -F @astryxdesign/interaction-harness typecheck + # Sandbox preview build — the single slowest piece of PR CI (~4min), run as # its own job so it overlaps build-storybook instead of serializing after it. # PR-only: merge_group runs never deploy previews, and docsite-only PRs skip @@ -3803,6 +3809,88 @@ jobs: retention-days: 30 delete-merged: true + # Touch interaction and animation proofs: the harness in + # internal/interaction-harness and every *.interaction.browser.spec.ts. + # Real touch input, composited frames, rendering traces and presented + # animation values only exist in a real engine, and the specs read stories + # from this PR's own Storybook build. The harness's self-test runs with + # them, so a change to an instrument is proven by its negative controls + # before a component spec relies on it. Chromium only on these runners; + # see the harness README for running WebKit. + pr-interaction: + needs: [check-scope, build-storybook] + if: github.event_name == 'pull_request' && needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' + # Frame evidence measures timing: four cores, one Playwright worker. + runs-on: 4-core-ubuntu + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Verify evidence checkout + env: + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD_SHA" + + # The helper comes from the base, since an older PR head lacks it. + # Exit 1 means out of scope; 0 in scope; 2 unknown, so run. + - name: Check interaction scope + id: scope + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + scope=2 + helper="$(mktemp)" + if git fetch --quiet --no-tags --depth=1 origin "$BASE_SHA" \ + && git show "$BASE_SHA:.github/scripts/pr-scope-touches.sh" > "$helper"; then + scope=0 + bash "$helper" "$BASE_SHA" '^(internal/interaction-harness/|playwright\.interaction\.config\.ts$|packages/[^/]+/src/|apps/storybook/)' || scope=$? + fi + if [ "$scope" = 1 ]; then + echo "Neither the interaction harness nor a component changed; skipping the interaction proofs." + echo 'run=false' >> "$GITHUB_OUTPUT" + else + echo 'run=true' >> "$GITHUB_OUTPUT" + fi + + - name: Setup Node and pnpm + if: steps.scope.outputs.run == 'true' + uses: ./.github/actions/setup + with: + install: 'false' + + - name: Download Storybook artifact + if: steps.scope.outputs.run == 'true' + uses: actions/download-artifact@v8 + with: + name: storybook-${{ needs.build-storybook.outputs.short_hash }} + path: apps/storybook/dist + + - name: Install Playwright + if: steps.scope.outputs.run == 'true' + run: pnpm install --frozen-lockfile && npx playwright install chromium + + - name: Run interaction proofs + if: steps.scope.outputs.run == 'true' + run: | + pnpm exec playwright test \ + --config playwright.interaction.config.ts \ + --project chromium \ + --output interaction-proof-output + + # Storyboards (PNG contact sheets) and curve traces, for review. + - name: Upload interaction evidence + if: always() && steps.scope.outputs.run == 'true' + uses: actions/upload-artifact@v7 + with: + name: interaction-evidence + path: test-results/interaction-evidence + if-no-files-found: ignore + retention-days: 7 + # Build package test owner. The plugin's production CSS partition is a Build # integration contract: source inspection cannot prove which declaration # paints after Vite emits the final bundle. The `test-build` name makes that diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 698440caafd9d..8badd15edbf40 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -146,6 +146,9 @@ jobs: - name: Typecheck accessibility spec-test contracts run: pnpm -F @astryxdesign/a11y-spec typecheck + - name: Typecheck interaction harness + run: pnpm -F @astryxdesign/interaction-harness typecheck + # Stable join for the three post-merge validation lanes. test: needs: [test-ui, test-node, typecheck] diff --git a/apps/docsite/scripts/generate-data.mjs b/apps/docsite/scripts/generate-data.mjs index cce1be21605ea..fff1fee578285 100644 --- a/apps/docsite/scripts/generate-data.mjs +++ b/apps/docsite/scripts/generate-data.mjs @@ -656,6 +656,10 @@ async function generateComponentRegistry() { const mod = await import(pathToFileURL(docFile).href); doc = mod.docs; if (!doc) continue; + // An experimental doc (spec:AST-017 FR27) gets no stable page or + // ShadCN item: docs use marked experimental routes, which this site + // does not have yet. + if (doc.stability === 'experimental') continue; recordDocExampleOrder(doc); } catch (err) { console.warn( diff --git a/apps/sandbox/vite.config.mts b/apps/sandbox/vite.config.mts index f91d6d4d024bd..a904a908b8a23 100644 --- a/apps/sandbox/vite.config.mts +++ b/apps/sandbox/vite.config.mts @@ -192,6 +192,7 @@ export default defineConfig({ unstable_moduleResolution: stylexResolution.stylexModuleResolution, }, stylexPrefix: 'x', + undefinedVariables: 'error', }), react({ babel: { diff --git a/apps/storybook/.storybook/main.test.ts b/apps/storybook/.storybook/main.test.ts index c63b484479205..8e3c402f74123 100644 --- a/apps/storybook/.storybook/main.test.ts +++ b/apps/storybook/.storybook/main.test.ts @@ -57,6 +57,7 @@ const runtimeImports = [ describe('storybook main.ts workspace source aliases', () => { let viteAliases: Record; let stylexAliases: Record; + let undefinedVariables: unknown; const stylexSpy = vi.spyOn(build, 'astryxStylex'); beforeAll(async () => { @@ -69,6 +70,11 @@ describe('storybook main.ts workspace source aliases', () => { throw new Error('Expected the Storybook StyleX options'); } stylexAliases = options.stylexOptions!.aliases as Record; + undefinedVariables = options.undefinedVariables; + }); + + it('fails the build on a generated variable nothing declares', () => { + expect(undefinedVariables).toBe('error'); }); afterAll(() => stylexSpy.mockRestore()); diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index 243cb3468d30c..b9370dadfbc1c 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -198,6 +198,9 @@ const config: StorybookConfig = { }, }, libraryPattern: 'packages/', + // CI catches a style that reads a generated variable nothing + // declares (a dropped declaration) instead of shipping it. + undefinedVariables: 'error', }), ], resolve: { diff --git a/apps/storybook/stories/ContextMenu.stories.tsx b/apps/storybook/stories/ContextMenu.stories.tsx index 9ee6b0bbc5fae..97229007deab7 100644 --- a/apps/storybook/stories/ContextMenu.stories.tsx +++ b/apps/storybook/stories/ContextMenu.stories.tsx @@ -494,7 +494,7 @@ export const AdaptivePresentation: Story = { docs: { description: { story: - 'Uses the built-in adaptive policy: BottomSheet at 768px and below with a coarse primary pointer, cursor-positioned popover otherwise. Important actions still need a visible MoreMenu or equivalent entry point.', + 'Uses the built-in adaptive policy: BottomSheet on a compact touch screen (a coarse primary pointer, and a viewport narrower than 768px or shorter than 500px), cursor-positioned popover otherwise. Important actions still need a visible MoreMenu or equivalent entry point.', }, }, }, diff --git a/apps/storybook/stories/MoreMenu.stories.tsx b/apps/storybook/stories/MoreMenu.stories.tsx index ad736177aedc0..791371983ba54 100644 --- a/apps/storybook/stories/MoreMenu.stories.tsx +++ b/apps/storybook/stories/MoreMenu.stories.tsx @@ -316,7 +316,7 @@ export const AdaptivePresentation: Story = { docs: { description: { story: - 'Uses the built-in adaptive policy: BottomSheet at 768px and below with a coarse primary pointer, anchored popover otherwise. Use device emulation to exercise the touch branch.', + 'Uses the built-in adaptive policy: BottomSheet on a compact touch screen (a coarse primary pointer, and a viewport narrower than 768px or shorter than 500px), anchored popover otherwise. Use device emulation to exercise the touch branch.', }, }, }, diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md new file mode 100644 index 0000000000000..d81f92cc41ea0 --- /dev/null +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -0,0 +1,293 @@ +--- +schema_version: 4 +template_version: 2 +kind: system-spec +id: spec:AST-094 +authority: draft +archive_reason: null +superseded_by: null +approved_by: null +approved_at: null +phase: proposed +owners: [vjeux] +affects_architecture: + [architecture:interaction-modality, architecture:theme-tokens] +affects_families: [family:buttons, family:overlay-dismissal] +affects_contributing: [] +affects_consumer_docs: [] +--- + +# Touch presentations follow UIKit system spec + +## Intent + +A person using an Astryx product on a phone gets the controls, sheets, menus, +lists and navigation they know from iOS: the same physics, the same timings, +the same gesture thresholds and the same feel. Every component's touch +presentation follows UIKit on a named reference release, and its motion keeps +running at display rate while the page's JavaScript is busy. + +The reference is UIKit on iOS 26.2 today. Each number a touch presentation +uses names the iOS version it was taken from; moving the reference to a newer +release re-measures the table, and differences on older releases are noted, +not targeted. + +## Non-goals + +- Pointer (desktop) presentations. Their timing and easing stay with the + theme's motion tokens (`architecture:theme-tokens`). +- WebKit embedded in a native app and the native Swift package. This record + governs the web components themselves. +- Scroll physics. Scrolling stays native on every platform: on iOS an overflow + area or the page is a UIKit scroll view already. +- Which components get a touch presentation, and when. Each component's own + record owns its presentation. +- Equivalent internal implementations remain valid when they satisfy this + contract. The modules, files and algorithms of the motion and gesture + foundations belong to their architecture records. + +## Requirements + +### Physics + +- **FR1 — One table of UIKit values.** Every spring, duration, deceleration + rate, rubber-band coefficient, gesture threshold and haptic moment a touch + presentation uses MUST come from one shared table of UIKit values. A + component MUST NOT carry a number of its own. +- **FR2 — Every value has a source.** Each value in the table MUST name its + source next to it: Apple's documentation, a WWDC session, WebKit's own + source where WebKit states it matches UIKit, or a measurement of UIKit on + the iOS simulator naming the app, the iOS version, the method and the date. + A value without one of these MUST be labelled a guess and name the + measurement that will replace it. A decompilation of UIKit MAY aim a + measurement and MUST NOT be a source. +- **FR3 — UIKit's physics.** Springs MUST follow UIKit's spring model + (response and damping ratio, mass, stiffness and damping, and the release + velocity), with overdamping clamped to critical damping wherever UIKit + clamps it. A momentum coast MUST decay like a UIKit scroll view (0.998 per + millisecond, 0.99 for the fast rate), a drag past an edge MUST resist with + UIKit's rubber band `(1 − 1 ÷ (x·c ÷ d + 1))·d` with the coefficient its use + measures (0.55 for a scroll view and a sheet), and a release MUST pick its + rest point by projecting its velocity the way its UIKit counterpart does: + with a deceleration rate, or with a measured projection time. +- **FR4 — Physics are not themable in touch presentations.** A touch + presentation MUST read its motion from the table on every theme. Themes keep + color, shape, type and spacing; the theme's duration and easing tokens keep + governing pointer presentations. **Proposed; pending owner review:** this + narrows `architecture:theme-tokens` (owners: cixzhang, imdreamrunner) to + pointer presentations, and departs from the draft `design:motion` DR3 + (owners: ernestt, cixzhang) wherever UIKit settles with an overshoot or + stretches with a rubber band. + +### Motion + +- **FR5 — Motion runs on the compositor.** Every release and every + programmatic animation of a touch presentation MUST animate `transform`, the + individual `translate`, `scale` and `rotate` properties, or `opacity` + (a dialog's `::backdrop` included) through Web Animations that the browser + runs off the main thread, + and MUST keep advancing at display rate through a main-thread block. It MUST + NOT animate layout properties, and MUST NOT step a frame from script when the + compositor can run the curve. +- **FR6 — Motion is interruptible.** A finger MUST be able to catch any + animation mid-flight at the value on screen. That value and its velocity + MUST come from the curve, not from a layout read. A new target MUST keep the + current velocity, and a release MUST hand its velocity to the spring that + follows. +- **FR7 — Exits animate as UIKit's do.** A touch presentation MUST play the + exit its UIKit counterpart plays. **Proposed; pending owner review:** this + departs from the draft `spec:AST-090` FR1 (exits off by default) and FR10 + (reduced motion closes at once) for touch presentations (owners: ernestt, + cixzhang). +- **FR8 — Reduce Motion substitutes, never jumps.** Under + `prefers-reduced-motion: reduce`, a touch presentation MUST behave as its + UIKit counterpart does with Reduce Motion and Prefer Cross-Fade Transitions + on: a transition that travels becomes a cross-fade carrying the same change + of state. An entrance lands in place and fades in, an exit fades out in + place, and any other move fades through. A navigation push or pop + cross-fades over 0.5 s on the ease-in-out curve; a sheet, a full-screen + modal or an alert fades on its own presentation spring and drops its travel + and scale; menus open and close on UIKit's Reduce Motion spring. Motion that + follows a finger, such as a throw, MUST keep its motion. **Proposed; pending + owner review:** this is the minimally moving form the draft `design:motion` + DR5 asks for, for touch presentations (owners: ernestt, cixzhang); the + alternative is UIKit's Reduce Motion alone, which keeps the slides of + navigation, sheets and modals. + +### Gestures + +- **FR9 — Fingers are tracked by recognizers.** A drag MUST be tracked by the + shared gesture recognizers, writing one transform per move, with no + component render per move and no layout read after the gesture begins. + Native scrolling MUST be used only where the component is a scroll view + (lists, scroll areas, the page). A component MAY track a finger with native + scroll snapping only with simulator evidence that it tracks through a + main-thread block, ends on UIKit's release curve, ends correctly when the + finger lifts during a block, and lets a finger catch the content mid-flight. +- **FR10 — A second finger never breaks the first.** A second finger MUST NOT + cancel or redirect a drag a recognizer has claimed. Page pinch-zoom MUST stay + the browser's whenever no pinch recognizer claims the fingers and no drag is + claimed (WCAG 1.4.4). +- **FR11 — Two press profiles.** Press feedback MUST follow one of two UIKit + profiles. A control (UIControl) highlights on touch-down outside scroll + views, keeps tracking within its expanded bounds (70 pt), turns its highlight + off and on as the finger drags out and back, and activates on a lift inside. + A cell (a table or collection cell) highlights 150 ms after touch-down inside + a scroll view and drops the highlight when the scroll wins. The 150 ms delay + applies only inside scroll views. **Proposed; pending owner review:** this + amends `design:user-states` DR1 and DR3 for touch presentations (owners: + ernestt, cixzhang), and the press treatment of `family:buttons` (owner: + cixzhang). +- **FR12 — Long press and rubber band are per use.** A long press MUST use the + thresholds its UIKit counterpart uses: 0.5 s and 10 pt for a plain long + press, the measured values for context menus and menus. A rubber band MUST + use the coefficient its use measures. +- **FR13 — One touch-presentation resolver.** Whether a component shows its + touch presentation MUST come from one shared resolver: touch behaviors key + on a coarse pointer, layout on the available size, and a presentation that + depends on both (a sheet instead of a popover) asks for both. A component + MUST NOT write its own media query for it. **Proposed; pending owner + review:** the resolver sits beside the shared modality state of + `architecture:interaction-modality` (owner: cixzhang). +- **FR14 — Nested scrolling follows UIKit.** Who gets a drag that starts + inside a scroll area MUST be decided once, when the drag begins. If the + area sits at its edge and the drag moves toward that edge, the surrounding + surface (a sheet) takes the drag; otherwise the area keeps it for the whole + gesture and rubber-bands at its edge. The drag MUST NOT change hands midway, + in either direction. + **Proposed; pending owner review:** this amends `spec:AST-025` FR14 (scroll + chaining by default) for touch presentations (owner: cixzhang). +- **FR15 — Recognizers arbitrate like UIKit's.** Recognizers that see the + same touch sequence MUST be exclusive by default: the first to recognize + wins and the others fail, unless both allow simultaneous recognition. A + recognizer that requires another to fail MUST hold its recognition until + that one fails, and MUST fail if that one recognizes. When several recognize + on the same event, the innermost element's recognizer MUST win. +- **FR16 — Gestures keep the event clock.** Recognizers MUST read time from the + input events' timestamps. Durations (a long press, the interval between + taps) MUST be deadlines on that clock, so a blocked main thread delays the + report and never the moment reported. +- **FR17 — UIKit's readings.** A recognizer's thresholds and readings MUST + follow UIKit's and come from the table. A pan begins at 10 pt of travel on + either axis, and its translation excludes that travel; its velocity is + UIKit's estimate, 0.2 × the newest event's instantaneous velocity + 0.8 × + the previous event's, never decayed through a rest. +- **FR18 — `touch-action` leaves the browser the rest.** An element with touch + recognizers MUST get a `touch-action` that leaves the browser exactly what + its recognizers do not use: the other axis for an axis-limited pan or swipe, + `manipulation` for taps and long presses, everything for a one-way pan or a + pan that shares its axis with native scrolling. `pinch-zoom` MUST stay + unless a pinch or rotation recognizer is attached. +- **FR19 — Claims are made in time and only by an owner.** A recognizer that + begins MUST claim its touches by cancelling `touchmove` through a + non-passive listener on its element. A `touchmove` of a touch no recognizer + owns MUST NOT be cancelled. A recognizer that competes with native scrolling + MUST decide within 10 px of travel, before the browser commits to the + scroll. +- **FR20 — The browser can take a touch.** `pointercancel`, an uncancelable + `touchmove`, or a native scroll under the touch MUST count as the browser + taking the touch: possible recognizers that share it fail, active ones are + cancelled, and the view's press is cancelled without a release. +- **FR21 — Clicks follow the gesture.** When a gesture takes a touch, its + `touchend` MUST be cancelled, and no click or compatibility mouse event + reaches the page. A click held for a recognizer that may still recognize (a + tap that a double tap may claim) MUST be replayed on its target if that + recognizer fails. Activation with `detail === 0` (keyboard, assistive + technology) MUST always pass. A tap MUST NOT wait for the browser's + double-tap delay. +- **FR22 — iOS Safari keeps its edge swipe.** Safari's own back swipe from the + screen edge MUST stay the browser's; a touch presentation never overrides it. + +### Haptics + +- **FR23 — Haptics where UIKit plays them.** A touch presentation MUST play a + haptic only at the moments its UIKit counterpart does. In iOS Safari the only + haptic a page can produce is a light impact from a real tap landing on a + `