diff --git a/.changeset/flat-peaches-hang.md b/.changeset/flat-peaches-hang.md new file mode 100644 index 00000000000..d7be335dec8 --- /dev/null +++ b/.changeset/flat-peaches-hang.md @@ -0,0 +1,5 @@ +--- + +--- + +Push margin-top of TimelineBody +1px diff --git a/.changeset/mcp-lineheight-tokens.md b/.changeset/mcp-lineheight-tokens.md new file mode 100644 index 00000000000..206fa178de0 --- /dev/null +++ b/.changeset/mcp-lineheight-tokens.md @@ -0,0 +1,5 @@ +--- +'@primer/mcp': patch +--- + +Expose lineHeight tokens in design token search results, added color name to token conversion and `lint_css` tool for self-check loop diff --git a/.changeset/modern-buckets-chew.md b/.changeset/modern-buckets-chew.md new file mode 100644 index 00000000000..d549ae837cb --- /dev/null +++ b/.changeset/modern-buckets-chew.md @@ -0,0 +1,6 @@ +--- +"@primer/react": patch +--- + +- Fixes a bug where `ActionBar` menu items would be out of order if new items were mounted after the initial render +- Improves initial render performance for `ActionBar` diff --git a/.changeset/spinner-css-animation-sync.md b/.changeset/spinner-css-animation-sync.md new file mode 100644 index 00000000000..1a47ed4d810 --- /dev/null +++ b/.changeset/spinner-css-animation-sync.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +perf(Spinner): replace Web Animations API with CSS animation-delay sync diff --git a/.changeset/stupid-bees-marry.md b/.changeset/stupid-bees-marry.md new file mode 100644 index 00000000000..ab7c9a9fe65 --- /dev/null +++ b/.changeset/stupid-bees-marry.md @@ -0,0 +1,5 @@ +--- +"@primer/react": minor +--- + +chore: always render ActionMenu in viewport when inside Dialog under feature flag diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-colorblind-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-colorblind-linux.png index 8727b830433..c5924ec1948 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-colorblind-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-dimmed-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-dimmed-linux.png index 9a596f6cae0..597295a45ec 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-dimmed-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-high-contrast-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-high-contrast-linux.png index 85e4cce2805..73bb54d6f6f 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-high-contrast-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-linux.png index 8727b830433..c5924ec1948 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-tritanopia-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-tritanopia-linux.png index 8727b830433..c5924ec1948 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-tritanopia-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-light-colorblind-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-light-colorblind-linux.png index 2fe90ceecdc..2f4e9c789d2 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-light-colorblind-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-light-high-contrast-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-light-high-contrast-linux.png index d2bb6dd09ae..c3a1c529d21 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-light-high-contrast-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-light-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-light-linux.png index 2fe90ceecdc..2f4e9c789d2 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-light-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-light-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-light-tritanopia-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-light-tritanopia-linux.png index 2fe90ceecdc..2f4e9c789d2 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-light-tritanopia-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Clip-Sidebar-light-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-colorblind-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-colorblind-linux.png index 6f4262853ea..add4ee0ee17 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-colorblind-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-dimmed-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-dimmed-linux.png index fdbaacbdd72..bd552eec520 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-dimmed-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-high-contrast-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-high-contrast-linux.png index a44c49efa9a..01b55e10ae2 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-high-contrast-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-linux.png index 6f4262853ea..add4ee0ee17 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-tritanopia-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-tritanopia-linux.png index 6f4262853ea..add4ee0ee17 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-tritanopia-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-light-colorblind-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-light-colorblind-linux.png index 7f7104bc8f0..94ede8fffba 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-light-colorblind-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-light-high-contrast-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-light-high-contrast-linux.png index 5f0414b1302..be505acbad4 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-light-high-contrast-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-light-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-light-linux.png index 7f7104bc8f0..94ede8fffba 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-light-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-light-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-light-tritanopia-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-light-tritanopia-linux.png index 7f7104bc8f0..94ede8fffba 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-light-tritanopia-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Condensed-Items-light-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-colorblind-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-colorblind-linux.png index a862eb9507b..3b7e63450d4 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-colorblind-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-dimmed-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-dimmed-linux.png index 71d48dfde3f..31ce6675d5c 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-dimmed-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-high-contrast-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-high-contrast-linux.png index 1574e801cc3..73c86b85c70 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-high-contrast-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-linux.png index a862eb9507b..3b7e63450d4 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-tritanopia-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-tritanopia-linux.png index a862eb9507b..3b7e63450d4 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-tritanopia-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-light-colorblind-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-light-colorblind-linux.png index 78eb1aea348..d7957025f3b 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-light-colorblind-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-light-high-contrast-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-light-high-contrast-linux.png index 8c365d390ea..58a04c6e0ce 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-light-high-contrast-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-light-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-light-linux.png index 78eb1aea348..d7957025f3b 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-light-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-light-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-light-tritanopia-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-light-tritanopia-linux.png index 78eb1aea348..d7957025f3b 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-light-tritanopia-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Default-light-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-colorblind-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-colorblind-linux.png index c1d572d4ac3..f46bfb8c7a8 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-colorblind-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-dimmed-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-dimmed-linux.png index 35a3bca4328..2ed415a6a41 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-dimmed-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-high-contrast-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-high-contrast-linux.png index fd23b676b4e..302d5fbac79 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-high-contrast-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-linux.png index c1d572d4ac3..f46bfb8c7a8 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-tritanopia-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-tritanopia-linux.png index c1d572d4ac3..f46bfb8c7a8 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-tritanopia-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-light-colorblind-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-light-colorblind-linux.png index 7cd05e47be7..2f811af0553 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-light-colorblind-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-light-high-contrast-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-light-high-contrast-linux.png index 2a539d9cac8..6ec2901a6c9 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-light-high-contrast-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-light-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-light-linux.png index 7cd05e47be7..2f811af0553 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-light-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-light-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-light-tritanopia-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-light-tritanopia-linux.png index 7cd05e47be7..2f811af0553 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-light-tritanopia-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-Timeline-Break-light-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-colorblind-focus-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-colorblind-focus-linux.png index fcb376b9dab..a4ee9e4d07a 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-colorblind-focus-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-colorblind-focus-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-colorblind-hover-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-colorblind-hover-linux.png index cce73b9e3ae..0c6aa99890e 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-colorblind-hover-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-colorblind-hover-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-colorblind-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-colorblind-linux.png index cfd6b5aeb88..c6a7fe64500 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-colorblind-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-dimmed-focus-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-dimmed-focus-linux.png index 0960bebc36b..175495da3ac 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-dimmed-focus-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-dimmed-focus-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-dimmed-hover-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-dimmed-hover-linux.png index 6aa96d809d7..3c0d99fd3c2 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-dimmed-hover-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-dimmed-hover-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-dimmed-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-dimmed-linux.png index cce0bb1a2fd..d979387bda9 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-dimmed-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-focus-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-focus-linux.png index fcb376b9dab..a4ee9e4d07a 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-focus-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-focus-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-high-contrast-focus-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-high-contrast-focus-linux.png index 231ee7f104a..65350271e2d 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-high-contrast-focus-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-high-contrast-focus-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-high-contrast-hover-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-high-contrast-hover-linux.png index 7f8f59afc44..6f5ddb48435 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-high-contrast-hover-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-high-contrast-hover-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-high-contrast-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-high-contrast-linux.png index 8722c3cfc90..2ad1d02362d 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-high-contrast-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-hover-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-hover-linux.png index cce73b9e3ae..0c6aa99890e 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-hover-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-hover-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-linux.png index cfd6b5aeb88..c6a7fe64500 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-tritanopia-focus-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-tritanopia-focus-linux.png index fcb376b9dab..a4ee9e4d07a 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-tritanopia-focus-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-tritanopia-focus-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-tritanopia-hover-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-tritanopia-hover-linux.png index cce73b9e3ae..0c6aa99890e 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-tritanopia-hover-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-tritanopia-hover-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-tritanopia-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-tritanopia-linux.png index cfd6b5aeb88..c6a7fe64500 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-tritanopia-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-colorblind-focus-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-colorblind-focus-linux.png index 0b8fba47243..038804b6660 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-colorblind-focus-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-colorblind-focus-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-colorblind-hover-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-colorblind-hover-linux.png index 208f79736a8..1337347c2a6 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-colorblind-hover-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-colorblind-hover-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-colorblind-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-colorblind-linux.png index 4eac70cf984..3a2cda93501 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-colorblind-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-focus-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-focus-linux.png index 0b8fba47243..038804b6660 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-focus-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-focus-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-high-contrast-focus-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-high-contrast-focus-linux.png index 7ed7af4d691..ff85b1c99ae 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-high-contrast-focus-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-high-contrast-focus-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-high-contrast-hover-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-high-contrast-hover-linux.png index 5204bb1a12a..77cb803b04a 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-high-contrast-hover-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-high-contrast-hover-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-high-contrast-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-high-contrast-linux.png index 50bf4a0878a..4d33c49ef70 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-high-contrast-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-hover-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-hover-linux.png index 208f79736a8..1337347c2a6 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-hover-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-hover-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-linux.png index 4eac70cf984..3a2cda93501 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-tritanopia-focus-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-tritanopia-focus-linux.png index 0b8fba47243..038804b6660 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-tritanopia-focus-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-tritanopia-focus-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-tritanopia-hover-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-tritanopia-hover-linux.png index 208f79736a8..1337347c2a6 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-tritanopia-hover-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-tritanopia-hover-linux.png differ diff --git a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-tritanopia-linux.png b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-tritanopia-linux.png index 4eac70cf984..3a2cda93501 100644 Binary files a/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-tritanopia-linux.png and b/.playwright/snapshots/components/Timeline.test.ts-snapshots/Timeline-With-Inline-Links-light-tritanopia-linux.png differ diff --git a/packages/mcp/src/primitives.ts b/packages/mcp/src/primitives.ts index dd2e9572c9a..790dbf91261 100644 --- a/packages/mcp/src/primitives.ts +++ b/packages/mcp/src/primitives.ts @@ -1,5 +1,6 @@ import {readFileSync} from 'node:fs' import {createRequire} from 'node:module' +import {spawn} from 'child_process' import baseMotion from '@primer/primitives/dist/docs/base/motion/motion.json' with {type: 'json'} import baseSize from '@primer/primitives/dist/docs/base/size/size.json' with {type: 'json'} import baseTypography from '@primer/primitives/dist/docs/base/typography/typography.json' with {type: 'json'} @@ -421,51 +422,41 @@ function getDesignTokenSpecsText(groups: TokenGroups): string { # Design Token Specifications ## 1. Core Rule & Enforcement -- **Expert Mode**: You are a CSS expert. NEVER use raw values (hex, px, etc.). Only use tokens. -- **Shorthand**: MUST use shorthand tokens (e.g., \`font: var(...)\`). NEVER split font-size/weight. -- **States**: MUST define 5 states: Rest, Hover, Focus-visible, Active, Disabled. -- **Safety**: If unsure of a token name, suffix with \`/* check-token */\`. -- **Focus States**: When implementing :focus-visible, you MUST use both: - - outline: var(--focus-outline) - - outline-offset: var(--outline-focus-offset) +* **Expert Mode**: CSS expert. NEVER use raw values (hex, px, etc.). Tokens only. +* **Shorthand**: MUST use \`font: var(...)\`. NEVER split size/weight. +* **Shorthand Fallback**: If no shorthand exists (e.g. Monospace), use individual tokens for font-size, family, and line-height. NEVER raw 1.5. +* **States**: Define 5: Rest, Hover, Focus-visible, Active, Disabled. +* **Focus**: \`:focus-visible\` MUST use \`outline: var(--focus-outline)\` AND \`outline-offset: var(--outline-focus-offset)\`. +* **Validation**: CALL \`lint_css\` after any CSS change. Task is incomplete without a success message. +* **Self-Correction**: Adopt autofixes immediately. Report unfixable errors to the user. ## 2. Typography Constraints (STRICT) -- **Body Only**: Only the \`body\` group supports size suffixes (e.g., \`body-small\`, \`body-medium\`). -- **Static Shorthands**: The following groups do NOT support size suffixes. Use the base shorthand only: - - **caption**: \`var(--text-caption-shorthand)\` (NEVER add -medium or -small) - - **display**: \`var(--text-display-shorthand)\` - - **codeBlock**: \`var(--text-codeBlock-shorthand)\` - - **codeInline**: \`var(--text-codeInline-shorthand)\` - -## 3. Logic Matrix: Color Pairings (CRITICAL) -| Background Token | Foreground Token | Requirement | -| :--- | :--- | :--- | -| --bgColor-*-emphasis | --fgColor-onEmphasis | MUST pair | -| --bgColor-*-muted | --fgColor-{semantic} | MUST match semantic | -| --bgColor-default | --fgColor-default | Standard pairing | -| --bgColor-muted | --fgColor-default | NEVER use fgColor-muted | - -## 4. Semantic Intent Key -Use these for \`find_tokens\` or to map natural language to groups: -- **Search Aliases**: Map "background" -> \`bgColor\`, "foreground" -> \`fgColor\`, "typography/font" -> \`text\`, "padding/margin" -> \`stack\`, "radius" -> \`borderRadius\`, "shadow/elevation" -> \`overlay\`. -- **danger**: Errors/Destructive | **success**: Positive/Done -- **attention**: Warning/Pending | **accent**: Interactive/Selected - -## 5. Available Token Groups -Use these names in \`find_tokens(group: "...")\`: -- **Semantic**: ${groups.semantic.map(g => g.name).join(', ')} -- **Components**: ${groups.component.map(g => g.name).join(', ')} - -## 6. Optimization Strategy (MANDATORY) -- **STOP**: Do not call \`find_tokens\` repeatedly for individual properties. -- **GO**: Use \`get_token_group_bundle\` to fetch relevant groups at once. - - *Example for Button*: \`get_token_group_bundle(groups: ["control", "button"])\` - - *Note*: \`control\` is for form inputs; \`button\` is for triggers. - -## 7. Token Bundle Recipes (Recommended) -- **Forms/Inputs**: \`["control", "focus", "outline", "text", "borderRadius"]\` -- **Modals/Dialogs**: \`["overlay", "shadow", "outline", "borderRadius", "bgColor"]\` -- **Data Tables**: \`["stack", "borderColor", "text", "bgColor"]\` +- **Body Only**: Only \`body\` group supports size suffixes (e.g., \`body-small\`). +- **Static Shorthands**: NEVER add suffixes to \`caption\`, \`display\`, \`codeBlock\`, or \`codeInline\`. + +## 3. Logic Matrix: Color & Semantic Mapping +| Input Color/Intent | Semantic Role | Background Suffix | Foreground Requirement | +| :--- | :--- | :--- | :--- | +| Blue / Interactive | \`accent\` | \`-emphasis\` (Solid) | \`fgColor-onEmphasis\` | +| Green / Positive | \`success\` | \`-muted\` (Light) | \`fgColor-{semantic}\` | +| Red / Danger | \`danger\` | \`-emphasis\` | \`fgColor-onEmphasis\` | +| Yellow / Warning | \`attention\` | \`-muted\` | \`fgColor-attention\` | +| Orange / Critical | \`severe\` | \`-emphasis\` | \`fgColor-onEmphasis\` | +| Purple / Done | \`done\` | Any | Match intent | +| Pink / Sponsors | \`sponsors\` | Any | Match intent | +| Grey / Neutral | \`default\` | \`bgColor-muted\` | \`fgColor-default\` (Not muted) | + +## 4. Optimization & Recipes (MANDATORY) +**Strategy**: STOP property-by-property searching. Use \`get_token_group_bundle\` for these common patterns: +- **Forms**: \`["control", "focus", "outline", "text", "borderRadius", "stack"]\` +- **Modals/Cards**: \`["overlay", "shadow", "outline", "borderRadius", "bgColor", "stack"]\` +- **Tables/Lists**: \`["stack", "borderColor", "text", "bgColor", "control"]\` +- **Nav/Sidebars**: \`["control", "text", "accent", "stack", "focus"]\` +- **Status/Badges**: \`["text", "success", "danger", "attention", "severe", "stack"]\` + +## 5. Available Groups +- **Semantic**: ${groups.semantic.map(g => `${g.name}\``).join(', ')} +- **Components**: ${groups.component.map(g => `\`${g.name}\``).join(', ')} `.trim() } @@ -625,6 +616,9 @@ const GROUP_ALIASES: Record = { typography: 'text', font: 'text', text: 'text', + 'line-height': 'text', + lineheight: 'text', + leading: 'text', // Layout & Spacing stack: 'stack', @@ -645,6 +639,27 @@ const GROUP_ALIASES: Record = { line: 'borderColor', stroke: 'borderColor', separator: 'borderColor', + + // Color-to-Semantic Intent Mapping + red: 'danger', + green: 'success', + yellow: 'attention', + orange: 'severe', + blue: 'accent', + purple: 'done', + pink: 'sponsors', + grey: 'neutral', + gray: 'neutral', + + // Descriptive Aliases + light: 'muted', + subtle: 'muted', + dark: 'emphasis', + strong: 'emphasis', + intense: 'emphasis', + bold: 'emphasis', + vivid: 'emphasis', + highlight: 'emphasis', } // Match a token against a resolved group by checking both the token name prefix and the group label @@ -701,12 +716,51 @@ function getValidGroupsList(validTokens: TokenWithGuidelines[]): string { const groupHints: Record = { control: '`control` tokens are for form inputs/checkboxes. For buttons, use the `button` group.', button: '`button` tokens are for standard triggers. For form-fields, see the `control` group.', - text: 'STRICT: The following typography groups do NOT support size suffixes (-small, -medium, -large): `caption`, `display`, `codeBlock`, and `codeInline`. Use the base shorthand name only (e.g., --text-codeBlock-shorthand).', + text: 'STRICT: The following typography groups do NOT support size suffixes (-small, -medium, -large): `caption`, `display`, `codeBlock`, and `codeInline`. STRICT: Use shorthand tokens where possible. If splitting, you MUST fetch line-height tokens (e.g., --text-body-lineHeight-small) instead of using raw numbers.', fgColor: 'Use `fgColor` for text. For borders, use `borderColor`.', borderWidth: '`borderWidth` only has sizing values (thin, thick, thicker). For border *colors*, use the `borderColor` or `border` group.', } +// ----------------------------------------------------------------------------- +// Stylelint runner +// ----------------------------------------------------------------------------- +function runStylelint(css: string): Promise<{stdout: string; stderr: string}> { + return new Promise((resolve, reject) => { + const proc = spawn('npx', ['stylelint', '--stdin', '--fix'], { + stdio: ['pipe', 'pipe', 'pipe'], + shell: true, + }) + + let stdout = '' + let stderr = '' + + proc.stdout.on('data', (data: Buffer) => { + stdout += data.toString() + }) + + proc.stderr.on('data', (data: Buffer) => { + stderr += data.toString() + }) + + proc.on('close', code => { + if (code === 0) { + resolve({stdout, stderr}) + } else { + const error = new Error(`Stylelint exited with code ${code}`) as Error & {stdout: string; stderr: string} + error.stdout = stdout + error.stderr = stderr + reject(error) + } + }) + + proc.on('error', reject) + + proc.stdin.write(css) + proc.stdin.end() + }) +} + export { parseDesignTokensSpec, findTokens, @@ -724,5 +778,6 @@ export { GROUP_ALIASES, GROUP_LABELS, tokenMatchesGroup, + runStylelint, type TokenWithGuidelines, } diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index c6aa0dd2bc1..7cda805d255 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -18,6 +18,7 @@ import { type TokenWithGuidelines, getValidGroupsList, groupHints, + runStylelint, } from './primitives' import packageJson from '../package.json' with {type: 'json'} @@ -479,7 +480,7 @@ server.registerTool( 'find_tokens', { description: - "Search for specific tokens. Tip: If you only provide a 'group' and leave 'query' empty, it returns all tokens in that category. Avoid property-by-property searching.", + 'Search for specific tokens. Tip: If you only provide a \'group\' and leave \'query\' empty, it returns all tokens in that category. Avoid property-by-property searching. COLOR RESOLUTION: If a user asks for "pink" or "blue", do not search for the color name. Use the semantic intent: blue->accent, red->danger, green->success. Always check both "emphasis" and "muted" variants for background colors. After identifying tokens and writing CSS, you MUST validate the result using lint_css.', inputSchema: { query: z .string() @@ -680,6 +681,42 @@ server.registerTool( }, ) +server.registerTool( + 'lint_css', + { + description: + 'REQUIRED FINAL STEP. Use this to validate your CSS. You cannot complete a task involving CSS without a successful run of this tool.', + inputSchema: {css: z.string()}, + }, + async ({css}) => { + try { + // --fix flag tells Stylelint to repair what it can + const {stdout} = await runStylelint(css) + + return { + content: [ + { + type: 'text', + text: stdout || '✅ Stylelint passed (or was successfully autofixed).', + }, + ], + } + } catch (error: unknown) { + // If Stylelint still has errors it CANNOT fix, it will land here + const errorOutput = + error instanceof Error && 'stdout' in error ? (error as Error & {stdout: string}).stdout : String(error) + return { + content: [ + { + type: 'text', + text: `❌ Errors without autofix remaining:\n${errorOutput}`, + }, + ], + } + } + }, +) + // ----------------------------------------------------------------------------- // Foundations // ----------------------------------------------------------------------------- diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx index 1c2fb0da71a..83712ce21fd 100644 --- a/packages/react/src/ActionBar/ActionBar.tsx +++ b/packages/react/src/ActionBar/ActionBar.tsx @@ -1,5 +1,5 @@ import type {RefObject, MouseEventHandler} from 'react' -import React, {useState, useCallback, useRef, forwardRef, useId} from 'react' +import React, {useState, useCallback, useRef, forwardRef, useMemo} from 'react' import {KebabHorizontalIcon} from '@primer/octicons-react' import {ActionList, type ActionListItemProps} from '../ActionList' import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect' @@ -13,6 +13,7 @@ import {useFocusZone, FocusKeys} from '../hooks/useFocusZone' import styles from './ActionBar.module.css' import {clsx} from 'clsx' import {useRefObjectAsForwardedRef} from '../hooks' +import {createDescendantRegistry} from '../utils/descendant-registry' const ACTIONBAR_ITEM_GAP = 8 @@ -44,14 +45,10 @@ type ChildRegistry = ReadonlyMap const ActionBarContext = React.createContext<{ size: Size - registerChild: (id: string, props: ChildProps) => void - unregisterChild: (id: string, groupId?: string) => void isVisibleChild: (id: string) => boolean groupId?: string }>({ size: 'medium', - registerChild: () => {}, - unregisterChild: () => {}, isVisibleChild: () => true, groupId: undefined, }) @@ -162,6 +159,8 @@ export type ActionBarMenuProps = { const MORE_BTN_WIDTH = 32 +const ActionBarItemsRegistry = createDescendantRegistry() + const calculatePossibleItems = ( registryEntries: Array<[string, ChildProps]>, navWidth: number, @@ -235,11 +234,11 @@ const renderMenuItem = (item: ActionBarMenuItemProps, index: number): React.Reac const getMenuItems = ( navWidth: number, moreMenuWidth: number, - childRegistry: ChildRegistry, + childRegistry: ChildRegistry | undefined, hasActiveMenu: boolean, gap: number, ): Set | void => { - const registryEntries = Array.from(childRegistry).filter( + const registryEntries = Array.from(childRegistry?.entries() ?? []).filter( (entry): entry is [string, ChildProps] => entry[1] !== null && (entry[1].type !== 'action' || entry[1].groupId === undefined), ) @@ -301,13 +300,7 @@ export const ActionBar: React.FC> = prop const listRef = useRef(null) const [computedGap, setComputedGap] = useState(ACTIONBAR_ITEM_GAP) - const [childRegistry, setChildRegistry] = useState(() => new Map()) - - const registerChild = useCallback( - (id: string, childProps: ChildProps) => setChildRegistry(prev => new Map(prev).set(id, childProps)), - [], - ) - const unregisterChild = useCallback((id: string) => setChildRegistry(prev => new Map(prev).set(id, null)), []) + const [childRegistry, setChildRegistry] = ActionBarItemsRegistry.useRegistryState() const [menuItemIds, setMenuItemIds] = useState>(() => new Set()) @@ -348,8 +341,8 @@ export const ActionBar: React.FC> = prop const groupedItems = React.useMemo(() => { const groupedItemsMap = new Map>() - for (const [key, childProps] of childRegistry) { - if (childProps?.type === 'action' && childProps.groupId) { + for (const [key, childProps] of childRegistry ?? []) { + if (childProps.type === 'action' && childProps.groupId) { const existingGroup = groupedItemsMap.get(childProps.groupId) || [] existingGroup.push([key, childProps]) groupedItemsMap.set(childProps.groupId, existingGroup) @@ -359,7 +352,7 @@ export const ActionBar: React.FC> = prop }, [childRegistry]) return ( - +
> = prop aria-labelledby={ariaLabelledBy} data-gap={gap} > - {children} + {children} {menuItemIds.size > 0 && ( @@ -378,7 +371,7 @@ export const ActionBar: React.FC> = prop {Array.from(menuItemIds).map(id => { - const menuItem = childRegistry.get(id) + const menuItem = childRegistry?.get(id) if (!menuItem) return null if (menuItem.type === 'divider') { @@ -466,37 +459,40 @@ export const ActionBar: React.FC> = prop ) } +function useWidth(ref: React.RefObject) { + const [width, setWidth] = useState(-1) + + useIsomorphicLayoutEffect(() => setWidth(ref.current?.getBoundingClientRect().width ?? -1), [ref]) + + return width +} + export const ActionBarIconButton = forwardRef( ({disabled, onClick, ...props}: ActionBarIconButtonProps, forwardedRef) => { const ref = useRef(null) useRefObjectAsForwardedRef(forwardedRef, ref) - const id = useId() - const {size, registerChild, unregisterChild, isVisibleChild} = React.useContext(ActionBarContext) + const {size, isVisibleChild} = React.useContext(ActionBarContext) const {groupId} = React.useContext(ActionBarGroupContext) - // Storing the width in a ref ensures we don't forget about it when not visible - const widthRef = useRef() + const width = useWidth(ref) - useIsomorphicLayoutEffect(() => { - const width = ref.current?.getBoundingClientRect().width - if (width) widthRef.current = width - if (!widthRef.current) return + const {['aria-label']: ariaLabel, icon} = props - registerChild(id, { + const registryProps = useMemo( + (): ChildProps => ({ type: 'action', - label: props['aria-label'] ?? '', - icon: props.icon, + label: ariaLabel ?? '', + icon, disabled: !!disabled, onClick: onClick as MouseEventHandler, - width: widthRef.current, + width, groupId: groupId ?? undefined, - }) + }), + [ariaLabel, icon, disabled, onClick, groupId, width], + ) - return () => { - unregisterChild(id) - } - }, [registerChild, unregisterChild, props['aria-label'], props.icon, disabled, onClick]) + const id = ActionBarItemsRegistry.useRegisterDescendant(registryProps) const clickHandler = useCallback( (event: React.MouseEvent) => { @@ -528,24 +524,11 @@ const ActionBarGroupContext = React.createContext<{ export const ActionBarGroup = forwardRef(({children}: React.PropsWithChildren, forwardedRef) => { const backupRef = useRef(null) const ref = (forwardedRef ?? backupRef) as RefObject - const id = useId() - const {registerChild, unregisterChild} = React.useContext(ActionBarContext) + const width = useWidth(ref) - // Like IconButton, we store the width in a ref to ensure that we don't forget about it when not visible - // If a child has a groupId, it won't be visible if the group isn't visible, so we don't need to check isVisibleChild here - const widthRef = useRef() + const registryProps = useMemo((): ChildProps => ({type: 'group', width}), [width]) - useIsomorphicLayoutEffect(() => { - const width = ref.current?.getBoundingClientRect().width - if (width) widthRef.current = width - if (!widthRef.current) return - - registerChild(id, {type: 'group', width: widthRef.current}) - - return () => { - unregisterChild(id) - } - }, [registerChild, unregisterChild]) + const id = ActionBarItemsRegistry.useRegisterDescendant(registryProps) return ( @@ -563,33 +546,25 @@ export const ActionBarMenu = forwardRef( ) => { const backupRef = useRef(null) const ref = (forwardedRef ?? backupRef) as RefObject - const id = useId() - const {registerChild, unregisterChild, isVisibleChild} = React.useContext(ActionBarContext) + const {isVisibleChild} = React.useContext(ActionBarContext) const [menuOpen, setMenuOpen] = useState(false) - // Like IconButton, we store the width in a ref to ensure that we don't forget about it when not visible - const widthRef = useRef() - - useIsomorphicLayoutEffect(() => { - const width = ref.current?.getBoundingClientRect().width - if (width) widthRef.current = width - - if (!widthRef.current) return + const width = useWidth(ref) - registerChild(id, { + const registryProps = useMemo( + (): ChildProps => ({ type: 'menu', - width: widthRef.current, + width, label: ariaLabel, icon: overflowIcon ? overflowIcon : icon, returnFocusRef, items, - }) + }), + [ariaLabel, overflowIcon, icon, items, returnFocusRef, width], + ) - return () => { - unregisterChild(id) - } - }, [registerChild, unregisterChild, ariaLabel, overflowIcon, icon, items]) + const id = ActionBarItemsRegistry.useRegisterDescendant(registryProps) if (!isVisibleChild(id)) return null @@ -608,21 +583,13 @@ export const ActionBarMenu = forwardRef( export const VerticalDivider = () => { const ref = useRef(null) - const id = useId() - const {registerChild, unregisterChild, isVisibleChild} = React.useContext(ActionBarContext) + const {isVisibleChild} = React.useContext(ActionBarContext) - // Storing the width in a ref ensures we don't forget about it when not visible - const widthRef = useRef() - - useIsomorphicLayoutEffect(() => { - const width = ref.current?.getBoundingClientRect().width - if (width) widthRef.current = width - if (!widthRef.current) return + const width = useWidth(ref) - registerChild(id, {type: 'divider', width: widthRef.current}) + const registryProps = useMemo((): ChildProps => ({type: 'divider', width}), [width]) - return () => unregisterChild(id) - }, [registerChild, unregisterChild]) + const id = ActionBarItemsRegistry.useRegisterDescendant(registryProps) if (!isVisibleChild(id)) return null return + + {/* Dialog containing ActionMenu */} + {isDialogOpen && ( + + + This dialog contains an ActionMenu. The main page content behind is long enough to be scrollable. + + + + Document Settings + + + + Configure the document properties and sharing settings. These options allow you to control how the + document is displayed and who has access to it. + + + + Actions + + + alert('Save clicked')}> + Save + ⌘S + + alert('Save as clicked')}> + Save as... + ⌘⇧S + + alert('Export clicked')}> + Export + ⌘E + + alert('Print clicked')}> + Print + ⌘P + + + alert('Copy clicked')}> + Copy + ⌘C + + alert('Paste clicked')}> + Paste + ⌘V + + alert('Duplicate clicked')}> + Duplicate + ⌘D + + + alert('Share clicked')}> + Share + ⌘⇧U + + alert('Share via email clicked')}>Share via email + alert('Share via link clicked')}>Share via link + + + + + + You can interact with the ActionMenu above while the main page content remains scrollable in the + background. + + + )} +
+ ) } diff --git a/packages/react/src/ActionMenu/ActionMenu.test.tsx b/packages/react/src/ActionMenu/ActionMenu.test.tsx index 0be609c68e8..f7e74c3db77 100644 --- a/packages/react/src/ActionMenu/ActionMenu.test.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.test.tsx @@ -1,17 +1,41 @@ -import {describe, expect, it, vi} from 'vitest' +import {describe, expect, it, vi, beforeEach} from 'vitest' import {render as HTMLRender, waitFor, act, within} from '@testing-library/react' import userEvent from '@testing-library/user-event' import type React from 'react' import BaseStyles from '../BaseStyles' -import {ActionMenu, ActionList, Button, IconButton} from '..' +import {ActionMenu, ActionList, Button, IconButton, Dialog} from '..' import Tooltip from '../Tooltip' import {Tooltip as TooltipV2} from '../TooltipV2/Tooltip' import {SingleSelect} from '../ActionMenu/ActionMenu.features.stories' import {MixedSelection} from '../ActionMenu/ActionMenu.examples.stories' import {SearchIcon, KebabHorizontalIcon} from '@primer/octicons-react' +import {getAnchoredPosition} from '@primer/behaviors' +import type {AnchorPosition} from '@primer/behaviors' import type {JSX} from 'react' import {implementsClassName} from '../utils/testing' +import {FeatureFlags} from '../FeatureFlags' + +// Mock getAnchoredPosition for feature flag tests +vi.mock('@primer/behaviors', async () => { + const actual = await vi.importActual('@primer/behaviors') + return { + ...actual, + getAnchoredPosition: vi.fn( + ( + _floatingElement: Element, + _anchorElement: Element | DOMRect, + _settings?: Partial<{displayInViewport?: boolean}>, + ) => + ({ + top: 100, + left: 100, + anchorSide: 'outside-bottom', + anchorAlign: 'start', + }) as AnchorPosition, + ), + } +}) function Example(): JSX.Element { return ( @@ -812,4 +836,223 @@ describe('ActionMenu', () => { expect(mockOnKeyDown).toHaveBeenCalledTimes(1) }) }) + + describe('feature flag: primer_react_action_menu_display_in_viewport_inside_dialog', () => { + const mockGetAnchoredPosition = vi.mocked(getAnchoredPosition) + + beforeEach(() => { + // Reset mock before each test + mockGetAnchoredPosition.mockClear() + }) + + it('should enable displayInViewport when flag is enabled and ActionMenu is inside a dialog', async () => { + // When the ActionMenu is wrapped in a Dialog, it's inside a dialog context. + // With the flag enabled, displayInViewport should be automatically enabled. + const component = HTMLRender( + + {}}> + + Toggle Menu + + + New file + + + + + , + ) + + const user = userEvent.setup() + const button = component.getByRole('button', {name: 'Toggle Menu'}) + await user.click(button) + + await waitFor(() => { + expect(component.queryByRole('menu')).toBeInTheDocument() + }) + + // Verify getAnchoredPosition was called with displayInViewport: true + await waitFor(() => { + expect(mockGetAnchoredPosition).toHaveBeenCalled() + }) + + const calls = mockGetAnchoredPosition.mock.calls + const lastCall = calls[calls.length - 1] + expect(lastCall[2]?.displayInViewport).toBe(true) + }) + + it('should not enable displayInViewport when flag is enabled but ActionMenu is NOT inside a dialog', async () => { + // Without being wrapped in a Dialog, the ActionMenu is not in a dialog context. + // Even with the flag enabled, displayInViewport should remain at its default (false/undefined). + const component = HTMLRender( + + + Toggle Menu + + + New file + + + + , + ) + + const user = userEvent.setup() + const button = component.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(component.queryByRole('menu')).toBeInTheDocument() + }) + + // Verify getAnchoredPosition was called without displayInViewport enabled + await waitFor(() => { + expect(mockGetAnchoredPosition).toHaveBeenCalled() + }) + + const calls = mockGetAnchoredPosition.mock.calls + const lastCall = calls[calls.length - 1] + expect(lastCall[2]?.displayInViewport).not.toBe(true) + }) + + it('should not enable displayInViewport when flag is disabled, even inside a dialog', async () => { + // Even when inside a Dialog, with the flag disabled, displayInViewport + // should remain at its default (false/undefined). + const component = HTMLRender( + + {}}> + + Toggle Menu + + + New file + + + + + , + ) + + const user = userEvent.setup() + const button = component.getByRole('button', {name: 'Toggle Menu'}) + await user.click(button) + + await waitFor(() => { + expect(component.queryByRole('menu')).toBeInTheDocument() + }) + + // Verify getAnchoredPosition was called without displayInViewport enabled + await waitFor(() => { + expect(mockGetAnchoredPosition).toHaveBeenCalled() + }) + + const calls = mockGetAnchoredPosition.mock.calls + const lastCall = calls[calls.length - 1] + expect(lastCall[2]?.displayInViewport).not.toBe(true) + }) + + it('should not enable displayInViewport when flag is disabled and outside dialog', async () => { + // Default scenario: flag disabled and not in a dialog context. + // displayInViewport should remain at its default (false/undefined). + const component = HTMLRender( + + + Toggle Menu + + + New file + + + + , + ) + + const user = userEvent.setup() + const button = component.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(component.queryByRole('menu')).toBeInTheDocument() + }) + + // Verify getAnchoredPosition was called without displayInViewport enabled + await waitFor(() => { + expect(mockGetAnchoredPosition).toHaveBeenCalled() + }) + + const calls = mockGetAnchoredPosition.mock.calls + const lastCall = calls[calls.length - 1] + expect(lastCall[2]?.displayInViewport).not.toBe(true) + }) + + it('should respect explicit displayInViewport prop over feature flag logic', async () => { + // Test that an explicit displayInViewport=false prop overrides the automatic + // detection, even when the flag is enabled and the ActionMenu is inside a dialog. + const component = HTMLRender( + + {}}> + + Toggle Menu + + + New file + + + + + , + ) + + const user = userEvent.setup() + const button = component.getByRole('button', {name: 'Toggle Menu'}) + await user.click(button) + + await waitFor(() => { + expect(component.queryByRole('menu')).toBeInTheDocument() + }) + + // Verify getAnchoredPosition was called with displayInViewport: false (explicit override) + await waitFor(() => { + expect(mockGetAnchoredPosition).toHaveBeenCalled() + }) + + const calls = mockGetAnchoredPosition.mock.calls + const lastCall = calls[calls.length - 1] + expect(lastCall[2]?.displayInViewport).toBe(false) + }) + + it('should respect explicit displayInViewport=true prop even when flag is disabled', async () => { + // Test that an explicit displayInViewport=true prop works regardless of + // the flag state or dialog context. + const component = HTMLRender( + + + Toggle Menu + + + New file + + + + , + ) + + const user = userEvent.setup() + const button = component.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(component.queryByRole('menu')).toBeInTheDocument() + }) + + // Verify getAnchoredPosition was called with displayInViewport: true (explicit override) + await waitFor(() => { + expect(mockGetAnchoredPosition).toHaveBeenCalled() + }) + + const calls = mockGetAnchoredPosition.mock.calls + const lastCall = calls[calls.length - 1] + expect(lastCall[2]?.displayInViewport).toBe(true) + }) + }) }) diff --git a/packages/react/src/ActionMenu/ActionMenu.tsx b/packages/react/src/ActionMenu/ActionMenu.tsx index 3e1233ff55b..e501b117377 100644 --- a/packages/react/src/ActionMenu/ActionMenu.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.tsx @@ -17,6 +17,8 @@ import styles from './ActionMenu.module.css' import {useResponsiveValue, type ResponsiveValue} from '../hooks/useResponsiveValue' import {isSlot} from '../utils/is-slot' import type {FCWithSlotMarker, WithSlotMarker} from '../utils/types/Slots' +import {useFeatureFlag} from '../FeatureFlags' +import {DialogContext} from '../Dialog/Dialog' export type MenuCloseHandler = ( gesture: 'anchor-click' | 'click-outside' | 'escape' | 'tab' | 'item-select' | 'arrow-left' | 'close', @@ -318,6 +320,12 @@ const Overlay: FCWithSlotMarker> = ({ } }, [anchorRef]) + const featureFlagDisplayInViewportInsideDialog = useFeatureFlag( + 'primer_react_action_menu_display_in_viewport_inside_dialog', + ) + + const isInsideDialog = useContext(DialogContext) !== undefined + return ( > = ({ focusZoneSettings={isNarrowFullscreen ? {disabled: true} : {focusOutBehavior: 'wrap'}} onPositionChange={onPositionChange} variant={variant} - displayInViewport={displayInViewport} + displayInViewport={ + displayInViewport !== undefined ? displayInViewport : featureFlagDisplayInViewportInsideDialog && isInsideDialog + } >
= [] +// useful to determine whether we're inside a Dialog from a nested component +export const DialogContext = React.createContext(undefined) + const _Dialog = React.forwardRef>((props, forwardedRef) => { const { title = 'Dialog', @@ -331,7 +334,7 @@ const _Dialog = React.forwardRef +
- +
) }) _Dialog.displayName = 'Dialog' diff --git a/packages/react/src/Dialog/index.tsx b/packages/react/src/Dialog/index.tsx new file mode 100644 index 00000000000..c0faabe53fa --- /dev/null +++ b/packages/react/src/Dialog/index.tsx @@ -0,0 +1,3 @@ +export {Dialog} from './Dialog' + +export type {DialogButtonProps, DialogHeaderProps, DialogHeight, DialogProps, DialogWidth} from './Dialog' diff --git a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts index b7952bde39e..796448887e8 100644 --- a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts +++ b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts @@ -7,4 +7,5 @@ export const DefaultFeatureFlags = FeatureFlagScope.create({ primer_react_select_panel_order_selected_at_top: false, primer_react_select_panel_remove_active_descendant: false, primer_react_spinner_synchronize_animations: false, + primer_react_action_menu_display_in_viewport_inside_dialog: false, }) diff --git a/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx b/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx index 363ed43cfd8..fa71edeb3ad 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControl.features.stories.tsx @@ -26,15 +26,11 @@ export const WithIcons = () => ( export const WithCounterLabels = () => ( - + Feature - - Bug - - - Good first issue - + Bug + Good first issue ) diff --git a/packages/react/src/Spinner/Spinner.tsx b/packages/react/src/Spinner/Spinner.tsx index a212b580aa1..7b194238c79 100644 --- a/packages/react/src/Spinner/Spinner.tsx +++ b/packages/react/src/Spinner/Spinner.tsx @@ -1,6 +1,6 @@ import {clsx} from 'clsx' import type React from 'react' -import {useCallback, useEffect, useRef, useState, useSyncExternalStore} from 'react' +import {useEffect, useState} from 'react' import {VisuallyHidden} from '../VisuallyHidden' import type {HTMLDataAttributes} from '../internal/internal-types' import {useId} from '../hooks' @@ -8,6 +8,8 @@ import classes from './Spinner.module.css' import {useMedia} from '../hooks/useMedia' import {useFeatureFlag} from '../FeatureFlags' +const ANIMATION_DURATION_MS = 1000 + const sizeMap = { small: '16px', medium: '32px', @@ -37,18 +39,21 @@ function Spinner({ ...props }: SpinnerProps) { const syncAnimationsEnabled = useFeatureFlag('primer_react_spinner_synchronize_animations') - const animationRef = useSpinnerAnimation() + const noMotionPreference = useMedia('(prefers-reduced-motion: no-preference)', false) const size = sizeMap[sizeKey] const hasHiddenLabel = srText !== null && ariaLabel === undefined const labelId = useId() - const [isVisible, setIsVisible] = useState(!delay) + const [{isVisible, syncDelay}, setVisibleState] = useState(() => ({ + isVisible: !delay, + syncDelay: !delay ? computeSyncDelay() : 0, + })) useEffect(() => { if (delay) { const delayDuration = typeof delay === 'number' ? delay : delay === 'short' ? 300 : 1000 const timeoutId = setTimeout(() => { - setIsVisible(true) + setVisibleState({isVisible: true, syncDelay: computeSyncDelay()}) }, delayDuration) return () => clearTimeout(timeoutId) @@ -59,11 +64,13 @@ function Spinner({ return null } + const shouldSync = syncAnimationsEnabled && noMotionPreference + const mergedStyle = shouldSync ? {...style, animationDelay: `${syncDelay}ms`} : style + return ( /* inline-flex removes the extra line height */ void - -type AnimationTimingValue = { - startTime: CSSNumberish | null -} - -type AnimationTimingStore = { - subscribers: Set - value: AnimationTimingValue - update(startTime: CSSNumberish): void - subscribe(subscriber: Subscriber): () => void - getSnapshot(): AnimationTimingValue - getServerSnapshot(): AnimationTimingValue -} - -const animationTimingStore: AnimationTimingStore = { - subscribers: new Set<() => void>(), - value: { - startTime: null, - }, - update(startTime) { - const value = { - startTime, - } - animationTimingStore.value = value - for (const subscriber of animationTimingStore.subscribers) { - subscriber() - } - }, - subscribe(subscriber) { - animationTimingStore.subscribers.add(subscriber) - return () => { - animationTimingStore.subscribers.delete(subscriber) - } - }, - getSnapshot() { - return animationTimingStore.value - }, - getServerSnapshot() { - return animationTimingStore.value - }, -} - /** - * A utility hook for reading a common `startTime` value so that all animations - * are in sync. This is a global value and is coordinated through `useSyncExternalStore`. + * Computes a negative animation-delay so all spinners land at the same + * rotation angle regardless of when they mount. Because every instance + * references the same clock (performance.now()), the CSS animation engine + * keeps them visually in sync without any Web Animations API calls + * (getAnimations, element.animate, startTime), which are significantly + * slower in Safari/WebKit. */ -function useAnimationTiming() { - return useSyncExternalStore( - animationTimingStore.subscribe, - animationTimingStore.getSnapshot, - animationTimingStore.getServerSnapshot, - ) -} - -/** - * Uses a technique from Spectrum to coordinate animations: - * @see https://github.com/adobe/react-spectrum/blob/ab5e6f3dba4235dafab9f81f8b5c506ce5f11230/packages/%40react-spectrum/s2/src/Skeleton.tsx#L21 - */ -function useSpinnerAnimation() { - const ref = useRef(null) - const noMotionPreference = useMedia('(prefers-reduced-motion: no-preference)', false) - const animationTiming = useAnimationTiming() - return useCallback( - (element: HTMLElement | SVGSVGElement | null) => { - if (!element) { - return - } - - if (ref.current !== null) { - return - } - - if (noMotionPreference) { - const cssAnimation = element.getAnimations().find((animation): animation is CSSAnimation => { - if (animation instanceof CSSAnimation) { - return animation.animationName.startsWith('Spinner') && animation.animationName.endsWith('rotate-keyframes') - } - return false - }) - // If we can find a CSS Animation, pause it and we will use the Web - // Animations API to pick up from where it left off - cssAnimation?.pause() - - ref.current = element.animate( - [ - { - transform: 'rotate(0deg)', - }, - { - transform: 'rotate(360deg)', - }, - ], - { - // var(--base-duration-1000) - duration: 1000, - // var(--base-easing-linear) - easing: 'cubic-bezier(0,0,1,1)', - iterations: Infinity, - }, - ) - - // When the `startTime` value from `animationTimingStore` is `null` we - // are currently hydrating on the client. In this case, the first - // spinner to mount will set the `startTime` for all other spinners. - if (animationTiming.startTime === null) { - const startTime = cssAnimation?.startTime ?? 0 - - animationTimingStore.update(startTime) - - // We use `startTime` to sync different animations. When all animations - // have the same startTime they will be in sync. - // @see https://developer.mozilla.org/en-US/docs/Web/API/Animation/startTime#syncing_different_animations - ref.current.startTime = startTime - } else { - ref.current.startTime = animationTiming.startTime - } - } - }, - [noMotionPreference, animationTiming], - ) +function computeSyncDelay(): number { + const now = typeof performance !== 'undefined' ? performance.now() : 0 + return -(now % ANIMATION_DURATION_MS) } export default Spinner diff --git a/packages/react/src/Timeline/Timeline.module.css b/packages/react/src/Timeline/Timeline.module.css index 608df26e2eb..f90a195d71b 100644 --- a/packages/react/src/Timeline/Timeline.module.css +++ b/packages/react/src/Timeline/Timeline.module.css @@ -88,7 +88,8 @@ .TimelineBody { min-width: 0; max-width: 100%; - margin-top: var(--base-size-4); + /* stylelint-disable-next-line primer/spacing */ + margin-top: calc(var(--base-size-4) + 1px); font-size: var(--text-body-size-medium); color: var(--fgColor-muted); flex: auto; diff --git a/packages/react/src/TreeView/TreeView.examples.stories.tsx b/packages/react/src/TreeView/TreeView.examples.stories.tsx index 874e081b3fe..009b7a4aeb6 100644 --- a/packages/react/src/TreeView/TreeView.examples.stories.tsx +++ b/packages/react/src/TreeView/TreeView.examples.stories.tsx @@ -3,7 +3,7 @@ import type {StoryFn, Meta} from '@storybook/react-vite' import React from 'react' import {TreeView} from './TreeView' import {IconButton} from '../Button' -import {Dialog} from '../Dialog/Dialog' +import {Dialog} from '../Dialog' import classes from './TreeView.stories.module.css' const meta: Meta = { diff --git a/packages/react/src/TreeView/TreeView.tsx b/packages/react/src/TreeView/TreeView.tsx index c10d740513a..73f19d08c24 100644 --- a/packages/react/src/TreeView/TreeView.tsx +++ b/packages/react/src/TreeView/TreeView.tsx @@ -21,7 +21,7 @@ import {getFirstChildElement, useRovingTabIndex} from './useRovingTabIndex' import {useTypeahead} from './useTypeahead' import {SkeletonAvatar} from '../SkeletonAvatar' import {SkeletonText} from '../SkeletonText' -import {Dialog} from '../Dialog/Dialog' +import {Dialog} from '../Dialog' import {Button, IconButton} from '../Button' import {ActionList} from '../ActionList' import {getAccessibleKeybindingHintString} from '../KeybindingHint' diff --git a/packages/react/src/experimental/index.ts b/packages/react/src/experimental/index.ts index 0a8bd1bb9e0..4c194a02f24 100644 --- a/packages/react/src/experimental/index.ts +++ b/packages/react/src/experimental/index.ts @@ -34,7 +34,7 @@ export type { ObjectPaths, } from '../DataTable' -export * from '../Dialog/Dialog' +export * from '../Dialog' export {InlineMessage} from '../InlineMessage' export type {InlineMessageProps} from '../InlineMessage' diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b3b6a1ca40d..66c8d438e12 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -92,8 +92,8 @@ export {default as CounterLabel} from './CounterLabel' export type {CounterLabelProps} from './CounterLabel' export {default as Details} from './Details' export type {DetailsProps} from './Details' -export {Dialog} from './Dialog/Dialog' -export type {DialogProps, DialogHeaderProps, DialogButtonProps, DialogWidth, DialogHeight} from './Dialog/Dialog' +export {Dialog} from './Dialog' +export type {DialogProps, DialogHeaderProps, DialogButtonProps, DialogWidth, DialogHeight} from './Dialog' export type {ConfirmationDialogProps} from './ConfirmationDialog/ConfirmationDialog' export {ConfirmationDialog} from './ConfirmationDialog/ConfirmationDialog' export {default as Flash} from './Flash' diff --git a/packages/react/src/utils/__tests__/descendant-registry.test.tsx b/packages/react/src/utils/__tests__/descendant-registry.test.tsx new file mode 100644 index 00000000000..f357b8d2b6e --- /dev/null +++ b/packages/react/src/utils/__tests__/descendant-registry.test.tsx @@ -0,0 +1,183 @@ +import {describe, expect, it} from 'vitest' +import type React from 'react' +import {Fragment, useState} from 'react' +import {render} from '@testing-library/react' +import {createDescendantRegistry} from '../descendant-registry' +import {userEvent} from '@testing-library/user-event' + +/** + * Creates a fresh registry instance with isolated helper components for each test. This ensures + * no state leaks between tests via a shared Context or Provider. + */ +function createTestRegistry() { + const {Provider, useRegistryState, useRegisterDescendant} = createDescendantRegistry() + + /** + * Parent component that exposes the registry values in the DOM for assertions. + * State is held here and passed down to the Provider. + */ + function RegistryParent({children}: {children: React.ReactNode}) { + const [registryState, setRegistry] = useRegistryState() + + return ( + <> +
{Array.from(registryState?.values() ?? []).join(',')}
+ {children} + + ) + } + + /** A leaf component that registers itself as a descendant. */ + function Item({value}: {value: string}) { + useRegisterDescendant(value) + return null + } + + return {RegistryParent, Item} +} + +describe('createDescendantRegistry', () => { + it('registers descendant items inside of other components', () => { + const {RegistryParent, Item} = createTestRegistry() + + function Wrapper({value}: {value: string}) { + return + } + + const {getByTestId} = render( + + + + + , + ) + + expect(getByTestId('registry-values').textContent).toBe('a,b,c') + }) + + it('registers descendant items inside of React fragments', () => { + const {RegistryParent, Item} = createTestRegistry() + + const {getByTestId} = render( + + + + + + + , + ) + + expect(getByTestId('registry-values').textContent).toBe('a,b,c') + }) + + it('updates item values on change', async () => { + const {RegistryParent, Item} = createTestRegistry() + + function Test() { + const [middleValue, setMiddleValue] = useState('middle') + return ( + + + + + + + ) + } + + const {getByTestId, getByRole} = render() + expect(getByTestId('registry-values').textContent).toBe('a,middle,b') + + await userEvent.click(getByRole('button')) + + expect(getByTestId('registry-values').textContent).toBe('a,c,b') + }) + + it('registers items added to the middle of children after initial render', async () => { + const {RegistryParent, Item} = createTestRegistry() + + function Test() { + const [showMiddle, setShowMiddle] = useState(false) + return ( + + + {showMiddle && } + + + + ) + } + + const {getByTestId, getByRole} = render() + expect(getByTestId('registry-values').textContent).toBe('a,b') + + await userEvent.click(getByRole('button')) + + expect(getByTestId('registry-values').textContent).toBe('a,middle,b') + }) + + it('drops items from the registry after they unmount', async () => { + const {RegistryParent, Item} = createTestRegistry() + + function Test() { + const [showLast, setShowLast] = useState(true) + return ( + + + + {showLast && } + + + ) + } + + const {getByTestId, getByRole} = render() + expect(getByTestId('registry-values').textContent).toBe('a,b,c') + + await userEvent.click(getByRole('button')) + + expect(getByTestId('registry-values').textContent).toBe('a,b') + }) + + it('registers deep descendants added to the beginning of the tree after initial render', async () => { + const {RegistryParent, Item} = createTestRegistry() + + function DeepItem({value}: {value: string}) { + return ( +
+
+ +
+
+ ) + } + + function Test() { + const [showFirst, setShowFirst] = useState(false) + return ( + + {showFirst && } + + + + + ) + } + + const {getByTestId, getByRole} = render() + expect(getByTestId('registry-values').textContent).toBe('second,third') + + await userEvent.click(getByRole('button')) + + expect(getByTestId('registry-values').textContent).toBe('first,second,third') + }) +}) diff --git a/packages/react/src/utils/descendant-registry.tsx b/packages/react/src/utils/descendant-registry.tsx new file mode 100644 index 00000000000..582b96c41c8 --- /dev/null +++ b/packages/react/src/utils/descendant-registry.tsx @@ -0,0 +1,159 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useReducer, + useRef, + useState, + type Dispatch, + type ReactNode, +} from 'react' +import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect' + +export interface ProviderProps { + children: ReactNode + /** State setter from `useRegistryState`. */ + setRegistry: Dispatch | undefined>> +} + +interface DescendantRegistryContext { + register: (id: string) => () => void + updateValue: (id: string, value: T) => void + key: number +} + +/** + * Create a "descendant registry" for a component. This allows a parent to store and track an ordered registry of + * child components, even if they are deeply nested in the tree. For example, a menu component can use this to track + * every menu item inside of it, including menu items inside of fragments and subcomponents. The order of the resulting + * `Map` will match the render order in the React tree. + * + * Usage: + * 1. Create a registry at the file level in a similar manner to creating a new React context. Set `T` to the data type + * of the registry - this can be anything **except** a function. + * 2. In the parent component, instantiate the registry state via `useRegistryState`. Then wrap `children` in the + * registry context `Provider` with `setRegistry` set to the state setter function. + * 3. Register child components via `useRegisterDescendant` or `useRegisterDescendantCallback`. + * 4. Access the registered data using the value from `useRegistryState`. This will be a map of `string` to `T`, where + * the string key is a unique and stable identifier for each component which can be used as a `key` if necessary. + * + * @note Note that this pattern is not SSR compatible. It won't raise errors or hydration mismatches, but the + * registry will not be available during SSR. The registry is built during the effect phase, so it will be populated + * after hydration on the client. The initial `undefined` value can be used to safely show loading UI during SSR/initial + * render if necessary. + */ +export function createDescendantRegistry() { + const Context = createContext>({ + register: () => () => {}, + updateValue: () => {}, + key: -1, + }) + + /** + * Instantiate descendant registry state. The initial value will be `undefined`, indicating that the registry hasn't + * been built yet. + */ + function useRegistryState() { + // We could do this inside of `Provider`, but then it would be difficult for the parent itself to access the state value + return useState>() + } + + /** Register a descendant component with the registry. */ + function useRegisterDescendant(value: T) { + const {register, updateValue, key} = useContext(Context) + + const id = useId() + + useEffect(() => register(id), [register, id, key]) + + useEffect(() => updateValue(id, value), [updateValue, id, value, key]) + + return id + } + + const unsetValue = Symbol('unset') + + /** Provide context for registering descendant components. This only needs to wrap `children`. */ + function Provider({children, setRegistry}: ProviderProps) { + const workingRegistryRef = useRef | 'queued' | 'idle'>('queued') + + /** State value to trigger a re-render and force all descendants to re-register. This ensures everything remains ordered. */ + const [key, rebuildRegistry] = useReducer(prev => prev + 1, 0) + + // If a rebuild is queued, instantiate a new map. Must be in a layout effect to run before all descendants' effects run to populate it + useIsomorphicLayoutEffect(function instantiateNewRegistry() { + if (workingRegistryRef.current === 'queued') { + workingRegistryRef.current = new Map() + } + }) + + /** + * Register a mounted descendant in the registry. + * @returns A cleanup function to unregister the descendant. + */ + const register = useCallback( + (id: string) => { + if (workingRegistryRef.current instanceof Map) { + // Initializing to `unsetValue` allows the `register` effect to not depend on the value + workingRegistryRef.current.set(id, unsetValue) + } else if (workingRegistryRef.current === 'idle') { + // When idle, registering a new component causes the whole registry to be rebuilt (because that item could + // be inserted anywhere in the tree, changing the order of items) + workingRegistryRef.current = 'queued' + rebuildRegistry() + } + // Noop if status is `queued` since we will restart the map in the next cycle + + return function unregister() { + if (workingRegistryRef.current instanceof Map) { + workingRegistryRef.current.delete(id) + } else if (workingRegistryRef.current === 'idle') { + // No need to rebuild the registry when unregistering, because removing an item doesn't affect the order + // the rest of the items + setRegistry(prev => { + const copy = new Map(prev) + copy.delete(id) + return copy + }) + } + } + }, + [setRegistry], + ) + + /** Update a descendant's value in the registry. */ + const updateValue = useCallback( + (id: string, value: T) => { + if (workingRegistryRef.current instanceof Map) { + workingRegistryRef.current.set(id, value) + } else if (workingRegistryRef.current === 'idle') { + // No need to rebuild the registry when updating a value; that doesn't affect order + setRegistry(prev => new Map(prev).set(id, value)) + } + // Ignore `queued` stage; a rebuild is coming that will capture the new value in the next render + }, + [setRegistry], + ) + + // After all descendants' effects complete, commit the working registry to state + useEffect(function commitWorkingRegistry() { + if (workingRegistryRef.current instanceof Map) { + // There shouldn't be any unset values left, but still filter them just in case + const setEntries = Array.from(workingRegistryRef.current.entries()).filter( + (entry): entry is [string, T] => entry[1] !== unsetValue, + ) + setRegistry(new Map(setEntries)) + workingRegistryRef.current = 'idle' + } + }) + + const contextValue = useMemo(() => ({register, updateValue, key}), [register, updateValue, key]) + + return {children} + } + + return {Provider, useRegistryState, useRegisterDescendant} +}