Skip to content

test(interaction): frame evidence - #8045

Merged
cixzhang merged 117 commits into
meta:mainfrom
vjeux:interaction-harness-3-frames
Oct 28, 2026
Merged

cixzhang merged 117 commits into
meta:mainfrom
vjeux:interaction-harness-3-frames

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Maintainer impact

"Runs on the compositor" is claimed in reviews but never shown: nothing records what the screen presents while the main thread is busy.

Intended invariant

No product change. A motion claim comes with frame evidence: per-frame positions from composited frames, judged through a busy main thread.

Change

  • frames.ts: timestamped composited frames (Chromium's; WebKit's, rebased from its monotonic clock), in presentation order, with per-frame marker boxes and frame stats.
  • busy.ts: block the main thread mid-flight, from a task queued once the animation is on the compositor (not pending, a frame in), and judge the frames the screencast delivered: no stall over three intervals, ≥ 90% of the delivered frames moving (an undelivered frame is a gap for the stall check, not a miss), at least four judged intervals, a median limit only for display-rate claims. A just-started animation is judged from when it reaches the screen; given the compositor's own presentation record (presented), frames the screencast skipped while the compositor presented them are not stalls; the proof reports the cast's position step (WebKit's is 2 px).

Split for scope: the rendering traces, the long-task log and the compositor presentation recorder are #8548; castStoryboard, sampleRects, openStory's Storybook highlight default and the WebKit shader cache are #8549 (both stacked on this one). #8423 shows these instruments on BottomSheet's detents.

Evidence

  • Failure before the change: no animation has frame evidence.
  • Success after the change, with the self-test's negative controls:
    • a compositor animation moves on every presented frame through a 700 ms block, while a rAF animation presents nothing new;
    • a loop inside the rAF callback froze a just-started transition in WebKit (0 frames presented); from a task it advances;
    • under CPU contention (8 busy loops, 4 cores): 20/20 and 30/30 passes; the rAF control failed every run;
    • at one screencast frame in six (about 100 ms per frame, as a loaded machine delivers), the compositor animation passes and the rAF one still fails, in Chromium and WebKit.
  • Product/runtime behavior verified unchanged: no package outside the harness changes; changeset coverage reports nothing shipped.

Scope

  • No intended public API, product behavior, visual direction, or policy change.
  • Product changes discovered during the work were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

  • At this head (main with test(interaction): deterministic storyboards #8348 landed): Chromium harness self-specs, 45 passed; busy.test.ts 29 passed (the verdict's limits, the on-screen start, the position step, the synthetic compositor-record cases, and a window that delivered under four frames judged against its span, which fails on the previous rule); harness typecheck and eslint clean.
  • Earlier rounds on the same busy code (before the split): WebKit 26.5 in the container, the busy tests 6–10 times each; the busy verdict's unit tests fail on each previous rule they replace (late on-screen start, the step report, an irregular cast judged on its delivered frames).
  • Changeset coverage: nothing shipped.

@vercel

vercel Bot commented Oct 24, 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 Oct 26, 2026 9:56pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 26, 2026, 10:11 PM UTC

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

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

No new or modified components detected.

Bundle Size Summary

No component packages changed.

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

55 of 502 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 35 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@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.

Approval waits on an engineering owner's decision. The BottomSheet detent spec asserts gesture and settle outcomes that the current BottomSheet contract does not state: a slow release settles on the nearest detent, a downward flick dismisses, and the settle is a composited transition with no Layout or Paint. An engineering owner needs to provide or identify the governing spec for those outcomes, for example in the BottomSheet contract or the motion design record, or the exemplar should stop pinning them; any engineering owner, the author included, records that decision by commenting /approve-spec <full head SHA> on this pull request. Whatever that decision, these changes are needed:

  • Join pr-interaction to the existing test owner, as test-native-swift does, or admit it through an AST-030 amendment, so a failing proof fails a required check.
  • Build the settle-curve oracle from fixed easing and duration values: the current check passes a 900 ms ease-in or 150 ms linear settle as readily as the real one.
  • Make busyWindowProof fail, and give the exemplar a frame floor, when the judged window is empty; with the block started 244 ms into the settle, all five assertions pass with zero frames.
  • Land #8043 and #8044 first, so this diff carries its own 1,443 authored lines instead of 4,561.

[Automated review]

Comment thread .github/workflows/ci.yml Outdated
# 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:

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 job routes itself with its own pr-scope-touches.sh pattern, runs only for pull_request, and no required join reads its result. spec:AST-030 FR11 rules that out for new coverage inside an existing surface ("MUST join those owners and MUST NOT create a separately routed lane or required context"), and FR7 requires that "Any owned lane failure MUST fail its join". test-native-swift shows the conforming shape: a fail-closed scope job, plus entries in test.needs and ci-test-join.mjs.

);
const from = samples[0].value;
const to = samples[samples.length - 1].value;
const ease = timingFunction(transition.easing);

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 oracle takes its easing and duration from the animation under test, so this check cannot fail on the curve's shape or duration. On the Snap points story in Chromium it also passes with the settle forced to ease-in over 900 ms (0.094 px) or linear over 150 ms (0.30 px), and CI ran it on a 300 ms settle while the header says 410 ms. Pin the expected values, for example cubic-bezier(0.24, 1, 0.4, 1) and the story theme's 300 ms, and assert transition.easing and transition.durationMs against them (architecture:component-test-sufficiency INV6).

end = Math.min(end, arrived.t);
}
}
const inner = {from: window.from + marginMs, to: end};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With settledWithin, end can come before window.from + marginMs, which leaves inner empty or inverted. frames and medianFrameMs are then 0, expectedFrames is -1 and longestStallMs is negative, so every assertion in the BottomSheet exemplar passes with nothing judged. Starting the block 244 ms into the 300 ms settle on the Snap points story does exactly that. Throwing, or returning a failing proof, when the judged window is shorter than a few frames would close it.

# Conflicts:
#	internal/interaction-harness/src/curves.ts
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts
@vjeux

vjeux commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

The scope rule caps a pull request at about 2,000 changed lines. Now that #8505 has landed, this branch's own commits change 3,070 against #8348's head, and astracat has asked in each of its last six reviews for instruments to move out. Moving only the five it names leaves about 2,300, so a split that clears the rule also takes out the frame-evidence core and leaves the busy-window proof here:

PR Content ≈ lines Open PRs importing it
A Frame-evidence core: screencast, markers, frame stats, traceRendering 760 130
B Long-task reporting (on A) 275 79
C castStoryboard (on B) 130 92
D sampleRects (on C) 105 8
E Storybook highlight default (on D) 160 none set the option
F WebKit shader cache (on E) 75 none (CI only)
#8045 Busy-window proof, merging F 1,550 130

Each slice is a byte-identical part of this branch, stacked on #8348. This pull request merges F, so its head and tree stay as they are, every open pull request keeps importing from it, and each slice folds cleanly when main squashes it in.

Should it split this way, or does your Oct 25 line-count waiver for new behavior modules (cited on #8055 and #8056) cover the harness?

@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.

Changes are needed for scope only: the frame-evidence instruments pass their positive and negative controls in Chromium and WebKit, and no defect was found in the code. The diff is 4,120 authored lines, and this pull request's own 3,105 stay above the 2,000-line scope threshold even after #8348 (the other 1,053) lands. Please land #8348, restack, and move the long-task reporting, the Storybook highlight default, castStoryboard, sampleRects and the container shader cache into their own pull requests. If the busy-main-thread proof alone still exceeds 2,000 lines, please explain why it cannot be split further under spec:AST-094 FR5.

[Automated review]

…r docker

Docker ran the Playwright image as root over the bind-mounted checkout, so
the test-results/ it wrote were root's and the next host run failed with
EACCES clearing them. Under docker the container now runs with the host uid
and gid (HOME=/tmp); rootless podman already maps the user. The README says
so.
…-user

Rootless Docker maps the container's root to the invoking user, and any
other container uid to a subordinate host uid that cannot write the mounted
checkout, so --user made the WebKit run fail with EACCES there. The script
passes --user only to a rootful daemon (docker info reports no
name=rootless); the README and header say so.
…o the frame evidence

# Conflicts:
#	internal/interaction-harness/scripts/webkit-container.mjs
Scope: castStoryboard, sampleRects, openStory's Storybook highlight default
and the WebKit container's shader cache move to a follow-up pull request,
unchanged, with their self-tests and README text. The frame evidence keeps
the screencast, marker tracking, frame stats and the busy-main-thread proof.
…f the frame evidence

Scope: traceRendering, countBetween and the long-task log (with the
harness-read marks the trace uses, their self-tests and README text) move to
a follow-up pull request, unchanged. trace.ts keeps startCompositorTrace,
which the busy-main-thread proof reads.
…e frame evidence

Scope: startCompositorTrace (trace.ts), its Chromium self-test and the
recorded skip fixture with its unit test move to the follow-up pull request
with the rendering traces, unchanged. busyWindowProof keeps its presented
option and its synthetic compositor-record tests; the README and JSDoc say
where a record comes from without naming the moved helper.

@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.

Changes are needed for sequencing more than for the code: the busy-main-thread instruments pass their positive and negative controls in Chromium and WebKit, and after the split this pull request's own commits change 1,872 lines, but the diff is still 2,918 because #8348 (1,080 lines) sits beneath it. Please land #8348 and merge main so this diff carries only its own commits; those need no line-count waiver.

  • README.md line 36 promises busy-main-thread frames in test-results/interaction-evidence/, and lines 153-156 point to a Display::FrameDisplayed trace that no instrument records at this head.
  • The description still lists trace.ts, castStoryboard, the highlight default, the shader cache and their tests, which have left this pull request.

[Automated review]

Comment thread internal/interaction-harness/README.md Outdated
node internal/interaction-harness/scripts/webkit-container.mjs [playwright test args…]
```

Evidence (storyboards, curve traces, busy-main-thread frames) is written to

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nothing at this head writes busy-main-thread frames here: only storyboard.ts calls writeEvidence, and the busy proof returns a summary that the self-tests attach as an annotation (the busy-window filmstrip left with castStoryboard). Dropping "busy-main-thread frames" until that instrument lands keeps this line true.


On a loaded machine Chromium's screencast can skip frames the compositor
still presents. Pass when the compositor presented each frame (epoch ms on the
page clock, from a trace of `Display::FrameDisplayed`) as `presented`. A gap between two moved

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No harness instrument records Display::FrameDisplayed at this head (startCompositorTrace left with the split), so a proof author cannot follow this sentence. Saying that presented comes from the proof's own trace until the recorder lands would fix it.

@vjeux

vjeux commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

A busy-main-thread proof on WebKit can fail for frames its screencast cannot resolve, and callers now work around it one spec at a time. Inside 2 px of rest, a spring's tail moves less than one of WebKit's 2 px cast steps a frame, so it reads 362, 362, 362 before a 366 rest. With settledWithin: 2, those repeats fall inside the judged window as frames that did not move (13 of 15, under 90%). This branch moves the rule into busyWindowProof: vjeux:uikit/x1-busy-settle-steps, which fast-forwards this pull request's 8715133 (its change: vjeux/astryx@8715133...c3bbd6a).

  • settledWithin widens to settleSteps (2 by default) of the cast's own stepPx, which the proof already measures. settleSteps: 0 judges to settledWithin exactly.
  • The proof reports the bound as settledWithinPx and adds a note when it widened.
  • A unit case replays the recorded WebKit tail: it fails on this pull request's busy.ts and passes with the change. The whole-pixel easing case keeps its strict reading with settleSteps: 0.
  • On the BottomSheet detent spec in WebKit, with settledWithin: 2 and no override, the settle proof fails 3 of 3 on this pull request's busy.ts (13 of 15 moved) and passes 3 of 3 with the change, its last-frame check reading settledWithinPx. Chromium passes 2 of 2.

Harness unit tests: 54 passed.

# Conflicts:
#	internal/interaction-harness/README.md
#	internal/interaction-harness/package.json
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts
#	internal/interaction-harness/src/story.ts

@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.

Changes are requested for two documentation fixes only: the busy-main-thread instruments discriminate as described in Chromium and WebKit (a compositor animation moves on every delivered frame through the block while the rAF control presents none), and with #8348 landed the diff is this pull request's own 1,872 lines.

  • internal/interaction-harness/README.md line 36 says busy-main-thread frames are written to test-results/interaction-evidence/, but nothing in this change writes them; please drop that phrase until a writer lands.
  • The description's Change, Evidence and Testing sections still describe trace.ts, the compositor recorder, castStoryboard, the highlight default and the shader cache, which have left this pull request; please rewrite them for what it now contains.

[Automated review]

…hat ships

Nothing in the frame evidence writes busy-main-thread frames to the evidence
directory, so the README no longer lists them there; and the busy recipe's
block is spec:AST-094 FR5's 1 s rather than 500-1000 ms.

@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.

Changes are requested for two fixes in the harness's own tests and docs; otherwise the busy-main-thread instruments discriminate as described, with a compositor animation moving through the block and a rAF one failing on its stall.

  • In WebKit the rAF negative control fails at harness.interaction.browser.spec.ts:331: a frame from before the block arrives about 45 ms into it and reads as "1/1 moved (100%)", because the moved criterion divides by the delivered frames (busy.ts:464-474), so judging a window that delivered fewer than minJudgedFrames frames against its span (with a busy.test.ts case), or dropping that expectation, fixes it.
  • README.md:11-14 and the spec file's header (lines 6-7) say every positive check has a negative control, which the during, curve-sampling and timer-gate tests do not have, so please scope that sentence to the busy-main-thread proof.
  • Non-blocking: the compositor positive control can fail on a 59-69 ms screencast gap even though every delivered frame moved, which the compositor record from #8548 absorbs once it feeds presented.

[Automated review]

Comment on lines +464 to +474
const judgedFrames =
compositorFrameMs != null
? changes.length > 0
? Math.round(
(changes[changes.length - 1] - (onScreenFrom ?? changes[0])) /
compositorFrameMs,
) + (onScreenFrom == null ? 1 : 0)
: expectedFrames
: deliveredFrames === 0
? expectedFrames
: deliveredFrames;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Without a compositor record this divides by the frames delivered in the judged part, so a frozen animation with one late frame (a pre-block commit presented 43-45 ms into the block, as WebKit's slower cast delivers it) reads "1/1 moved (100%)" and only the stall check fails it, despite the comment above ("a frozen animation misses here too"). Judging a part that delivered fewer than minJudgedFrames frames against its span (expectedFrames) keeps this criterion independent; a busy.test.ts case with one stale frame at +45 ms would pin it.

expect(raf.longestStallMs).toBeGreaterThan(600);
expect(raf.verdict.pass).toBe(false);
expect(raf.verdict.failures.join('\n')).toMatch(/longest stall/);
expect(raf.verdict.failures.join('\n')).toMatch(/expected frames moved/);

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 expectation fails in WebKit when the cast is slow: the rAF control read "1 frames presented, 1/1 moved (100%) ... longest stall 635.4 ms (10 intervals) at +45 ms", so failures holds only the stall (cause at busy.ts:464-474).

Comment thread internal/interaction-harness/README.md Outdated
Comment on lines +12 to +14
[`src/harness.interaction.browser.spec.ts`](src/harness.interaction.browser.spec.ts):
every positive check has a negative control beside it (a rAF animation that
freezes under a blocked main thread).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only the busy-main-thread proof has a negative control; the two during tests, the curve-sampling tests and the timer-gate wait test assert only the positive outcome. Scoping this to "the busy-main-thread proof has a negative control beside it" keeps it true.

Comment on lines +6 to +7
* @output Proof that each harness instrument measures what it claims, with a
* negative control beside every positive one: scripted timestamps reach the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same as the README: tests in this file such as lines 344-424, 427-449 and 1613-1638 have no negative control beside them.

Comment on lines +55 to +56
/** One display frame, ms: the least progress `blockMainThread` waits for. */
const ONE_FRAME_MS = 16;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: with this constant between them, the JSDoc above attaches to ONE_FRAME_MS, so blockMainThread shows no documentation on hover; moving the constant above the JSDoc fixes it.

… against its span

In WebKit the rAF negative control could read "1/1 moved": one frame from
before the block arrived 45 ms into it, and the moved criterion divided by
the delivered frames. A part that delivered fewer than minJudgedFrames
frames is now judged against its span, as one that delivered none was
(busy.test.ts case; it fails on the previous rule). The README and the
self-test header now say only the busy-main-thread proof carries a negative
control beside its positive check.
@vjeux

vjeux commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

busyWindowProof counts frames in the median of the screencast's single intervals around the window. WebKit's container screencast can alternate about 11.3 and 17.1 ms, so that median lands on one or the other by parity. On 11.3 ms it promises more frames than 60 Hz presentation shows, and each 17 ms interval reads as a dropped frame. A spec that reads expectedFrames then fails an animation that moved in every frame: Lightbox's double-tap zoom in WebKit presented 19 of 19 frames moved (median 16.8 ms) against 23 expected.

This branch counts frames in the cast's median, never in less than one 60 Hz frame: vjeux:uikit/x1-busy-display-cadence, which fast-forwards this pull request's 8715133 (its change: vjeux/astryx@8715133...38fa3aa). A mean interval would also undo the parity, but it lengthens with every frame a stall drops, so it would expect fewer frames and measure shorter stalls exactly when frames are missing; the median keeps its resistance to stalls. pxPerFrame still uses the cast's own interval, and a cast at or slower than display rate is unchanged.

The README's busy-main-thread paragraph and busyWindowProof's JSDoc said an
undelivered frame is never a miss; when fewer than minJudgedFrames frames
were delivered in the judged part, its span counts every frame. One clause
in each says so.

@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 is ready to approve: a frozen animation with one stale frame now fails the moved criterion as well as the stall (pinned by the new busy.test.ts case, which fails on the previous rule), the negative-control sentence is scoped to the busy-main-thread proof, and the frame-evidence self-tests pass in Chromium and WebKit. Nothing else is required; two optional follow-ups:

  • On an alternating WebKit cast (about 11.3 and 17.1 ms) the stall limit follows the median's parity, so a 45.5 ms gap from two undelivered frames fails at an 11.3 ms median and passes at 17.1 ms; your display-cadence branch closes this.
  • One frame in six of a WebKit cast slower than 27.5 ms leaves the self-test's slow case under the four-frame floor, which then reports the animation as not moving although every frame moved.

[Automated review]

Comment on lines +388 to +391
const cadence =
intervals.length >= 4
? intervals[Math.floor(intervals.length / 2)]
: stats.medianFrameMs || 1000 / 60;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: when WebKit's cast alternates short and long intervals (about 11.3 and 17.1 ms), this median lands on either value by the parity of the frame count, and the stall check divides by it (line 421). In a synthetic cast of that shape with two consecutive frames undelivered, the 45.5 ms gap reads 4 intervals and fails at an 11.3 ms median, and 3 intervals and passes at 17.1 ms, against the README's "two dropped frames" tolerance. Flooring the cadence at one 60 Hz frame, as your display-cadence branch does, takes the parity out of the verdict.

Comment on lines +337 to +339
const slow = await run('compositor', 6);
expect(slow.cadenceMs, slow.summary).toBeGreaterThan(60);
expect(slow.verdict.failures, slow.summary).toEqual([]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: on a WebKit cast slower than 27.5 ms a frame, keeping one frame in six delivers a frame less often than every 165 ms, so expectedFrames falls under the four-frame floor and this case fails as "not moving during the block" although every frame moved (a synthetic 210 ms cast reads "660 ms judged = 3 frames < 4"). Choosing the stride from the measured cast interval would keep it near the ~100 ms the comment above describes.

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf: closed and reopened only to restart this PR's CI on the current workflow after tonight's queue-wide cancel; nothing in the PR changed.

This branch was successfully deployed

1 active deployment
Preview — 8715133f Deployed Oct 26, 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants