Skip to content

feat(core): SideNav's rail collapses by snap, clip and cross-fade; AppShell's content moves on its clock - #8723

Merged
cixzhang merged 107 commits into
mainfrom
feat/sidenav-rail-motion
Oct 28, 2026
Merged

cixzhang merged 107 commits into
mainfrom
feat/sidenav-rail-motion

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Held for Cindy's inspection: do not merge. Based on main, which now carries the module's in-place swap (#8734, spec:AST-105 FR8).

What changes for people

Collapsing or expanding SideNav's rail no longer snaps the whole shell. Layout snaps to the final width at once (nothing animates a width), the rail covers or reveals its labels with a clip and ends in its real collapsed state (icons only, exactly today's collapsed rail), and inside AppShell the page slides with it: its old line wrapping fades out over the new one as both slide. One motion, one clock. No new props: every AppShell and SideNav gets it.

Authority (current on main since #8720 merged): component:SideNav FR6 / DEC-2, component:AppShell FR17, spec:AST-105 FR2 (AppShell's content area holds its own handle), FR4 (one shared motion, springs.settle) and FR8 (the in-place swap's one transient copy), architecture:react-component-runtime INV12, architecture:component-style-authoring INV7, design:reduced-motion DR2/DR3. The one shared clock is spec:AST-105 FR4.

Expand, frames paused at 0, 16, 33, 100, 200 ms expand first frames
Collapse, frames paused at 0, 16, 33, 100, 200 ms collapse first frames
Desktop collapse desktop collapse
Desktop expand desktop expand

Frames are real Chrome at quarter speed, labelled in real-time ms. Measured on every frame: frame 0 is exactly the state before the click (no expand jump, no line cut), nothing animates a width, nothing scrolls sideways (page or panel), and at rest the rail is 48 px with no labels and no copies left in the DOM.

390 px, elevated shell, outside AppShell, Reduce Motion, and an unchanged legacy shell
390 px viewport, collapse 390 collapse
390 px viewport, expand 390 expand
390 px viewport, expand, paused frames 390 expand first frames
390 px viewport, collapse, paused frames 390 collapse first frames
AppShell height="auto" (the page scrolls), collapse auto collapse
AppShell height="auto", expand auto expand
Elevated shell, collapse elevated collapse
Elevated shell, expand elevated expand
SideNav outside AppShell: rail clip only bare
Reduce Motion, mouse (DR3): at once reduced fine
Reduce Motion, finger (DR2): opacity only reduced coarse
Existing AppShell, no new props, rail not collapsible: unchanged legacy

Fine-pointer before and after, from the Storybook story (spec:AST-105 FR5)

The new "Rail collapse motion" stories, built from base and from this head, run in real Chrome with a mouse and paused at 0, 100, 200 and 700 ms. Before, the rail and the page snap. After, they move on one motion.

Before (base) After (this PR)
Desktop, collapse before after
Desktop, expand before after
390 px, collapse before after
390 px, expand before after

How

  • AppShell (FR17) holds its own animated-layout handle over the shell. When the rail changes, it reports the rail's size change to the module, so the column beside the rail (the rail's following sibling) glides from its old place, main's clip included. The content also swaps its old wrap for its new one through the module's in-place swap: a copy of the old wrap's visible part, beside the column, rides the column's actual glide frame by frame (so old and new share one left edge on every frame, and when the column lands silently out of view the copy lands with it) and fades out while the new wrap fades in. On an expand, the narrower column slides in from its old start, and the content surface (the elevated backdrop, or main) reaches over the trailing gap as it closes. In right-to-left documents, the glide follows the inline-start edge.
  • SideNav (FR6), in railMotion.ts, does only the rail's snap and clip:
    • A collapse covers the old, expanded rail, held by the same swap, with a clip down to the new width. Its text fades over the first 40%. The real collapsed rail, raised above the copy, fades in over the last 30%.
    • An expand clips the real rail open and fades its own text in over the last 60%, under the old, collapsed rail held by the same swap, which fades over the first 60%. The first frame is the frame before the click.
    • In AppShell the rail's clip rides the content column's glide frame by frame (the swap's ride), so the rail's edge and the column's never part, also when a press interrupts a running motion. A change mid-motion starts from what the rail shows: its width, its labels and its icons. When the old rail can't be copied, the clip follows the motion's curve.
    • The rail's copy is laid in the row that holds the rail and the column, so an auto-height side wrapper's clip doesn't cut it.
    • Outside AppShell, SideNav holds its own handle over its parent, and a resizable rail, whose nav remounts with the collapse, plays too.
  • One clock: every part reads springs.settle through the module and starts on the same timeline time. Starting clips and translates apply before the first frame paints.
  • Reduce Motion: opacity only under a coarse pointer (DR2), at once otherwise (DR3).
  • Copies come only from the module's one mechanism: inert, aria-hidden, with no ids, form or radio group, and loading no media. They're removed when the motion ends, is interrupted, or the rail unmounts.

Size (architecture:component-style-authoring INV6)

Gzip JavaScript each import loads eagerly, measured with CI's own measureComponentImport (esbuild, minified, split), with base = main and head = this PR on #8734:

Import Base Head Change
AppShell 59,399 B 66,072 B +6,673 B (+11.2%)
SideNav 59,536 B 65,408 B +5,872 B (+9.9%)

Most of it is the animated-layout module and its swap (#8734), which AppShell and SideNav now load for the rail motion. Neither changes for an app that doesn't import them.

Tests

Failing first: SideNavRailMotion.test.tsx has 22 cases:

  • collapse clip, slide and one clock; the content cross-fade beside the column
  • expand reveal under the old rail, before paint
  • changes mid-motion: from the shown width, continuing labels and wraps, one rail copy on reversal, a third press
  • the auto-height row host; an expand and a collapse without a copy, and one that interrupts a running expand
  • no copies at rest; DR3 and DR2 (the surface stays put; an expand fades the old surface out); outside AppShell; a page radio stays checked
  • resizable rails inside and outside AppShell; unmount mid-motion cleans up
  • the copy on the rail's edge wherever SideNav sits; no Web Animations

Real Chrome, every frame: frame 0 is exactly the pre-click state, and the real content and the old wrap share one left edge on every frame (paused at 0/16/33/100/200 ms: 73 → 77 → 87 → 149 → 225 px expanding, 285 → 281 → 271 → 209 → 133 px collapsing, both in view and scrolled out of view). Nothing animates a width or scrolls sideways, the page radio stays checked, and at rest the rail is 48 px with no labels and no copies. The SideNav, AppShell, Layout, motion, Collapsible, TreeList, TopNav and MobileNav suites pass (1392 tests).

Size

Over the loop's size guide (product +331: railMotion.ts 191, AppShell 81, SideNav 54, the module doc 5); the shared swap itself is in #8734.

…t around a size change

When something inside a registered area changes size, the content after it
glides from its old place to its new one with a compositor-only translate
instead of jumping (architecture:react-component-runtime INV12, proposed
in #8463).

- motion/animatedLayout.ts: one batched measurement before and after the
  change; a bounded parentElement/nextElementSibling walk up to the area
  (no TreeWalker), descending only through display: contents; absolute and
  fixed elements skipped; only elements whose rectangle changed and that
  are visible move; scale-corrected; Web Animations translate with
  composite: 'add', feature-tested per call with fallbacks; offscreen
  elements land silently; Reduce Motion cross-fades with no travel; every
  pointer.
- useAnimatedLayout(ref) (public) registers an area; reportLayoutChange
  stays internal. ScrollableArea registers its viewport itself.
- Collapsible and TreeList adopt it: the shown content plays its own
  entrance, and the chevron turns, on the same placeholder layout-change
  role as the glide.
- Storybook: Core/Animated layout, with a nested tree, a Collapsible, a
  field error, load more, wrapping tags, plain divs, a display: contents
  wrapper, a sticky header and an absolute badge.

Draft for trying it out; tests, docs and a changeset follow.
…ollapsible and TreeList take as animatedLayout

The API shape the owner set: the hook takes {containerRef} and returns an
opaque handle; Collapsible and TreeList accept an animatedLayout prop and
report their size changes through it, with no context registration.
Without the prop, a component keeps today's behavior, its chevron
included. ScrollableArea no longer registers itself: an app passes the
ScrollableArea's ref as containerRef. The story uses exactly this shape.
Adds the changeset.
…subpixel rounding

A real-Chromium check showed unscaled followers moving by 79.92 px for an
80 px shift: subpixel rounding made rect.width / offsetWidth read a hair
off 1. A scale within 1% of 1 is now treated as none.
…tion by pointer, visible-part rule for the changed element, top-layer and hidden skipped, chevron on the spring)
- One shared motion role: the glide, the changed element's entrance and the
  disclosure chevron all play springs.settle, resolved through the internal
  motion-role registry (a theme's --motion-springs-settle reaches all three);
  the glide's keyframes are sampled from the spring and played linearly.
- Reduce Motion by pointer (design:reduced-motion DR1-DR3): a cross-fade
  under a coarse primary pointer, an immediate change otherwise.
- The changed element outside the area's visible part lands without
  animation; hidden and top-layer elements are skipped.
- Collapsible and TreeList take the chevron from their Icon's icon target
  and set its transition before the change commits; no handle leaves every
  inline value untouched.
- Tests: the walk, motion and handle in jsdom; Collapsible and TreeList with
  and without a handle; one real-browser case per AST-105 Coverage row, plus
  FR2-FR5.
- Docs: the useAnimatedLayout hook doc (FR7 text), the animatedLayout prop in
  the Collapsible and TreeList docs and records, and a Before and after story.
… registry only

Motion roles are internal (spec:AST-094 DEC-11): the glide, the changed
element and the chevron share the registry's one value, and a scope's
--motion-springs-settle changes nothing. The tests assert that instead of
an override.
… and compare scrolling with and without a handle

Run in real Chromium (all 20 cases, three repeats): the page helper's
microtask wait is plain JavaScript; the out-of-view and nested-area cases
wait for the first change's glide to finish before the second; and the FR3
scroll case compares the area's scroll frame by frame with and without a
handle, since the browser's own scroll anchoring moves it either way.
…ed ones and retargets on the compositor

- A single-mode CollapsibleGroup measures every item a toggle changes (the
  one that closes as well as the one that opens), so the items between
  glide with the content after the group. Items holding a handle register
  their measurement with the group.
- A measurement no commit claims by the next frame (a controlled owner
  refused the toggle) is dropped, so it never moves unchanged rows later.
  A change nothing measured (an owner set isOpen directly) lands at once,
  entrance included.
- The follower glide is one replace-composited translate, which the
  compositor runs (architecture:react-component-runtime INV12). An element
  already gliding is retargeted: its glide stops and the new one starts from
  where it is on screen, so it keeps moving without a jump.
- INV12's source boundary check (no layout property animated, the walk only
  through parentElement / nextElementSibling, nothing public, only the hook
  and the adopters use it) and a real-browser case on a throttled phone
  over a 10-level by 50-sibling tree with two changes in one frame.
spec:AST-105's Coverage row: an element already moving keeps its running
motion and adds the new move. The new glide now samples the sum of the
running glide's remaining curve and the new move into one replace-composited
translate, so the element keeps its speed through the change instead of
restarting from rest. Every animation of a change starts on the document
timeline's current time, so a replaced glide moves in the very frame it is
replaced and the changed element and its followers share one start. Tests:
the carried speed (jsdom) and, in a real browser, no jump and no lost speed
across the change.
…e morph once Core has it, its own compositor translate until then
…st adopt it in their own pull request

The module, the hook, their tests and docs, and the INV12 record line stay
here. Collapsible's and TreeList's adoption (their animatedLayout prop,
docs and records), the CollapsibleGroup measurement, the adopter tests and
the Storybook story move to a stacked pull request, so each lands and
reverts on its own. The changeset now describes the hook alone.
Restores the adoption from the module pull request onto it: the
animatedLayout prop on Collapsible and TreeList (docs and records), the
CollapsibleGroup measuring every item a toggle changes, the adopter tests,
and the Storybook story. The story keeps only adopting components and plain
following content; its demos that drove the internal reporting functions
directly are gone.
…motion

spec:AST-105 FR4: the changed element plays its own animation when it exits
too. holdLayoutExit keeps a closing element on screen but out of flow
(absolutely positioned where it is), so the content after it glides up at
once, and conceals it upward from its bottom edge on springs.settle (a fade
under Reduce Motion with a coarse pointer, nothing with a fine one); the
component hides it when the exit finishes, and releaseLayoutExit puts it
back, including when it opens again mid-exit. The throttled-tree browser
case now reads its animations in the same task, so a slow round trip can't
outlast the glide.
…'s exit

spec:AST-105 FR4: the changed element animates when it exits too. A measured
close keeps the Collapsible's content (or the TreeList row's child group)
rendered, inert and hidden from assistive technology, while holdLayoutExit
holds it out of flow and conceals it on springs.settle; the rows after it
glide up at once, and the content hides (or the group unmounts) when the
exit ends. Opening again mid-exit ends it. Rows of an exiting group leave
the tree's keyboard order at once. An unmeasured close, no handle, or an
exit that lands at once hides it in the same commit.
…es with its row; the exit stays in flow

- This pull request is now the internal module only: the public hook, its
  docs and the chevron's transition move to the adoption pull request, so
  nothing public appears before a component can take the handle.
- A changed element that an earlier change in the same frame displaced moves
  with its row, by its total change (AST-105 FR3), while it plays its own
  entrance or exit; an element not rendered before the change has no old
  place and does not move.
- The exit keeps the element in flow where it is and gives up its space
  with a negative end margin as large as its box, so it scrolls and clips
  with its container and keeps its width (no offset-parent positioning).
- Browser proofs: the sticky row uses a stuck header after the change, the
  flex row a wrapped item and a row-direction neighbor whose rectangle does
  not change; new cases for the displaced changed element and the exit in a
  scrolled area.
… chevron move here

The module pull request is now internal only, so this one carries the
public face with the components that take it: useAnimatedLayout, its doc
page and export (and its docsite route), applyLayoutChangeTransition and
its tests, and the boundary test's list of users (the hook, Collapsible and
TreeList). The adopters follow the module's in-flow exit: their tests check
the exit's end margin instead of absolute positioning. The changeset
describes the hook and the two adopters together.
…e app moves are left alone

- holdLayoutExit measures the layout after the element with it hidden and
  sets the end margin that puts that layout there, so a gap or collapsing
  margins come out exact and nothing jumps when the element hides; where no
  margin can free the space (a grid track and its gap) the exit lands at
  once instead.
- The held element is inert while it conceals (no focus, no pointer,
  nothing read); the module sets and releases it.
- A follower with its own transform, translate, rotate or scale, or with a
  running transform animation the module did not start, takes its final
  place at once, as AST-105's Coverage lists for elements the app moves.
- Tests: the exact space with a gap (jsdom), the grid fallback, app-moved
  followers; real-browser cases for a flex gap and collapsing margins, and
  for app-moved followers.
… left alone) into the adoption

The module now makes a held element inert, so Collapsible's content and
TreeList's child group drop their own inert and aria-hidden while exiting
(TreeList keeps the marker that takes the group's rows out of the keyboard
order).
…red close, and restores what it replaced

- giveUpSpace measures the first element after the closing one in the area
  (its next sibling, or an ancestor's below the area), not the parent's
  edge, which a collapsed negative margin never moves: a Collapsible-shaped
  close (the last child of a plain wrapper) now plays its exit.
- holdLayoutExit holds only a close a change in the area measured, and its
  documentation asks for the call after animate() reports it measured.
- releaseLayoutExit puts back the element's own inert and inline values.
- AST-105's out-of-coverage list names a closing grid item whose track or
  gap changes when it hides (owner-approved): it hides at once, without its
  exit.
- Tests: an unmeasured close holds nothing; prior values restored; a real-
  browser case for the plain-wrapper shape.
…d holds only a measured close) into the adoption
…al next box and checks what follows its container

- The flush stops every running glide and reads every new place before
  starting any glide, and an element inside another moving element moves
  only by its own change: nested changes compose whichever is reported
  first (AST-105 FR3).
- The exit's search for the next box enters a display: contents wrapper,
  as the follower walk does.
- The exit checks the content after its container on both axes, so a
  flex-row item that sets its row's height is refused instead of leaving
  the content below to jump.
- A refused exit puts back the element's own end margin and drops its
  change, so the content after it takes its final place at once.
- AST-105's out-of-coverage line names both layouts no margin frees (owner-
  approved).
- Tests: a refused hold keeps its own margin and glides nothing; real-
  browser cases for nested changes reported inner-first, a display:
  contents next box, and a tall flex-row item.
…ext box and container checks) into the adoption

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The rail motion matches the current SideNav and AppShell records, and from rest the rail's edge stays on the content's edge on every frame, but one case needs a fix before approval. When a rail the swap can't copy (a canvas that reads back blank, or more than 1,500 elements) is collapsed while an expand is still moving, the early return in SideNav.tsx skips the content's change report, so the expand's glide keeps carrying the page over the collapsed rail (its left edge at −29 px beside a 64 px rail) for about half a second. Please land the column's running glide in that branch too and add a test that presses the no-copy collapse during an expand.

[Automated review]

Comment on lines +517 to +519
if (snapshot != null && snapshot.swap == null && !wasCollapsed) {
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When this collapse interrupts a running expand, returning here skips beforeRailChange(), and running?.endContent() above ends only the wrap swap and the cover, so the column keeps the expand's glide. In Chromium, with a blank canvas icon in the rail, a collapse pressed 120 ms into an expand put the content's left edge at −29 px beside the 64 px rail, sliding back over about 560 ms (component:SideNav FR6: "The main content moves on the same clock"). Landing the column's running glide in this branch too, with a test that presses the no-copy collapse during an expand, would close it.

@cixzhang
cixzhang changed the base branch from feat/animated-layout-swap to main October 28, 2026 06:19
…ng glide too

The module gains landLayoutGlide (the element takes its layout place and a
later change starts from there); AppShell lends it to SideNav as landContent,
which a collapse that cannot copy the rail calls with its early return.
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 28, 2026
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 266efdc

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The rail and content motion matches the current SideNav and AppShell records and starts from the frame before the press, but two fixes are needed before approval:

  • Under Reduce Motion with a coarse pointer, AppShell still reports the rail change to the animated-layout module, which fades the whole content column in from 0, so the page's surface blinks to the shell colour on the first frame; report the glide only for a spring motion and assert in the DR2 test that no ancestor of main animates.
  • packages/core/src/SideNav/railMotion.ts calls createContext without a 'use client' directive, which fails pnpm lint (check:use-client); add it after the copyright header.

[Automated review]

Comment thread packages/core/src/AppShell/AppShell.tsx Outdated
Comment on lines +819 to +820
const change = reportLayoutChange(contentLayout, railRef);
change.measureBefore();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under Reduce Motion with a coarse pointer the module turns this reported change into a fade of every moved element (animatedLayout.ts:851-856), so the column that holds main, surface included, fades 0 → 1 beside the swap's own fades. In Chromium the first frame shows the content area at the shell colour (#ffffff → #f1f1f1; dark #262626 → #1b1b1b), and the new wrap peaks at 25% at 100 ms. Reporting the glide only when layoutMotionFor(main).kind === 'spring' would leave DR2's cross-fade to the two wraps over a steady surface.

* SideNavRailMotion.test.tsx.
*/

import {createContext} from 'react';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This module calls createContext at module scope, but it has no 'use client' directive, so scripts/check-use-client.mjs (part of pnpm lint) fails on this head with "SideNav/railMotion.ts: missing directive". Adding 'use client'; after the copyright header, as SideNavCollapseContext.ts does, clears it.

… is a client module

- AppShell reports the column's glide only for a spring motion, so under
  Reduce Motion with a coarse pointer the two wraps cross-fade over a steady
  surface instead of the whole column fading in (design:reduced-motion DR2).
- railMotion.ts calls createContext, so it carries 'use client'.
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec aabae5d

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The rail and content motion matches the current SideNav and AppShell records, and both fixes from the last review hold: the content column no longer fades under Reduce Motion, and railMotion.ts is a client module. One case still needs a fix before approval: under Reduce Motion with a coarse pointer, an expand turns the 212 px strip the old content sat on from the surface colour to the frame colour in its first frame and keeps it there through the cross-fade, because the surface takes its new place at once while only the text cross-fades (AppShell.tsx). Let the old surface fade out with the old wrap there, for example by having the wrap's copy paint the surface it sat on, and add a Reduce Motion expand case that checks the first frame keeps the old surface.

[Automated review]

// Only a spring glides the column; under Reduce Motion the two wraps
// cross-fade over a steady surface (`design:reduced-motion` DR2).
let change: LayoutChange | null = null;
if (motion.kind === 'spring') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under Reduce Motion with a coarse pointer this skips the glide, so the content surface takes its new place at once while only the two wraps cross-fade. On an expand that leaves the strip the old wrap sat on (x 48 to 260 px) unpainted: frame 0 turns it from #ffffff to #f1f1f1 and it stays so through the 200 ms fade, where FR17 asks for DR2's cross-fade ("a leaving element fades out where it is"). Letting the old wrap's copy paint the surface it sat on under DR2 (the swap's isOpaque, as the rail's copy does) would fade the strip instead, and a DR2 expand case that checks frame 0 would hold it.

…th the old wrap

Nothing glides under Reduce Motion with a coarse pointer, so the old wrap's
copy paints the content surface it sat on: the strip the narrower page leaves
fades from the surface colour instead of turning bare at the first frame.
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 95a7245

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This looks ready to approve: the rail's snap, clip and cross-fade and AppShell's sliding content match the current SideNav FR6 and AppShell FR17 records, the fixes requested on earlier revisions hold, and the settled state is pixel-identical to today's. Two transient details are worth a look before merging: icons below a section title appear at two heights while the rails cross-fade, and in the section variant no divider is drawn at the rail's moving edge. As a follow-up, the SideNav, AppShell and AST-105 records still describe this motion as not implemented.

[Automated review]

@cixzhang
cixzhang merged commit b8c5e4a into main Oct 28, 2026
76 checks passed
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 28, 2026
…dates base: a real conflict in the animated layout module

# Conflicts:
#	packages/core/src/motion/animatedLayout.test.ts
#	packages/core/src/motion/animatedLayout.ts
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 28, 2026
…8723) into the List updates change

# Conflicts:
#	packages/core/src/motion/animatedLayout.boundary.test.ts
@cixzhang
cixzhang deleted the feat/sidenav-rail-motion branch October 28, 2026 10:28
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 28, 2026
useScrollableArea.ts conflicted: meta#8041 merged in a later form than the one this
branch carried. That file takes this PR's version, which is meta#8041's merged code
plus this PR's own changes (the shared mutation observers and the watch on a
viewport's direct children).

AppShell.tsx merged textually but declared mainRef twice: meta#8723 added an object
ref for the content glide, and this PR's ref is the scroll measurement under
fill. This PR's becomes mainScrollRef, and main's node goes to both through
useMergedRefs.

This branch was successfully deployed

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

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant