Skip to content

feat(lab): TabBar, iOS 26's floating tab bar, with its selection lens and press tracking - #9700

Open
vjeux wants to merge 20 commits into
meta:mainfrom
vjeux:tabbar/lab-bar-lens
Open

vjeux wants to merge 20 commits into
meta:mainfrom
vjeux:tabbar/lab-bar-lens

Conversation

@vjeux

@vjeux vjeux commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #9699 (on #9754). Review only this PR's change: vjeux/astryx@a236965...270f341

User need

Phone apps on Astryx need iOS 26's bottom tab bar: top-level sections in a floating capsule, with a lens that answers a finger as UIKit does.

Evidence

iOS: taps iOS: drag iOS: busy main thread iOS: Reduce Motion Android

From earlier drafts (iOS 26.2 simulator, Android emulator).

Problem and solution fit

Under a finger or a pen in the touch presentation the lens lifts into glass, follows a drag and settles on the tab nearest the lift on UIKit's springs while the bar flexes. Sliding off the bar or a second finger cancels. With no tab selected, a held finger paints its tab. Reselecting calls onReselect.

Desktop, before → after: a mouse or pen drag carried the lens and chose the tab under its release, and a held press painted its tab, fading on --duration-fast. Now a fine pointer's press is a plain button press: a click chooses its tab; a drag chooses and paints nothing.

Current specification

  • Lab: Lab is not shipped; no spec needed.
  • CLI: official-packages.json is regenerated, so astryx discover lists TabBar (spec:AST-051 FR11).

Decisions

  1. spec:AST-094 FR4: under touch, the lens plays UIKit's measured springs from uikitTabBar.ts. Specs decision, Nov 5 2026: gesture class per AST-094 FR45.
  2. spec:AST-017 FR60: the lens's copy of the items. Open until promotion: the moving lens adds 23 elements in the 4-tab Default story while it moves (none at rest); promotion must meet AST-017 FR60 or record an approved exception. Specs decision, Nov 3 2026.

Current records already decide: sliding off cancels (family:action-groups FR3), and the slide runs only in the touch presentation (DEC-1).

Core's useActionGroupTracking (#9699) owns the press; TabBar draws the lens.

Public delta and caller burden

New Lab exports TabBar and TabBarItem with their props types; panelId sets a tab's aria-controls:

<TabBar value={tab} onChange={setTab} onReselect={popToRoot} label="Sections">
  <TabBarItem value="home" label="Home" icon={<Icon icon={HomeIcon} />} />
</TabBar>

Design and alternatives

  • The lens runs on Core's motion engine on the compositor; a click slides it on a CSS transition. No move is state-change-only, so none uses createAnimator.
  • A finger's paint is a layer that appears at once. Only its opacity fades, on --duration-exit and --ease-exit, so the compositor runs the fade (spec:AST-094 FR5, card 47). Under Reduce Motion the fade stays within 200 ms (design:reduced-motion DR2).

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

Local gates at 270f341 (on #9699 a236965), node 24.22.1 from .nvmrc:

  1. Interaction specs, built Storybook: TabBar's, Chromium 49/49 and WebKit 40 passed, 9 skipped (Chromium-only evidence: CDP traces, scripted touch times, and a second finger, which Playwright's WebKit can't lift apart); AlertDialog's, 12/12 in each engine.
  2. Red-first, this push (2/2 in each engine): on 634a859 plus only the new case (f5d87b6), a finger's release ran a background-color transition on the item box, not an opacity fade on its layer.
  3. Same look: the lift fade's pixels on 634a859 and at this head differ by at most 1/255 per channel in each engine. Probes 0f0975c and db5cb7c freeze every animation at 0, 50, 100, 150 and 200 ms after the lift, with the theme's paint, a 300 ms exit, and a strong overlay whose fade spans about 110 levels.
  4. Red-first, earlier rulings (2/2 in each engine unless noted): on the previous head's build (be8f19a) a mouse press dragged from Library to Browse painted its tab, moved the lens and chose Browse; a pen drag chose its tab, and under a fine pointer the bar's element carried listeners (Chromium, CDP; controls: a planted probe listener, and the bar's own touchmove in the touch presentation). On a build without the exit-token change, with a 300 ms theme exit under Reduce Motion a finger's release read 125 ms (must be 200), and with a 90 ms exit the paint's duration and curve differed from the tokens.
  5. Red-first, earlier rounds: a second finger landing mid-drag let the lift choose Browse; after a finger's slide focus stayed on Home; a press the tab list ended moved focus at the lift; a finger lifted off the bar chose Library; past the bar's end the lift reselected; a release that reports twice made a click a reselect; with no tab selected a held finger painted nothing; panelId rendered no aria-controls (unit).
  6. Presentation switch: a switch to a fine pointer under a held finger lands the lens back and leaves no paint, which pins chore(core): Core's internal touch entry re-exports the action-group tracking hook, extended for Lab's TabBar, and a tab-bar string joins the catalog #9699's cancel when tracking turns off.
  7. Scene clock: the drag's chase law and the finger landing before the last lift run on the page freeze, at scripted instants, so a loaded runner can't change them.
  8. Unit and static: 81 unit tests (TabBar, its model, its UIKit table's sources, Lab's motion-engine hook, the CLI official list); Lab typecheck; strict ESLint on the changed files, 0 problems, and pnpm lint, 0 errors; Prettier; the CLI node suite, 335; review:pre.

Still owed before ready: the WebKit CI profile ×10 and the busy-main-thread proof ×10 at this head, and the feel check on a phone.

Batch: L1 · Lab: iOS chrome (TabBar, SearchField, NavigationStack, PageControl)

Size: 5,027 added lines, about half tests. Minimize through More follow in a second batch.

Supersedes #8129, #8141 and #8703.

Stack: #9754 → #9699 → #9700
Merge group: #9699, #9700

#9701, TabBar's second Lab batch, also builds on this PR.

@vercel

vercel Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 6, 2026 10:26am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 6, 2026, 10:38 AM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · e700938

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
lab/TabBar 72.1 kB (new) · 1.8 kB CSS (new) — — · ✓ · ✗
lab/TabBarItem 3.8 kB (new) · 1.8 kB CSS (new) — — · ✓ · ✗
lab/CommentThread 276.5 kB (+82 B) · 2.4 kB CSS (0) — — · — · —
6 more components
Component Size LoC Visual · A11y · RTL
lab/Comments 282.5 kB (+77 B) · 2.5 kB CSS (0) — — · — · —
ButtonGroup 39.9 kB (+65 B) · 3.7 kB CSS (0) — — · — · —
lab/Schedule 98.8 kB (+63 B) · 3.9 kB CSS (0) — — · — · —
AlertDialog 95.7 kB (+59 B) · 13.7 kB CSS (0) — — · — · —
lab/NavigationScreen 78.1 kB (+12 B) · 1.5 kB CSS (0) — — · — · —
lab/NavigationStack 78.1 kB (+11 B) · 1.5 kB CSS (0) — — · — · —

Package size:

  • @astryxdesign/cli 3.39 MB packed (+13 B); no new dependencies
  • @astryxdesign/core 6.71 MB packed (+880 B); no new dependencies

API changes: none in this pull request

Committed API report behind this build: cli 19 rows, core 24 rows; run pnpm api:report.

Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's spec decision, and three independent changes are needed either way.

This pull request also changes Core, the interaction harness, Storybook and CLI data, so current records bind TabBar as a family:action-groups member. An engineering owner needs to decide each of these departures, or update the records first:

  • Under a finger, a lift off the bar chooses the nearest tab, while family:action-groups FR3 says a lift outside the group acts on nothing.
  • The lens plays measured UIKit springs (and SearchField's bottom field plays measured frames) instead of motion roles, which spec:AST-094 FR4 forbids.
  • Under touch the moving lens mounts a copy of the items in a bar that is not a layer, beyond what spec:AST-017 FR60 allows.
  • A mouse or pen drag carries the lens, a new fine-pointer feel that spec:AST-017 FR59 says needs its own owner decision.

Required changes:

  • Retarget the pull request onto its parent so it carries only the TabBar commit; against main it also lands #9399, #9699 and #9698, 12,769 authored lines in all.
  • Drive the press tracking through Core's actionGroupTracker, re-exported from @astryxdesign/core/internal/touch as this change does for 14 other modules, instead of the recognizer and target resolution in useTabBarLens.ts.
  • Move @astryx.tabBar.more, @astryx.tabBar.cancelSearch and the matching changeset sentence to #9701, since nothing in this change reads them.

An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner.

[Automated review]

useGestureRecognizer(
capsuleRef,
() =>
new LongPressGestureRecognizer({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This recognizer, plus end()'s own target resolution, is a second press tracker for an action group. architecture:interaction-modality INV13 gives that job to Core's actionGroupTracker. Its resolveTarget can commit the nearest tab (or nothing off the bar under a mouse), and onMove reports every finger position for the lens. It can be re-exported from @astryxdesign/core/internal/touch like the 14 other modules this change adds there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in ba1aaaf. Core's action-group tracker, re-exported from the internal touch entry (#9699), now owns the press. It resolves the tab under the pointer through TabBar's target rule (the nearest tab, and nothing off the bar), and end() runs that tab through onActivate at the lift. The recognizer only feeds it positions; TabBar draws the lens. Following the owner's decision, sliding off the bar now cancels under every pointer, a finger too. The new red-first cases (finger and pen off the bar, past the bar's end) and the existing mouse slide-off cases pin that rule.

Comment thread packages/core/locales/en.json Outdated
Comment on lines +822 to +829
"@astryx.tabBar.more": {
"defaultMessage": "More",
"description": "Label of the tab that collects a phone tab bar's sections past the fourth (iOS's More tab), and the name of the list of those sections it shows. One short word."
},
"@astryx.tabBar.cancelSearch": {
"defaultMessage": "Cancel",
"description": "Name of the button that ends search in a phone tab bar's search tab. The tab bar draws it over its search circle, beside the active field, as iOS draws its cancel button. It is the tab bar's own button, not the search field's own Cancel button. One short word."
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing in this pull request reads @astryx.tabBar.more or @astryx.tabBar.cancelSearch; they serve the More and search tabs of #9701. Please move them, and the changeset's "TabBar's strings" sentence, to that pull request.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These strings belong to #9699, which carries TabBar's three strings for both Lab batches. Core's catalog lives in Core, so keeping them there keeps the Lab batches Lab-only. They appear in this diff only because it is stacked on #9699, and they leave it when #9699 lands.

export interface TabBarLensRefs {
/** The lens's resting place: a fine pointer's drag moves it (CSS). */
readonly lens: (element: HTMLElement | null) => void;
/** The capsule: the flex. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: this comment restates the ref's name. Suggest: "The capsule. Its scale is the press flex."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Taken in ba1aaaf: "The capsule. Its scale is the press flex."

@vjeux
vjeux force-pushed the tabbar/lab-bar-lens branch from f277ee0 to ba1aaaf Compare November 3, 2026 07:42
@vjeux

vjeux commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Round 1 is addressed in ba1aaaf, restacked on #9399's new head:

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on two spec departures, and two independent changes are needed either way.

The press now runs through Core's action-group tracker, and a slide off the bar cancels under every pointer, so those earlier points are resolved. This pull request also changes Core, the interaction harness, Storybook and CLI data, so current records bind TabBar. An engineering owner needs to approve these departures, or update the records first:

  • The lens and the bar's flex play measured UIKit springs (and SearchField's bottom field plays measured frames) instead of motion roles, which spec:AST-094 FR4 forbids.
  • Under touch the moving lens mounts a copy of the items in a bar that is not a layer, beyond what spec:AST-017 FR60 allows.

Required changes:

  • Land #9399, #9699 and #9698 first so this pull request carries only TabBar's commit, since against main it is 13,527 authored lines, including Core re-exports and two catalog strings nothing here reads.
  • Delete the Effect at useTabBarLens.ts:684, because the tracker it disposes holds no timer, attribute or listener, so it synchronizes nothing (architecture:react-component-runtime INV3).

An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner.

[Automated review]

const endRef = useRef(end);
endRef.current = end;
// Releases the tracker's press at unmount.
useEffect(() => () => tracker.dispose(), [tracker]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This Effect synchronizes nothing. TabBar sets paintsHighlight: false and no swallowsNativeClick, so the tracker arms no timer, writes no attribute and adds no listener. dispose() only resets fields of an object React drops at unmount, and the gesture host already silences the recognizer then. Please delete it (architecture:react-component-runtime INV3).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Deleted in a7d0520. With paintsHighlight: false, onActivate set and no swallowsNativeClick, the tracker arms no timer, writes no attribute and adds no listener, so nothing needs releasing at unmount.

Comment thread packages/lab/src/TabBar/TabBarTouch.ts Outdated
Comment on lines +12 to +14
* Under a fine pointer only the lens's drag runs: it moves the lens's resting
* place on TabBar's CSS transition. The animator, the springs and the copy of
* the items stay a finger's, so nothing else of it writes anything there.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: a mouse drag now runs the animator, so this no longer holds. Suggest: "Under a fine pointer a drag moves the lens body on lensTrack and settles it on lensSettle. The lift and the copy of the items stay a finger's." The same applies to TabBar.tsx:506-507 and useTabBarLens.ts:70 and :108.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Taken in a7d0520. TabBarTouch.ts's header now uses your text. TabBar.tsx's doc and the isFine and lens comments now say a drag moves the lens body on lensTrack and settles it on lensSettle, while TabBar's CSS slides it only after a click.

await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true');
});

test('a mouse press released off the bar chooses nothing, a tab or a reselect: slide-off cancels (spec:AST-020 FR9)', async ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: spec:AST-020 FR9 is about enforcing accessibility spec tests. The slide-off rule this test pins is family:action-groups FR3.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in a7d0520: the title now cites family:action-groups FR3.

@vjeux
vjeux force-pushed the tabbar/lab-bar-lens branch from ba1aaaf to a7d0520 Compare November 3, 2026 09:06
@vjeux

vjeux commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Round 2 is addressed in a7d0520. The no-op dispose Effect is gone, and the stale mouse-drag comments and the test's record citation are fixed. The two spec departures (FR4's measured springs, FR60's lens copy) are open cards with the engineering owner. The stack point stands until #9399, #9699 and #9698 land; until then, the body's compare link shows this PR's own commit.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on two spec departures, and one independent change is needed either way.

This pull request also changes Core, the interaction harness, Storybook and CLI data, so current records bind TabBar and SearchField; an engineering owner needs to approve these departures or update the records first:

  • The lens and the bar's flex play measured UIKit springs, and SearchField plays a measured 383.3 ms activation clock and frame tables, instead of motion roles, which spec:AST-094 FR4 forbids.
  • Under touch the moving lens mounts a copy of the items in a bar that is not a layer, 23 extra elements in the Default story where the description counts 18, beyond what spec:AST-017 FR60 allows without an owner exception.

Required change:

  • Land #9399, #9699 and #9698 first so this pull request carries only TabBar's commit, since against main it is 13,525 authored lines, above the 8,000-line owner-directed batch ceiling, including Core re-exports and two catalog strings nothing here reads.

An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner.

[Automated review]

Comment thread packages/core/locales/en.json Outdated
"defaultMessage": "Tabs",
"description": "Screen-reader-only fallback name for a horizontal tab bar. Plural noun; \"Tabs\" here = UI tab panels, not browser tabs or the Tab key."
},
"@astryx.tabBar.more": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing in this pull request reads @astryx.tabBar.more or @astryx.tabBar.cancelSearch (they serve #9701), so translators would receive them before any UI uses them. Part of the scope change: land them with the pull request that reads them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These belong to #9699, which carries the catalog strings for both Lab batches, so each Lab batch changes only Lab. They appear in this diff only through the stack and leave it when #9699 lands.

Comment thread packages/core/src/internal/touch.ts Outdated
unwrapAngle,
} from '../gestures/geometry';
export {LongPressGestureRecognizer} from '../gestures/LongPressGestureRecognizer';
export {PanGestureRecognizer} from '../gestures/PanGestureRecognizer';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PanGestureRecognizer, useFingerScroll (line 44) and usePressTracker (line 46) have no importer in this pull request; only #9701 reads them. Part of the scope change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same as the strings: these re-exports are #9699's commit, carried here by the stack. #9701 reads them, and they leave this diff when #9699 lands.

expect(await refusesSelection()).toBe(false);
});

test('a tap reports its choice once, and a mouse click chooses through the browser', async ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: the title promises a mouse-click case the body never runs, and the comment at line 1394 says a click activates through the browser's own click, while useTabBarLens.ts:194-195 says the gesture host swallows it and the release chooses. No mouse spec counts reports, so a click calling onChange and then onReselect would pass. Suggest counting __tabBarChanges and reselects after one mouse click, and fixing both texts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Taken in 6f87bcd. The tap case's title no longer promises a mouse case. A new mouse case clicks Library once and expects exactly one change and no reselect. A mutant whose release reports twice fails it (2/2). The old click case's name and comment now say the release chooses and the gesture host swallows the browser's own click.

readonly isFine: boolean;
/** The capsule's box in client px, before the flex. */
readonly box: {left: number; right: number; top: number; bottom: number};
/** The number of tabs the press measured. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (INV29): this restates count; the same holds for /** The bar's tabs in order. */ at line 261 on tabsOf. Delete both.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Deleted both in 6f87bcd.

@vjeux
vjeux force-pushed the tabbar/lab-bar-lens branch from a7d0520 to 6f87bcd Compare November 3, 2026 11:10
@vjeux

vjeux commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Round 3 is addressed in 6f87bcd, restacked on #9399's head 7b86fe2 and #9698's approved head:

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on two spec departures, and one independent change is needed either way.

This pull request also changes Core, the interaction harness, Storybook and CLI data, so current records bind TabBar and SearchField at this head; an engineering owner needs to approve these departures or update the records first:

  • The lens and the bar's flex play measured UIKit springs, and SearchField plays a measured 383.3 ms activation clock and frame tables, instead of motion roles, which spec:AST-094 FR4 forbids.
  • Under touch the moving lens mounts a copy of the items in a bar that is not a layer, 23 extra elements in the Default story (52 in the tablist against 29 at rest), beyond what spec:AST-017 FR60 allows without an owner exception.

Required change:

  • Land #9399, #9699 and #9698 first so this pull request carries only TabBar's commit, since against main it is 13,760 authored lines, above the 8,000-line owner-directed batch ceiling, including Core re-exports and two catalog strings nothing here reads.

The mouse click is now pinned to a single report, and the restating comments are gone. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner.

[Automated review]

Comment thread packages/lab/src/TabBar/TabBar.tsx Outdated
Comment on lines +522 to +525
* Accessibility: a `tablist` of `tab` buttons (`aria-selected`) with one tab
* stop. The arrow keys, Home and End move between tabs, and Enter or Space
* chooses one. A badge is part of its tab's name, one message with both
* ("Library, 3"; `badgeLabel`, or the badge itself).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (INV29): these lines restate the markup: the tablist and tab roles, the single tab stop and the keys. Suggest deleting them and keeping only the reasons in this block (UIKit fidelity, CSS layout before hydration, why the copy exists and when it mounts).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Taken: the paragraph keeps only the reasons. It lands with the push that rebases this stack once #9399 merges, so the owner's /approve-spec can bind to that final head.

@vjeux
vjeux force-pushed the tabbar/lab-bar-lens branch from 6f87bcd to 81ca5e6 Compare November 3, 2026 15:32
@vjeux

vjeux commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Restacked on #9699 over #9754, since #9399 won't merge, so SearchField is gone from this diff. The lens now uses Core's renamed internal motion engine (createMotionEngine, useMotionEngine). No move here is state-change-only, so none uses the public createAnimator. The body notes what is open until promotion: tap moves play the theme's motion tokens, and only gesture-driven motion springs from velocity.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on two spec departures, and two independent changes are needed either way.

This pull request also changes Core, a Storybook story and CLI data, so current records bind TabBar at this head. An engineering owner needs to approve these departures or update the records first:

  • The lens and the bar's flex play measured UIKit springs instead of motion roles, which spec:AST-094 FR4 forbids.
  • Under touch the moving lens mounts a copy of the items in a bar that is not a layer, beyond what spec:AST-017 FR60 allows without an owner exception with before and after counts.

Required changes:

  • A second finger landing during a press must end it with nothing chosen (family:action-groups FR3), which Core's useActionGroupTracking already does, so route TabBar's press through that hook, extended for the lens, instead of its own long-press recognizer.
  • Move @astryx.tabBar.more, @astryx.tabBar.cancelSearch and the internal re-exports nothing here reads to #9701, which also lets the owner-directed batch cover this pull request's size.

The long comment above TabBar is still to be trimmed to its reasons (non-blocking). An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner.

[Automated review]

useGestureRecognizer(
capsuleRef,
() =>
new LongPressGestureRecognizer({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A plain LongPressGestureRecognizer ignores extra fingers once the press begins (LongPressGestureRecognizer.ts:100), so a second finger landing mid-press does not cancel: the first lift still chooses, against family:action-groups FR3 ("a second finger ends the gesture with nothing acting"). A finger slide also leaves focus on the tab the press left. Core's useActionGroupTracking already drives the shared tracker with both rules (ActionGroupPress, and focus after the lift). Suggest extending it with a target rule, a no-paint option and press callbacks for the lens, re-exporting it from the internal touch entry, and calling it here instead of this recognizer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. TabBar's press now goes through Core's useActionGroupTracking, which #9699 extends with a no-paint option and press callbacks for the lens, so TabBar's own long-press recognizer is gone. A second finger landing mid-drag ends the press with nothing chosen, and after a finger's slide focus moves to the chosen tab. Each has a new interaction case, red on the previous head.

Comment thread packages/core/locales/en.json Outdated
"defaultMessage": "Tabs",
"description": "Screen-reader-only fallback name for a horizontal tab bar. Plural noun; \"Tabs\" here = UI tab panels, not browser tabs or the Tab key."
},
"@astryx.tabBar.more": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing in this pull request reads @astryx.tabBar.more or @astryx.tabBar.cancelSearch, and the same holds for the PanGestureRecognizer, useFingerScroll, usePressTracker, cubicBezier and timing re-exports in internal/touch.ts. A shipped key is override surface whose removal is breaking (scripts/check-i18n-catalog.mjs). Please move these to #9701, where they are used.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. more, cancelSearch, useFingerScroll and usePressTracker left #9699; they land with the search tab. PanGestureRecognizer, cubicBezier and timing come from #9754, which other Lab batches share.

Comment thread packages/lab/src/TabBar/TabBar.tsx Outdated
Comment on lines +471 to +526
/*
* WHAT IT DRAWS
*
* A capsule floats above the bottom edge: 62 px tall, 21 px from the sides and
* the bottom (uikitTabBar.ts). With two or three tabs it is centred and
* narrower. Its items are 54 px tall and share one width, which UIKit derives
* from the capsule's width and the tab count. They spread evenly inside it, so
* neighbours overlap. The selection lens is a pill under the selected item, and
* the item draws its own selected look (accent colour, semibold label). While
* the lens moves under a finger, as it slides or lifts into glass, the selected
* look rides inside it, as on iOS. That look is a copy of the items,
* counter-translated so it stays over the buttons. So an item half under the
* lens is half tinted. That copy is mounted only while the lens moves; at rest,
* and always under a mouse, every item is drawn once. The lens's moving fill,
* and the glass it lifts into, are opaque and hide the plain items beneath the
* copy.
*
* Layout is CSS: the capsule is a size container and every position is a
* `calc()` of its width. So the bar is right before hydration and through a
* resize. Each button spans the capsule from halfway to its neighbours, so a
* press goes to the nearest item, which is the item UIKit picks.
*
* HOW THE LENS MOVES
*
* Under a finger (the touch presentation: a coarse primary pointer, on iOS and
* Android alike), useTabBarLens.ts moves the lens as UITabBar does. A finger
* landing lifts the lens while the bar flexes: the lens grows into clear glass
* and magnifies the item under it. A dragging finger is chased across the
* items, and the lift chooses the item nearest the finger. A selection made
* with the keys slides the lens on UIKit's spring (`tabBar.lensTravel`).
* Everything runs on the compositor, keeps its speed when retargeted, and plays
* each spring as measured (uikitTabBar.ts). That motion is TabBar's touch layer
* (TabBarTouch.ts), its one import site. Under a mouse (a fine pointer) the
* lens slides on the theme's duration and easing, a CSS transition on
* `translate` that the compositor runs. The selected look moves to the new tab
* at once. A press that drags moves the lens body on `lensTrack`, and its
* release settles it on `lensSettle`. Neither lifts the lens nor draws a copy
* of the items. The release chooses the tab under it. Under
* Reduce Motion nothing travels, and the animator applies it. On a touch screen
* the lens cross-fades: it fades out where it was and in at the new tab, in 200
* ms. A press scales nothing. Under a mouse the lens moves at once.
*
* SELECTION AND RESELECT
*
* The app owns the selection (`value` + `onChange`). Choosing the selected tab
* again calls `onReselect` instead: on iOS that pops the tab's navigation stack
* to its root (NavigationStack's `popToRoot`). On iOS 26.2 it does not scroll a
* screen already at its root to the top.
*
* Badges appear, change and disappear without animating, as UIKit's do.
*
* Accessibility: a `tablist` of `tab` buttons (`aria-selected`) with one tab
* stop. The arrow keys, Home and End move between tabs, and Enter or Space
* chooses one. A badge is part of its tab's name, one message with both
* ("Library, 3"; `badgeLabel`, or the badge itself).
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (INV29): this 56-line block repeats useTabBarLens.ts's description, and lines 522-525 restate the tablist roles and keys. Suggest keeping only the reasons: "Layout is CSS, so the bar is right before hydration and through a resize. Each button spans to halfway to its neighbours, so a press goes to the nearest item. While the lens moves under a finger, a copy of the items rides in it, so an item half under the lens is half tinted."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Taken: the block is now your three sentences of reasons, above the JSDoc.

});

test(
'storyboard: a mouse drag carries the lens on the theme’s motion, and the release chooses',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: the mouse drag plays lensTrack, a UIKit spring (useTabBarLens.ts:494-498), not the theme's motion. Suggest "a mouse drag carries the lens on the tracking spring, and the release chooses".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renamed to "a mouse drag carries the lens on the tracking spring, and the release chooses".

@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec b416f9f

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet, because GitHub cannot merge the branch into main.

#9754: approved earlier and landed.

#9699: approved. Its merge of main keeps ButtonGroup's finger resolver in the shared hook and adds paintsHighlight and the press callbacks beside it; AlertDialog and ButtonGroup pass neither, and their tests pass.

#9700: approved. An engineering owner's decision for this head covers the measured springs, the lens's copy of the items and the missing component record, and TabBar's interaction spec passes on this head.

Whole stack: passes. A pull request lands only when it and every pull request below it are approved.

Next: main now also adds NavigationStack's re-exports at the end of packages/core/src/internal/touch.ts and its test, where #9699 adds its own, so a restack is needed before CI can run; the new head needs its own /approve-spec.

[Automated review]

@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 5aaac13

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI is still running on this head.

#9754: approved earlier and landed.

#9699: approved; its merge of main keeps NavigationStack's new re-exports in Core's internal touch entry and adds the action-group hook's re-export after them, and AlertDialog and ButtonGroup pass none of the hook's new options.

#9700: approved; an engineering owner's decision for this head covers the measured springs, the lens's copy of the items and the missing component record, and TabBar's interaction spec passes on this head.

Whole stack: passes. A pull request lands only when it and every pull request below it are approved.

[Automated review]

# Conflicts:
#	packages/lab/src/index.ts
@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 64069d3

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI is still running on this head.

#9754: approved earlier and landed.

#9699: approved; its head is unchanged since the last review, and the action-group hook's new options stay on Core's internal touch entry, with AlertDialog and ButtonGroup passing none of them.

#9700: approved; an engineering owner's decision for this head covers the measured springs, the lens's copy of the items and the missing component record, and TabBar's interaction spec passes on this head after the merge with main.

Whole stack: passes. A pull request lands only when it and every pull request below it are approved.

Two optional clean-ups remain: the Reduce Motion case at TabBar.interaction.browser.spec.ts:1140 cites a design:reduced-motion DEC-4 that does not exist (DR2 is the clause), and nearestIndex in tabBarModel.ts is used only by its tests.

[Automated review]

@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec a2774b8

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision for this head, and one change is needed after it.

#9754: approved. It has landed.

#9699: approved. Its own patch is unchanged, and an engineering owner approved its current head.

#9700: waits on an owner decision, then needs one change. The latest /approve-spec names the previous head, so an engineering owner records the decision by commenting /approve-spec followed by this head's full SHA; the author may do so as an engineering owner. The decision covers:

  • Under touch, the lens and the bar's flex play UIKit's measured springs from uikitTabBar.ts instead of motion roles, key- and app-driven slides included (spec:AST-094 FR4).
  • Under touch, the moving lens mounts a copy of the items in a bar that is not a layer: the Default story goes from 30 elements at rest to 52 while the lens moves (spec:AST-017 FR60).
  • TabBar has no component record, so nothing current settles its behavior, including the badged tab's name @astryx.tabBar.badgedItem.

Blocking:

  • Miss: with no tab selected, a finger's paint fades out at the lift as a background-color transition on the main thread (TabBar.tsx:256-272), while spec:AST-094 FR5 requires a touch release to animate only transform, translate, scale, rotate or opacity off the main thread; paint it on the item's ::before and fade its opacity on --duration-exit and --ease-exit, as Button now does under a coarse pointer.

Whole stack: fails on that one #9700 finding. CI passes on this head. A pull request lands only when it and every pull request below it are approved.

Optional clean-up: nearestIndex in tabBarModel.ts is called only by its tests; delete it, or say in its comment that it models the shared tracker's column rule.

[Automated review]

Comment thread packages/lab/src/TabBar/TabBar.tsx Outdated
Comment on lines +256 to +261
backgroundColor: {
default: null,
[stylex.when.ancestor('[data-tab-bar-pressed]', tabBarItemMarker)]:
colorVars['--color-overlay-pressed'],
},
transitionProperty: 'background-color',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss: with no tab selected, this paint fades out at a finger's lift as a background-color transition, which runs on the main thread (a Chromium probe of the NoSelection story reads a 125 ms CSSTransition of background-color). spec:AST-094 FR5 requires every touch release to animate only transform, translate, scale, rotate or opacity off the main thread, so the fade stalls while the app renders the chosen tab. Suggest drawing the paint on this box's ::before with --color-overlay-pressed and fading its opacity on --duration-exit and --ease-exit, as Button now does under a coarse pointer.

@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 634a859

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision for this head, and one change is needed after it.

#9754: approved. It has landed.

#9699: approved. An engineering owner approved its current head, and its own patch is unchanged.

#9700: waits on an owner decision, then needs one change. The latest /approve-spec names the previous head, and this head only merges main through #9699, so an engineering owner records the decision again by commenting /approve-spec followed by this head's full SHA; the author may do so as an engineering owner. The decision covers:

  • Under touch, the lens and the bar's flex play UIKit's measured springs from uikitTabBar.ts instead of motion roles, key- and app-driven slides included (spec:AST-094 FR4).
  • Under touch, the moving lens mounts a copy of the items in a bar that is not a layer: the Default story goes from 31 elements at rest to 53 while the lens moves (spec:AST-017 FR60).
  • TabBar has no component record, so nothing current settles its behavior, including the badged tab's name @astryx.tabBar.badgedItem.

Blocking:

  • Miss: with no tab selected, a finger's paint fades out at the lift as a background-color transition on the main thread (TabBar.tsx:256-272), while spec:AST-094 FR5 requires a touch release to animate only transform, translate, scale, rotate or opacity off the main thread; paint it on the item's ::before and fade its opacity on --duration-exit and --ease-exit, as Button does under a coarse pointer.

Whole stack: fails on that one #9700 finding. CI passes on this head. A pull request lands only when it and every pull request below it are approved.

Optional clean-up: nearestIndex in tabBarModel.ts is called only by its tests; delete it, or say in its comment that it models the shared tracker's column rule.

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes are needed in #9700 before this stack can land.

#9754: approved. It has landed.

#9699: approved. An engineering owner approved its current head.

#9700: changes requested. An engineering owner's /approve-spec for this head decides TabBar's measured springs, the lens's copy of the items and its missing component record, so one fix remains:

  • Miss: with no tab selected, a finger's paint fades out at the lift as a background-color transition on the main thread (TabBar.tsx:256-272), while spec:AST-094 FR5 lets a touch release animate only transform, translate, scale, rotate or opacity off the main thread; draw the paint on the item box's ::before with --color-overlay-pressed and fade its opacity on --duration-exit and --ease-exit.

Whole stack: fails on that one #9700 finding. A pull request lands only when it and every pull request below it are approved.

[Automated review]

Comment thread packages/lab/src/TabBar/TabBar.tsx Outdated
Comment on lines +256 to +261
backgroundColor: {
default: null,
[stylex.when.ancestor('[data-tab-bar-pressed]', tabBarItemMarker)]:
colorVars['--color-overlay-pressed'],
},
transitionProperty: 'background-color',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss: with no tab selected, this paint fades out at a finger's lift as a background-color transition on the main thread (a Chromium probe of the NoSelection story reads one 125 ms CSSTransition of background-color). spec:AST-094 FR5 lets a touch release animate only transform, translate, scale, rotate or opacity off the main thread, so the fade stalls while the app renders the chosen tab. Draw the paint on this box's ::before with --color-overlay-pressed and fade its opacity on --duration-exit and --ease-exit. Core's shared press paint is no model here: it fades an animated custom property, which also repaints every frame.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 270f341. The paint is now this box's ::before, filled with --color-overlay-pressed, and only its opacity fades on --duration-exit and --ease-exit. The onset stays at once, and Reduce Motion keeps the fade within 200 ms. A new browser case checks that the lift runs one animation on the box: opacity on ::before. On 634a859 it fails in Chromium and WebKit with background-color.

With no tab selected, a held finger paints its tab, and the paint fades
after the lift on the theme's exit duration and curve. The fade was a
background-color transition, which runs on the main thread. The paint is
now a layer, and only its opacity fades, so the compositor runs the fade
through a main-thread block. Its timing is unchanged: at once on the
touch, then the theme's exit duration and curve at the lift, within
200 ms under Reduce Motion.

Also: TabBar calls itemWidthCss directly, the test-only nearestIndex is
gone (the shared tracker decides the tab a lift commits), and the Reduce
Motion case cites design:reduced-motion DR2.
@vjeux
vjeux marked this pull request as draft November 6, 2026 07:28
@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 270f341

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet; it runs once the pull request is marked ready for review.

#9754: approved. It has landed.

#9699: approved. An engineering owner approved its current head, and its patch is unchanged.

#9700: approved. An engineering owner's /approve-spec for this head decides TabBar's measured springs, the lens's copy of the items and its missing component record. The earlier paint finding is fixed: with no tab selected, the press paint is now a ::before layer whose opacity fades on the theme's exit motion, with the same look as before. Two non-blocking notes point at test references to the deleted tie cases.

Whole stack: no blocking finding. A pull request lands only when it and every pull request below it are approved.

[Automated review]

expect(Math.abs((await lensCenterX(page)) - profile.x)).toBeLessThan(0.5);
});

test('a lift between two items commits the nearer one (an exact tie goes to the leading one: tabBarModel.test.ts)', async ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: this push deletes the tie cases from tabBarModel.test.ts along with nearestIndex, so the parenthetical points at a test that no longer exists; the shared tracker's columns decide the lifted tab now. Suggest: a lift between two items commits the nearer one.

* @input vitest and tabBarModel.ts
* @output Checks TabBar's geometry against UIKit's layouts measured on iOS
* 26.2: the capsule's width, the items' width and centres, and right to
* left. Also the item a lift commits, the rubber band, the lifted lens and

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: this file no longer tests the item a lift commits (nearestIndex and its cases are gone). Suggest: left. Also the rubber band, the lifted lens and.

@vjeux
vjeux marked this pull request as ready for review November 6, 2026 07:56
vjeux added 2 commits November 6, 2026 01:40
# Conflicts:
#	packages/core/src/internal/touch.test.ts
#	packages/core/src/internal/touch.ts
@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/approve-spec e700938

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet. Its jobs stopped at admission because CI was full, so a re-run of all jobs is the next step.

#9754: approved. It has landed.

#9699: approved. An engineering owner approved its current head; its own patch is unchanged, and the merge of main keeps both sides of Core's internal touch entry.

#9700: approved. An engineering owner's /approve-spec for this head decides TabBar's measured springs, the lens's copy of the items and its missing component record. This push only brings main in through #9699, and TabBar's own patch is unchanged. Three non-blocking notes on the tests are in the review: two references to the deleted tie cases, and a local pointer helper that main now provides for Lab tests.

Whole stack: no blocking finding. A pull request lands only when it and every pull request below it are approved.

[Automated review]

This branch was successfully deployed

1 active deployment
Preview — e700938b Deployed Nov 6, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants