@qijenchen/design-system 0.1.0-beta.145 → 0.1.0-beta.147

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (576) hide show
  1. package/dist/components/Accordion/accordion.d.ts +1 -1
  2. package/dist/components/Accordion/accordion.d.ts.map +1 -1
  3. package/dist/components/Accordion/accordion.js +8 -3
  4. package/dist/components/Accordion/accordion.js.map +1 -1
  5. package/dist/components/AccountMenu/account-menu.d.ts.map +1 -1
  6. package/dist/components/AccountMenu/account-menu.js.map +1 -1
  7. package/dist/components/AgentPanel/agent-panel-fab.d.ts +7 -1
  8. package/dist/components/AgentPanel/agent-panel-fab.d.ts.map +1 -1
  9. package/dist/components/AgentPanel/agent-panel-fab.js +36 -14
  10. package/dist/components/AgentPanel/agent-panel-fab.js.map +1 -1
  11. package/dist/components/AgentPanel/agent-panel-logo.d.ts +3 -3
  12. package/dist/components/AgentPanel/agent-panel-logo.js.map +1 -1
  13. package/dist/components/AgentPanel/agent-panel.d.ts +1 -1
  14. package/dist/components/AgentPanel/agent-panel.d.ts.map +1 -1
  15. package/dist/components/AgentPanel/agent-panel.js +28 -22
  16. package/dist/components/AgentPanel/agent-panel.js.map +1 -1
  17. package/dist/components/AppShell/app-shell.d.ts +1 -1
  18. package/dist/components/AppShell/app-shell.js +1 -1
  19. package/dist/components/AppShell/app-shell.js.map +1 -1
  20. package/dist/components/Avatar/avatar.d.ts +95 -1
  21. package/dist/components/Avatar/avatar.d.ts.map +1 -1
  22. package/dist/components/Avatar/avatar.js +106 -15
  23. package/dist/components/Avatar/avatar.js.map +1 -1
  24. package/dist/components/Avatar/index.js +14 -2
  25. package/dist/components/Breadcrumb/breadcrumb.d.ts +4 -2
  26. package/dist/components/Breadcrumb/breadcrumb.d.ts.map +1 -1
  27. package/dist/components/Breadcrumb/breadcrumb.js +2 -2
  28. package/dist/components/Breadcrumb/breadcrumb.js.map +1 -1
  29. package/dist/components/BulkActionBar/bulk-action-bar.d.ts.map +1 -1
  30. package/dist/components/BulkActionBar/bulk-action-bar.js +27 -2
  31. package/dist/components/BulkActionBar/bulk-action-bar.js.map +1 -1
  32. package/dist/components/Button/button.d.ts +2 -2
  33. package/dist/components/Button/button.d.ts.map +1 -1
  34. package/dist/components/Button/button.js +118 -22
  35. package/dist/components/Button/button.js.map +1 -1
  36. package/dist/components/Calendar/calendar.d.ts +34 -10
  37. package/dist/components/Calendar/calendar.d.ts.map +1 -1
  38. package/dist/components/Calendar/calendar.js +210 -36
  39. package/dist/components/Calendar/calendar.js.map +1 -1
  40. package/dist/components/Carousel/carousel.d.ts +1 -1
  41. package/dist/components/Carousel/carousel.d.ts.map +1 -1
  42. package/dist/components/Carousel/carousel.js +3 -2
  43. package/dist/components/Carousel/carousel.js.map +1 -1
  44. package/dist/components/Chart/chart.d.ts +1 -0
  45. package/dist/components/Chart/chart.d.ts.map +1 -1
  46. package/dist/components/Chart/chart.js +8 -0
  47. package/dist/components/Chart/chart.js.map +1 -1
  48. package/dist/components/Checkbox/checkbox.d.ts +1 -1
  49. package/dist/components/Checkbox/checkbox.d.ts.map +1 -1
  50. package/dist/components/Checkbox/checkbox.js +10 -2
  51. package/dist/components/Checkbox/checkbox.js.map +1 -1
  52. package/dist/components/Chip/chip.d.ts +1 -1
  53. package/dist/components/Chip/chip.d.ts.map +1 -1
  54. package/dist/components/Chip/chip.js +4 -2
  55. package/dist/components/Chip/chip.js.map +1 -1
  56. package/dist/components/Combobox/combobox.d.ts +1 -1
  57. package/dist/components/Combobox/combobox.d.ts.map +1 -1
  58. package/dist/components/Combobox/combobox.js +37 -11
  59. package/dist/components/Combobox/combobox.js.map +1 -1
  60. package/dist/components/Command/command.d.ts +1 -1
  61. package/dist/components/Command/command.d.ts.map +1 -1
  62. package/dist/components/Command/command.js +106 -8
  63. package/dist/components/Command/command.js.map +1 -1
  64. package/dist/components/DataTable/cell-registry.d.ts.map +1 -1
  65. package/dist/components/DataTable/cell-registry.js +3 -2
  66. package/dist/components/DataTable/cell-registry.js.map +1 -1
  67. package/dist/components/DataTable/data-table-column-visibility-panel.d.ts.map +1 -1
  68. package/dist/components/DataTable/data-table-column-visibility-panel.js +24 -24
  69. package/dist/components/DataTable/data-table-column-visibility-panel.js.map +1 -1
  70. package/dist/components/DataTable/data-table-interaction-layer.d.ts +2 -1
  71. package/dist/components/DataTable/data-table-interaction-layer.d.ts.map +1 -1
  72. package/dist/components/DataTable/data-table-interaction-layer.js +3 -4
  73. package/dist/components/DataTable/data-table-interaction-layer.js.map +1 -1
  74. package/dist/components/DataTable/data-table-sort-manager.d.ts.map +1 -1
  75. package/dist/components/DataTable/data-table-sort-manager.js +20 -20
  76. package/dist/components/DataTable/data-table-sort-manager.js.map +1 -1
  77. package/dist/components/DataTable/data-table.d.ts +8 -5
  78. package/dist/components/DataTable/data-table.d.ts.map +1 -1
  79. package/dist/components/DataTable/data-table.js +197 -71
  80. package/dist/components/DataTable/data-table.js.map +1 -1
  81. package/dist/components/DateGrid/date-grid.d.ts +1 -1
  82. package/dist/components/DateGrid/date-grid.d.ts.map +1 -1
  83. package/dist/components/DateGrid/date-grid.js +358 -225
  84. package/dist/components/DateGrid/date-grid.js.map +1 -1
  85. package/dist/components/DatePicker/date-picker.d.ts.map +1 -1
  86. package/dist/components/DatePicker/date-picker.js +16 -1
  87. package/dist/components/DatePicker/date-picker.js.map +1 -1
  88. package/dist/components/Dialog/dialog.d.ts.map +1 -1
  89. package/dist/components/Dialog/dialog.js +55 -43
  90. package/dist/components/Dialog/dialog.js.map +1 -1
  91. package/dist/components/DropdownMenu/dropdown-menu-keyboard.d.ts +86 -0
  92. package/dist/components/DropdownMenu/dropdown-menu-keyboard.d.ts.map +1 -0
  93. package/dist/components/DropdownMenu/dropdown-menu-keyboard.js +172 -0
  94. package/dist/components/DropdownMenu/dropdown-menu-keyboard.js.map +1 -0
  95. package/dist/components/DropdownMenu/dropdown-menu.d.ts +6 -1
  96. package/dist/components/DropdownMenu/dropdown-menu.d.ts.map +1 -1
  97. package/dist/components/DropdownMenu/dropdown-menu.js +50 -39
  98. package/dist/components/DropdownMenu/dropdown-menu.js.map +1 -1
  99. package/dist/components/Field/field-context.d.ts +4 -0
  100. package/dist/components/Field/field-context.d.ts.map +1 -1
  101. package/dist/components/Field/field-context.js +1 -2
  102. package/dist/components/Field/field-context.js.map +1 -1
  103. package/dist/components/Field/field-edit-keys.d.ts +8 -0
  104. package/dist/components/Field/field-edit-keys.d.ts.map +1 -1
  105. package/dist/components/Field/field-edit-keys.js +3 -0
  106. package/dist/components/Field/field-edit-keys.js.map +1 -1
  107. package/dist/components/Field/field-wrapper.d.ts +18 -1
  108. package/dist/components/Field/field-wrapper.d.ts.map +1 -1
  109. package/dist/components/Field/field-wrapper.js +39 -10
  110. package/dist/components/Field/field-wrapper.js.map +1 -1
  111. package/dist/components/Field/field.d.ts +3 -2
  112. package/dist/components/Field/field.d.ts.map +1 -1
  113. package/dist/components/Field/field.js +2 -1
  114. package/dist/components/Field/field.js.map +1 -1
  115. package/dist/components/FileItem/file-item.d.ts +26 -2
  116. package/dist/components/FileItem/file-item.d.ts.map +1 -1
  117. package/dist/components/FileItem/file-item.js +82 -25
  118. package/dist/components/FileItem/file-item.js.map +1 -1
  119. package/dist/components/FileItem/index.js +4 -2
  120. package/dist/components/FileUpload/file-upload.d.ts +1 -1
  121. package/dist/components/FileUpload/file-upload.d.ts.map +1 -1
  122. package/dist/components/FileUpload/file-upload.js +132 -55
  123. package/dist/components/FileUpload/file-upload.js.map +1 -1
  124. package/dist/components/FileViewer/file-viewer.d.ts +1 -1
  125. package/dist/components/FileViewer/file-viewer.d.ts.map +1 -1
  126. package/dist/components/FileViewer/file-viewer.js +9 -4
  127. package/dist/components/FileViewer/file-viewer.js.map +1 -1
  128. package/dist/components/InlineEdit/inline-edit.d.ts.map +1 -1
  129. package/dist/components/InlineEdit/inline-edit.js +1 -1
  130. package/dist/components/InlineEdit/inline-edit.js.map +1 -1
  131. package/dist/components/Input/input.d.ts.map +1 -1
  132. package/dist/components/Input/input.js +6 -1
  133. package/dist/components/Input/input.js.map +1 -1
  134. package/dist/components/LinkInput/link-input.d.ts.map +1 -1
  135. package/dist/components/LinkInput/link-input.js +20 -4
  136. package/dist/components/LinkInput/link-input.js.map +1 -1
  137. package/dist/components/Notice/notice.js +1 -1
  138. package/dist/components/Notice/notice.js.map +1 -1
  139. package/dist/components/NumberInput/number-input.d.ts.map +1 -1
  140. package/dist/components/NumberInput/number-input.js +6 -1
  141. package/dist/components/NumberInput/number-input.js.map +1 -1
  142. package/dist/components/OverflowIndicator/index.js +2 -1
  143. package/dist/components/OverflowIndicator/overflow-indicator.d.ts +19 -1
  144. package/dist/components/OverflowIndicator/overflow-indicator.d.ts.map +1 -1
  145. package/dist/components/OverflowIndicator/overflow-indicator.js +47 -27
  146. package/dist/components/OverflowIndicator/overflow-indicator.js.map +1 -1
  147. package/dist/components/Pagination/pagination.d.ts +1 -1
  148. package/dist/components/Pagination/pagination.js +1 -1
  149. package/dist/components/Pagination/pagination.js.map +1 -1
  150. package/dist/components/PeoplePicker/avatar-stack-overflow.d.ts +7 -6
  151. package/dist/components/PeoplePicker/avatar-stack-overflow.d.ts.map +1 -1
  152. package/dist/components/PeoplePicker/avatar-stack-overflow.js +6 -7
  153. package/dist/components/PeoplePicker/avatar-stack-overflow.js.map +1 -1
  154. package/dist/components/PeoplePicker/people-picker-helpers.d.ts.map +1 -1
  155. package/dist/components/PeoplePicker/people-picker-helpers.js +1 -1
  156. package/dist/components/PeoplePicker/people-picker-helpers.js.map +1 -1
  157. package/dist/components/PeoplePicker/people-picker.d.ts.map +1 -1
  158. package/dist/components/PeoplePicker/people-picker.js +2 -2
  159. package/dist/components/PeoplePicker/people-picker.js.map +1 -1
  160. package/dist/components/PeoplePicker/person-display.d.ts.map +1 -1
  161. package/dist/components/PeoplePicker/person-display.js +26 -28
  162. package/dist/components/PeoplePicker/person-display.js.map +1 -1
  163. package/dist/components/ProfileCard/profile-card.d.ts.map +1 -1
  164. package/dist/components/ProfileCard/profile-card.js.map +1 -1
  165. package/dist/components/RadioGroup/radio-group.d.ts +1 -1
  166. package/dist/components/RadioGroup/radio-group.d.ts.map +1 -1
  167. package/dist/components/RadioGroup/radio-group.js +10 -4
  168. package/dist/components/RadioGroup/radio-group.js.map +1 -1
  169. package/dist/components/Rating/rating.d.ts +28 -6
  170. package/dist/components/Rating/rating.d.ts.map +1 -1
  171. package/dist/components/Rating/rating.js +97 -80
  172. package/dist/components/Rating/rating.js.map +1 -1
  173. package/dist/components/ScrollArea/scroll-area.d.ts +1 -1
  174. package/dist/components/ScrollArea/scroll-area.js +1 -1
  175. package/dist/components/ScrollArea/scroll-area.js.map +1 -1
  176. package/dist/components/SegmentedControl/segmented-control.d.ts +1 -1
  177. package/dist/components/SegmentedControl/segmented-control.d.ts.map +1 -1
  178. package/dist/components/SegmentedControl/segmented-control.js +3 -2
  179. package/dist/components/SegmentedControl/segmented-control.js.map +1 -1
  180. package/dist/components/Select/select.d.ts.map +1 -1
  181. package/dist/components/Select/select.js +15 -8
  182. package/dist/components/Select/select.js.map +1 -1
  183. package/dist/components/SelectMenu/index.js +2 -1
  184. package/dist/components/SelectMenu/index.js.map +1 -1
  185. package/dist/components/SelectMenu/select-menu-keyboard.d.ts +59 -0
  186. package/dist/components/SelectMenu/select-menu-keyboard.d.ts.map +1 -0
  187. package/dist/components/SelectMenu/select-menu-keyboard.js +107 -0
  188. package/dist/components/SelectMenu/select-menu-keyboard.js.map +1 -0
  189. package/dist/components/SelectMenu/select-menu.d.ts +1 -22
  190. package/dist/components/SelectMenu/select-menu.d.ts.map +1 -1
  191. package/dist/components/SelectMenu/select-menu.js +18 -34
  192. package/dist/components/SelectMenu/select-menu.js.map +1 -1
  193. package/dist/components/SelectionControl/selection-item.d.ts.map +1 -1
  194. package/dist/components/SelectionControl/selection-item.js +1 -0
  195. package/dist/components/SelectionControl/selection-item.js.map +1 -1
  196. package/dist/components/Sidebar/sidebar.d.ts +7 -9
  197. package/dist/components/Sidebar/sidebar.d.ts.map +1 -1
  198. package/dist/components/Sidebar/sidebar.js +171 -50
  199. package/dist/components/Sidebar/sidebar.js.map +1 -1
  200. package/dist/components/Slider/slider.d.ts +1 -1
  201. package/dist/components/Slider/slider.d.ts.map +1 -1
  202. package/dist/components/Slider/slider.js +9 -4
  203. package/dist/components/Slider/slider.js.map +1 -1
  204. package/dist/components/Steps/steps.d.ts.map +1 -1
  205. package/dist/components/Steps/steps.js +54 -26
  206. package/dist/components/Steps/steps.js.map +1 -1
  207. package/dist/components/Switch/switch.d.ts +1 -1
  208. package/dist/components/Switch/switch.d.ts.map +1 -1
  209. package/dist/components/Switch/switch.js +7 -2
  210. package/dist/components/Switch/switch.js.map +1 -1
  211. package/dist/components/Tabs/tabs.d.ts +1 -1
  212. package/dist/components/Tabs/tabs.d.ts.map +1 -1
  213. package/dist/components/Tabs/tabs.js +39 -4
  214. package/dist/components/Tabs/tabs.js.map +1 -1
  215. package/dist/components/Tag/tag.d.ts +1 -1
  216. package/dist/components/Tag/tag.d.ts.map +1 -1
  217. package/dist/components/Tag/tag.js +3 -2
  218. package/dist/components/Tag/tag.js.map +1 -1
  219. package/dist/components/Textarea/textarea.d.ts.map +1 -1
  220. package/dist/components/Textarea/textarea.js +8 -2
  221. package/dist/components/Textarea/textarea.js.map +1 -1
  222. package/dist/components/TimePicker/time-columns.d.ts.map +1 -1
  223. package/dist/components/TimePicker/time-columns.js +8 -4
  224. package/dist/components/TimePicker/time-columns.js.map +1 -1
  225. package/dist/components/TimePicker/time-picker.d.ts +1 -1
  226. package/dist/components/TimePicker/time-picker.js +1 -1
  227. package/dist/components/TimePicker/time-picker.js.map +1 -1
  228. package/dist/components/TreeView/tree-view.d.ts +9 -5
  229. package/dist/components/TreeView/tree-view.d.ts.map +1 -1
  230. package/dist/components/TreeView/tree-view.js +266 -214
  231. package/dist/components/TreeView/tree-view.js.map +1 -1
  232. package/dist/hooks/use-input-modality.d.ts +12 -17
  233. package/dist/hooks/use-input-modality.d.ts.map +1 -1
  234. package/dist/hooks/use-input-modality.js +4 -34
  235. package/dist/hooks/use-input-modality.js.map +1 -1
  236. package/dist/hooks/use-overflow-items.d.ts.map +1 -1
  237. package/dist/hooks/use-overflow-items.js +2 -0
  238. package/dist/hooks/use-overflow-items.js.map +1 -1
  239. package/dist/hooks/use-truncated.d.ts.map +1 -1
  240. package/dist/hooks/use-truncated.js.map +1 -1
  241. package/dist/index.d.ts +3 -0
  242. package/dist/index.d.ts.map +1 -1
  243. package/dist/index.js +23 -3
  244. package/dist/index.js.map +1 -1
  245. package/dist/lib/drag-announcements.d.ts +32 -0
  246. package/dist/lib/drag-announcements.d.ts.map +1 -1
  247. package/dist/lib/drag-announcements.js +39 -1
  248. package/dist/lib/drag-announcements.js.map +1 -1
  249. package/dist/lib/focus-after-trigger.d.ts +45 -0
  250. package/dist/lib/focus-after-trigger.d.ts.map +1 -0
  251. package/dist/lib/focus-after-trigger.js +66 -0
  252. package/dist/lib/focus-after-trigger.js.map +1 -0
  253. package/dist/lib/overlay-coexistence.js +1 -1
  254. package/dist/lib/roving-list-keyboard.d.ts +208 -0
  255. package/dist/lib/roving-list-keyboard.d.ts.map +1 -0
  256. package/dist/lib/roving-list-keyboard.js +174 -0
  257. package/dist/lib/roving-list-keyboard.js.map +1 -0
  258. package/dist/lib/utils.d.ts.map +1 -1
  259. package/dist/lib/utils.js +6 -1
  260. package/dist/lib/utils.js.map +1 -1
  261. package/dist/patterns/element-anatomy/item-anatomy.d.ts +4 -4
  262. package/dist/patterns/element-anatomy/item-anatomy.d.ts.map +1 -1
  263. package/dist/patterns/element-anatomy/item-anatomy.js +14 -4
  264. package/dist/patterns/element-anatomy/item-anatomy.js.map +1 -1
  265. package/dist/patterns/horizontal-overflow/horizontal-overflow.d.ts.map +1 -1
  266. package/dist/patterns/horizontal-overflow/horizontal-overflow.js +25 -0
  267. package/dist/patterns/horizontal-overflow/horizontal-overflow.js.map +1 -1
  268. package/dist/patterns/overlay-surface/overlay-surface.d.ts.map +1 -1
  269. package/dist/patterns/overlay-surface/overlay-surface.js.map +1 -1
  270. package/dist/tokens/categorical-color.d.ts +6 -1
  271. package/dist/tokens/categorical-color.d.ts.map +1 -1
  272. package/dist/tokens/categorical-color.js.map +1 -1
  273. package/dist/tokens/uiSize/icon-size.d.ts +1 -1
  274. package/dist/tokens/uiSize/stack-gap.d.ts +18 -0
  275. package/dist/tokens/uiSize/stack-gap.d.ts.map +1 -0
  276. package/dist/tokens/uiSize/stack-gap.js +5 -0
  277. package/dist/tokens/uiSize/stack-gap.js.map +1 -0
  278. package/ds-canonical/fork/codex/agents/skills/performance-audit/SKILL.md +1 -1
  279. package/ds-canonical/fork/codex/hooks.json +1 -1
  280. package/ds-canonical/fork/consumer/lock.json +17 -14
  281. package/ds-canonical/fork/consumer/managed-files/423729840acdd43c812f17c245b871716c6b60f76e4d3e94864d0e34a815bf8d.blob +9 -0
  282. package/ds-canonical/fork/consumer/managed-files/46f10bc55cdfdf834d0749eebff42ea0bb8576f361b36d2b6ba1851354c50150.blob +47 -9
  283. package/ds-canonical/fork/consumer/managed-files/5a0d134a2645114c8caac20458070b00a7f0fd55b3e8805b7325cb4cf52fa15e.blob +110 -0
  284. package/ds-canonical/fork/consumer/managed-files/5af0a46760af338d51fd0f7950675f8e97f7d2f09386b8818fcd7a58e6654e75.blob +790 -0
  285. package/ds-canonical/fork/consumer/managed-files/68bfcddff9c4a16388c248cd34fb66163e9e64b3d809a48ef3984f6ae4ff6d12.blob +211 -0
  286. package/ds-canonical/fork/consumer/managed-files/7eb6cc60abfdabaf6617c336058fad1454ccde08889295767bfd9b7fd153cc6e.blob +45 -6
  287. package/ds-canonical/fork/consumer/managed-files/92e4828cdcb96ff0d01daa16f6885f24045a8e72820665650f2a1890dc3a446c.blob +42 -2
  288. package/ds-canonical/fork/consumer/managed-files/ab072e7f2eca51f0e18a5e18c4b333260ee5d219a12cc6df5bb2d683f0b2734d.blob +27 -1
  289. package/ds-canonical/fork/consumer/managed-files/e71c65f45c6af7ce12b6b3c0d8028f185da5b719e2050c9b2a6b243d41ae4878.blob +62 -14
  290. package/ds-canonical/fork/governance.lock +41 -17
  291. package/ds-canonical/fork/hooks/check_consumer_app_invariants.sh +16 -4
  292. package/ds-canonical/fork/hooks/check_consumer_code_quality.sh +3 -1
  293. package/ds-canonical/fork/hooks/check_pattern_invariants.sh +13 -9
  294. package/ds-canonical/fork/launchers/settings-hooks.json +1 -1
  295. package/ds-canonical/fork/manifest.json +17 -11
  296. package/ds-canonical/fork/providers/claude/skills/performance-audit/SKILL.md +1 -1
  297. package/ds-canonical/fork/providers/codex/hook-config.json +1 -1
  298. package/ds-canonical/fork/providers/codex/skills/performance-audit/SKILL.md +1 -1
  299. package/ds-canonical/fork/skills/performance-audit/SKILL.md +1 -1
  300. package/ds-canonical/hooks/check_consumer_app_invariants.sh +16 -4
  301. package/ds-canonical/hooks/check_consumer_code_quality.sh +3 -1
  302. package/ds-canonical/hooks/check_datatable_invariants.sh +4 -1
  303. package/ds-canonical/hooks/check_pattern_invariants.sh +13 -9
  304. package/ds-canonical/hooks/check_plugin_fork_health.sh +8 -1
  305. package/ds-canonical/hooks/check_propose_discipline.sh +8 -1
  306. package/ds-canonical/hooks/check_storybook_addon_packaging.sh +8 -1
  307. package/ds-canonical/hooks/check_substantive_edit_approval_preflight.sh +64 -2
  308. package/ds-canonical/hooks/lib/_overlay_handcraft.sh +5 -2
  309. package/ds-canonical/hooks/registrations.json +12 -7
  310. package/ds-canonical/hooks/session_start_governance_check.sh +16 -0
  311. package/ds-canonical/hooks/tests/test_check_consumer_app_invariants.sh +25 -0
  312. package/ds-canonical/hooks/tests/test_check_consumer_code_quality.sh +4 -0
  313. package/ds-canonical/hooks/tests/test_check_consumer_ds_primitive_misuse.sh +14 -0
  314. package/ds-canonical/hooks/tests/test_check_datatable_invariants.sh +24 -0
  315. package/ds-canonical/hooks/tests/test_check_overlay_handcraft.sh +32 -0
  316. package/ds-canonical/hooks/tests/test_check_pattern_invariants.sh +59 -0
  317. package/ds-canonical/hooks/tests/test_check_plugin_fork_health.sh +32 -1
  318. package/ds-canonical/hooks/tests/test_check_propose_discipline.sh +21 -0
  319. package/ds-canonical/hooks/tests/test_check_storybook_addon_packaging.sh +21 -0
  320. package/ds-canonical/hooks/tests/test_check_substantive_edit_approval_preflight.sh +97 -0
  321. package/ds-canonical/hooks/tests/test_session_start_governance_check.sh +49 -0
  322. package/ds-canonical/references/README.md +12 -6
  323. package/ds-canonical/references/drag-canonical.md +3 -1
  324. package/ds-canonical/references/focus-canonical.md +26 -13
  325. package/ds-canonical/references/governance-audit-coverage.md +1 -1
  326. package/ds-canonical/references/hit-area-canonical.md +361 -0
  327. package/ds-canonical/references/item-anatomy-recipe.md +4 -2
  328. package/ds-canonical/references/keyboard-model-canonical.md +559 -0
  329. package/ds-canonical/rules/meta-patterns.md +4 -4
  330. package/ds-canonical/rules/self-verify.md +4 -4
  331. package/ds-canonical/skills/design-system-audit/references/audit-prompts.md +1 -1
  332. package/ds-canonical/skills/design-system-audit/references/historical-bugs.md +96 -0
  333. package/ds-canonical/skills/knowledge-prune/SKILL.md +4 -1
  334. package/ds-canonical/skills/performance-audit/SKILL.md +1 -1
  335. package/ds-story-manifest.json +19 -7
  336. package/llms-full.txt +2 -2
  337. package/llms.txt +2 -2
  338. package/package.json +3 -1
  339. package/src/components/Accordion/accordion.anatomy.stories.tsx +3 -3
  340. package/src/components/Accordion/accordion.spec.md +1 -1
  341. package/src/components/Accordion/accordion.tsx +8 -3
  342. package/src/components/AccountMenu/account-menu.spec.md +2 -3
  343. package/src/components/AccountMenu/account-menu.tsx +0 -1
  344. package/src/components/AgentPanel/agent-decision-card.spec.md +88 -0
  345. package/src/components/AgentPanel/agent-panel-fab.spec.md +151 -0
  346. package/src/components/AgentPanel/agent-panel-fab.tsx +67 -24
  347. package/src/components/AgentPanel/agent-panel-logo.spec.md +87 -0
  348. package/src/components/AgentPanel/agent-panel-logo.tsx +3 -3
  349. package/src/components/AgentPanel/agent-panel.anatomy.stories.tsx +1 -1
  350. package/src/components/AgentPanel/agent-panel.principles.stories.tsx +1 -1
  351. package/src/components/AgentPanel/agent-panel.spec.md +30 -275
  352. package/src/components/AgentPanel/agent-panel.stories.tsx +113 -8
  353. package/src/components/AgentPanel/agent-panel.tsx +59 -22
  354. package/src/components/Alert/alert.anatomy.stories.tsx +1 -1
  355. package/src/components/AppShell/app-shell.tsx +1 -1
  356. package/src/components/AspectRatio/aspect-ratio.anatomy.stories.tsx +10 -15
  357. package/src/components/Avatar/avatar.anatomy.stories.tsx +23 -26
  358. package/src/components/Avatar/avatar.principles.stories.tsx +10 -10
  359. package/src/components/Avatar/avatar.spec.md +45 -6
  360. package/src/components/Avatar/avatar.stories.tsx +8 -8
  361. package/src/components/Avatar/avatar.tsx +227 -21
  362. package/src/components/Badge/badge.anatomy.stories.tsx +16 -21
  363. package/src/components/Breadcrumb/breadcrumb.anatomy.stories.tsx +3 -3
  364. package/src/components/Breadcrumb/breadcrumb.spec.md +3 -3
  365. package/src/components/Breadcrumb/breadcrumb.tsx +5 -3
  366. package/src/components/BulkActionBar/bulk-action-bar.anatomy.stories.tsx +1 -1
  367. package/src/components/BulkActionBar/bulk-action-bar.principles.stories.tsx +1 -1
  368. package/src/components/BulkActionBar/bulk-action-bar.spec.md +1 -0
  369. package/src/components/BulkActionBar/bulk-action-bar.stories.tsx +57 -2
  370. package/src/components/BulkActionBar/bulk-action-bar.tsx +32 -2
  371. package/src/components/Button/button.anatomy.stories.tsx +174 -33
  372. package/src/components/Button/button.spec.md +23 -8
  373. package/src/components/Button/button.tsx +116 -21
  374. package/src/components/Calendar/calendar.anatomy.stories.tsx +34 -11
  375. package/src/components/Calendar/calendar.principles.stories.tsx +44 -1
  376. package/src/components/Calendar/calendar.spec.md +97 -20
  377. package/src/components/Calendar/calendar.stories.tsx +101 -4
  378. package/src/components/Calendar/calendar.tsx +379 -74
  379. package/src/components/Carousel/carousel.anatomy.stories.tsx +16 -17
  380. package/src/components/Carousel/carousel.spec.md +48 -2
  381. package/src/components/Carousel/carousel.tsx +9 -3
  382. package/src/components/Chart/chart.anatomy.stories.tsx +7 -5
  383. package/src/components/Chart/chart.spec.md +32 -7
  384. package/src/components/Chart/chart.tsx +9 -0
  385. package/src/components/Checkbox/checkbox.anatomy.stories.tsx +21 -28
  386. package/src/components/Checkbox/checkbox.spec.md +4 -2
  387. package/src/components/Checkbox/checkbox.tsx +10 -2
  388. package/src/components/Chip/chip.anatomy.stories.tsx +1 -1
  389. package/src/components/Chip/chip.spec.md +2 -1
  390. package/src/components/Chip/chip.tsx +4 -2
  391. package/src/components/Combobox/combobox.anatomy.stories.tsx +30 -37
  392. package/src/components/Combobox/combobox.spec.md +11 -3
  393. package/src/components/Combobox/combobox.tsx +43 -12
  394. package/src/components/Command/command.spec.md +11 -0
  395. package/src/components/Command/command.tsx +143 -10
  396. package/src/components/DataTable/cell-registry.tsx +5 -1
  397. package/src/components/DataTable/data-table-column-visibility-panel.tsx +5 -6
  398. package/src/components/DataTable/data-table-interaction-layer.tsx +27 -14
  399. package/src/components/DataTable/data-table-known-defects.spec.md +72 -0
  400. package/src/components/DataTable/data-table-scroll-performance.spec.md +295 -0
  401. package/src/components/DataTable/data-table-sort-manager.tsx +5 -6
  402. package/src/components/DataTable/data-table.anatomy.stories.tsx +1 -1
  403. package/src/components/DataTable/data-table.css +64 -0
  404. package/src/components/DataTable/data-table.spec.md +70 -346
  405. package/src/components/DataTable/data-table.stories.tsx +56 -9
  406. package/src/components/DataTable/data-table.tsx +392 -103
  407. package/src/components/DateGrid/date-grid.anatomy.stories.tsx +2 -2
  408. package/src/components/DateGrid/date-grid.spec.md +163 -1
  409. package/src/components/DateGrid/date-grid.tsx +270 -7
  410. package/src/components/DatePicker/date-picker.anatomy.stories.tsx +18 -27
  411. package/src/components/DatePicker/date-picker.spec.md +6 -2
  412. package/src/components/DatePicker/date-picker.tsx +20 -1
  413. package/src/components/Dialog/dialog.spec.md +6 -2
  414. package/src/components/Dialog/dialog.stories.tsx +18 -9
  415. package/src/components/Dialog/dialog.tsx +83 -48
  416. package/src/components/DropdownMenu/dropdown-menu-keyboard.ts +255 -0
  417. package/src/components/DropdownMenu/dropdown-menu.anatomy.stories.tsx +32 -33
  418. package/src/components/DropdownMenu/dropdown-menu.spec.md +18 -5
  419. package/src/components/DropdownMenu/dropdown-menu.stories.tsx +58 -1
  420. package/src/components/DropdownMenu/dropdown-menu.tsx +70 -33
  421. package/src/components/Empty/empty.spec.md +1 -2
  422. package/src/components/Field/field-context.ts +9 -4
  423. package/src/components/Field/field-controls.spec.md +5 -2
  424. package/src/components/Field/field-edit-keys.ts +12 -0
  425. package/src/components/Field/field-wrapper.tsx +76 -10
  426. package/src/components/Field/field.anatomy.stories.tsx +1 -1
  427. package/src/components/Field/field.spec.md +11 -6
  428. package/src/components/Field/field.stories.tsx +24 -1
  429. package/src/components/Field/field.tsx +10 -4
  430. package/src/components/FileItem/file-item.anatomy.stories.tsx +20 -10
  431. package/src/components/FileItem/file-item.principles.stories.tsx +14 -7
  432. package/src/components/FileItem/file-item.spec.md +50 -48
  433. package/src/components/FileItem/file-item.stories.tsx +41 -28
  434. package/src/components/FileItem/file-item.tsx +184 -50
  435. package/src/components/FileUpload/file-upload.anatomy.stories.tsx +11 -15
  436. package/src/components/FileUpload/file-upload.spec.md +27 -1
  437. package/src/components/FileUpload/file-upload.stories.tsx +33 -0
  438. package/src/components/FileUpload/file-upload.tsx +173 -43
  439. package/src/components/FileViewer/file-viewer.tsx +9 -4
  440. package/src/components/HoverCard/hover-card.stories.tsx +2 -2
  441. package/src/components/InlineEdit/inline-edit.spec.md +2 -0
  442. package/src/components/InlineEdit/inline-edit.tsx +8 -3
  443. package/src/components/Input/input.anatomy.stories.tsx +23 -31
  444. package/src/components/Input/input.spec.md +11 -0
  445. package/src/components/Input/input.tsx +6 -1
  446. package/src/components/LinkInput/link-input.anatomy.stories.tsx +18 -21
  447. package/src/components/LinkInput/link-input.principles.stories.tsx +4 -4
  448. package/src/components/LinkInput/link-input.spec.md +12 -1
  449. package/src/components/LinkInput/link-input.tsx +37 -4
  450. package/src/components/Menu/menu-item.anatomy.stories.tsx +50 -56
  451. package/src/components/Menu/menu-item.spec.md +1 -1
  452. package/src/components/Menu/menu-item.stories.tsx +48 -42
  453. package/src/components/Notice/notice.anatomy.stories.tsx +8 -16
  454. package/src/components/Notice/notice.spec.md +1 -1
  455. package/src/components/Notice/notice.tsx +1 -1
  456. package/src/components/NumberInput/number-input.anatomy.stories.tsx +21 -28
  457. package/src/components/NumberInput/number-input.spec.md +2 -0
  458. package/src/components/NumberInput/number-input.tsx +8 -1
  459. package/src/components/OverflowIndicator/overflow-indicator.anatomy.stories.tsx +28 -48
  460. package/src/components/OverflowIndicator/overflow-indicator.spec.md +13 -1
  461. package/src/components/OverflowIndicator/overflow-indicator.stories.tsx +12 -9
  462. package/src/components/OverflowIndicator/overflow-indicator.tsx +32 -12
  463. package/src/components/Pagination/pagination.spec.md +1 -0
  464. package/src/components/Pagination/pagination.tsx +1 -1
  465. package/src/components/PeoplePicker/avatar-stack-overflow.ts +14 -13
  466. package/src/components/PeoplePicker/people-picker-helpers.ts +5 -2
  467. package/src/components/PeoplePicker/people-picker.anatomy.stories.tsx +1 -1
  468. package/src/components/PeoplePicker/people-picker.spec.md +4 -2
  469. package/src/components/PeoplePicker/people-picker.tsx +1 -4
  470. package/src/components/PeoplePicker/person-display.tsx +45 -28
  471. package/src/components/Popover/popover.anatomy.stories.tsx +1 -1
  472. package/src/components/Popover/popover.principles.stories.tsx +3 -12
  473. package/src/components/Popover/popover.spec.md +7 -3
  474. package/src/components/ProfileCard/profile-card.anatomy.stories.tsx +1 -1
  475. package/src/components/ProfileCard/profile-card.spec.md +1 -1
  476. package/src/components/ProfileCard/profile-card.tsx +0 -3
  477. package/src/components/ProgressBar/progress-bar.anatomy.stories.tsx +11 -21
  478. package/src/components/ProgressBar/progress-bar.spec.md +1 -1
  479. package/src/components/RadioGroup/radio-group.anatomy.stories.tsx +17 -24
  480. package/src/components/RadioGroup/radio-group.spec.md +1 -1
  481. package/src/components/RadioGroup/radio-group.tsx +22 -4
  482. package/src/components/Rating/rating.anatomy.stories.tsx +131 -124
  483. package/src/components/Rating/rating.principles.stories.tsx +69 -43
  484. package/src/components/Rating/rating.spec.md +142 -63
  485. package/src/components/Rating/rating.stories.tsx +11 -15
  486. package/src/components/Rating/rating.tsx +131 -117
  487. package/src/components/ScrollArea/scroll-area.anatomy.stories.tsx +1 -1
  488. package/src/components/ScrollArea/scroll-area.principles.stories.tsx +6 -3
  489. package/src/components/ScrollArea/scroll-area.spec.md +3 -1
  490. package/src/components/ScrollArea/scroll-area.stories.tsx +5 -2
  491. package/src/components/ScrollArea/scroll-area.tsx +1 -1
  492. package/src/components/SegmentedControl/segmented-control.anatomy.stories.tsx +1 -1
  493. package/src/components/SegmentedControl/segmented-control.spec.md +2 -2
  494. package/src/components/SegmentedControl/segmented-control.tsx +3 -2
  495. package/src/components/Select/select.anatomy.stories.tsx +26 -35
  496. package/src/components/Select/select.spec.md +4 -3
  497. package/src/components/Select/select.tsx +11 -4
  498. package/src/components/SelectMenu/select-menu-keyboard.ts +218 -0
  499. package/src/components/SelectMenu/select-menu.anatomy.stories.tsx +6 -17
  500. package/src/components/SelectMenu/select-menu.spec.md +12 -4
  501. package/src/components/SelectMenu/select-menu.tsx +21 -56
  502. package/src/components/SelectionControl/selection-item.anatomy.stories.tsx +9 -33
  503. package/src/components/SelectionControl/selection-item.spec.md +2 -2
  504. package/src/components/SelectionControl/selection-item.tsx +3 -1
  505. package/src/components/Separator/separator.stories.tsx +4 -4
  506. package/src/components/Sheet/sheet.principles.stories.tsx +31 -13
  507. package/src/components/Sidebar/sidebar.anatomy.stories.tsx +1 -1
  508. package/src/components/Sidebar/sidebar.spec.md +73 -6
  509. package/src/components/Sidebar/sidebar.stories.tsx +82 -4
  510. package/src/components/Sidebar/sidebar.tsx +281 -64
  511. package/src/components/Skeleton/skeleton.spec.md +1 -1
  512. package/src/components/Slider/slider.anatomy.stories.tsx +1 -1
  513. package/src/components/Slider/slider.spec.md +6 -5
  514. package/src/components/Slider/slider.tsx +13 -8
  515. package/src/components/Steps/steps-state-visuals.spec.md +157 -0
  516. package/src/components/Steps/steps.anatomy.stories.tsx +1 -0
  517. package/src/components/Steps/steps.spec.md +59 -142
  518. package/src/components/Steps/steps.stories.tsx +1 -1
  519. package/src/components/Steps/steps.tsx +125 -33
  520. package/src/components/Switch/switch.spec.md +3 -3
  521. package/src/components/Switch/switch.tsx +6 -1
  522. package/src/components/Tabs/tabs.spec.md +5 -1
  523. package/src/components/Tabs/tabs.tsx +42 -3
  524. package/src/components/Tag/tag.anatomy.stories.tsx +24 -28
  525. package/src/components/Tag/tag.spec.md +10 -11
  526. package/src/components/Tag/tag.tsx +3 -3
  527. package/src/components/Textarea/textarea.tsx +7 -1
  528. package/src/components/TimePicker/time-columns.tsx +9 -4
  529. package/src/components/TimePicker/time-picker.spec.md +4 -1
  530. package/src/components/TimePicker/time-picker.tsx +1 -1
  531. package/src/components/Tooltip/tooltip.anatomy.stories.tsx +6 -15
  532. package/src/components/Tooltip/tooltip.principles.stories.tsx +4 -16
  533. package/src/components/Tooltip/tooltip.spec.md +2 -1
  534. package/src/components/TreeView/tree-view.anatomy.stories.tsx +35 -13
  535. package/src/components/TreeView/tree-view.spec.md +67 -38
  536. package/src/components/TreeView/tree-view.stories.tsx +221 -12
  537. package/src/components/TreeView/tree-view.tsx +463 -356
  538. package/src/hooks/use-input-modality.ts +17 -46
  539. package/src/hooks/use-overflow-items.ts +4 -0
  540. package/src/hooks/use-truncated.ts +3 -2
  541. package/src/index.ts +17 -0
  542. package/src/lib/README.md +2 -0
  543. package/src/lib/drag-announcements.ts +78 -0
  544. package/src/lib/focus-after-trigger.ts +102 -0
  545. package/src/lib/roving-list-keyboard.ts +400 -0
  546. package/src/lib/utils.ts +5 -0
  547. package/src/patterns/action-bar/action-bar.spec.md +2 -0
  548. package/src/patterns/element-anatomy/inline-action.spec.md +16 -12
  549. package/src/patterns/element-anatomy/item-anatomy.spec.md +98 -6
  550. package/src/patterns/element-anatomy/item-anatomy.stories.tsx +75 -70
  551. package/src/patterns/element-anatomy/item-anatomy.tsx +17 -6
  552. package/src/patterns/element-anatomy/truncated-text.spec.md +1 -0
  553. package/src/patterns/header-canonical/header-canonical.spec.md +3 -2
  554. package/src/patterns/horizontal-overflow/horizontal-overflow.spec.md +2 -0
  555. package/src/patterns/horizontal-overflow/horizontal-overflow.stories.tsx +36 -0
  556. package/src/patterns/horizontal-overflow/horizontal-overflow.tsx +28 -0
  557. package/src/patterns/overlay-surface/overlay-chrome-sizing.spec.md +180 -0
  558. package/src/patterns/overlay-surface/overlay-surface.spec.md +8 -162
  559. package/src/patterns/overlay-surface/overlay-surface.tsx +5 -3
  560. package/src/patterns/resize-handle/resize-handle.spec.md +3 -3
  561. package/src/styles/base.css +1 -1
  562. package/src/tokens/categorical-color.ts +6 -1
  563. package/src/tokens/color/color.spec.md +91 -24
  564. package/src/tokens/color/color.stories.tsx +50 -2
  565. package/src/tokens/color/primitives.css +41 -38
  566. package/src/tokens/color/semantic.css +105 -36
  567. package/src/tokens/layoutSpace/layoutSpace.spec.md +2 -0
  568. package/src/tokens/motion/motion.spec.md +31 -8
  569. package/src/tokens/orphan-tokens.spec.md +1 -1
  570. package/src/tokens/uiSize/icon-size.ts +1 -1
  571. package/src/tokens/uiSize/stack-gap.ts +17 -0
  572. package/src/tokens/uiSize/uiSize.css +12 -0
  573. package/src/tokens/uiSize/uiSize.spec.md +17 -16
  574. package/src/tokens/uiSize/uiSize.stories.tsx +1 -1
  575. package/dist/node_modules/aria-hidden/dist/es2015/index.js +0 -147
  576. package/dist/node_modules/aria-hidden/dist/es2015/index.js.map +0 -1
@@ -0,0 +1,790 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // Playwright 啟動參數的單一來源
3
+ // ═══════════════════════════════════════════════════════════════════════════
4
+ //
5
+ // **為什麼有這個檔**(2026-09-07):
6
+ // 全 repo 有 20 支以上的腳本各自寫 `chromium.launch({ headless: true })`,
7
+ // 而本 repo 的沙箱**少了 `--single-process --no-sandbox` 就起不了 Chromium**。
8
+ // 結果是:有 SKIPPED-ENV 守衛的腳本一路回 exit 0(看起來綠的,其實一條都沒驗);
9
+ // 沒有守衛的直接爆掉。實測 `data-table-invariants.mjs` 的 322 條不變條件
10
+ // **在本機從來沒有真的執行過** —— 補上參數後才第一次跑起來(而且全過)。
11
+ //
12
+ // 這是「同一個值散在 20 幾處」的典型(M17):任何一支忘了寫,它就靜靜地不驗任何東西。
13
+ //
14
+ // `--single-process`:沙箱不給開 zygote / 多程序。
15
+ // `--no-sandbox`:外層已經有沙箱,Chromium 自己的那層起不來。
16
+ // 實測(2026-09-07)三種組合:只給 `--no-sandbox` 起不來、什麼都不給也起不來,**兩個都要**。
17
+ //
18
+ // ⚠️ **`--single-process` 的代價:開不了第二個 browser context。**
19
+ // 需要多個 context(不同 deviceScaleFactor / 不同權限)的腳本,要**每種情境重開一次瀏覽器**,
20
+ // 不能在同一個 browser 上 `newContext()` —— 那會「第一個過、第二個當場崩」,
21
+ // 症狀是跑到一半才爆,很容易被誤讀成別的問題(`test-devmode-geometry-invariant.mjs` 踩過)。
22
+ // 同一個 context 內開多個 page 也不穩,長流程建議一個 page 走完。
23
+ //
24
+ // 用法:
25
+ // 用法:從 scripts 底下的閘 import 本檔的 launchBrowser(相對路徑 lib/launch-browser.mjs)。
26
+ // (這行刻意不寫成 import 語句:相依閉包掃描器連註解裡的 import 也會算,寫成語句會讓本檔看起來 import 自己。)
27
+ // const browser = await launchBrowser() // 起不來會丟例外
28
+ // const browser = await launchBrowser({ headless: false }) // 覆寫任何選項
29
+ // const browser = await launchBrowserOrSkip() // 起不來 → SKIPPED-ENV exit 0;必需瀏覽器的 lane → BROWSER-REQUIRED exit 1
30
+ // const browser = await launchBrowserOrSkip({}, { cleanup: () => server.stop(), hint: '…' }) // 退出前先收尾
31
+ // requireStorybookBuild(join(STATIC, 'index.json')) // 沒有建置 → 印 MISSING-BUILD 並 exit 2(缺前置)
32
+ // requireFreshStorybookBuild(STATIC, ['packages/design-system/src/components/X/x.tsx']) // 沒有建置 → MISSING-BUILD;建置比原始碼舊 → STALE-BUILD exit 2(唯一實作,見該函式)
33
+ // await openStory(page, url, { … }) // 開 story 並證明真的渲染完成(見下方 openStory 區塊)
34
+ // await waitForStoryRender(frame, { storyId }) // 不是導覽觸發的切換(管理介面點側欄)也用同一份判定
35
+ // await settleAfterInteraction(page, { frames: 10 }) // 點開浮層 / 按鍵之後等版面真的停了(取代固定睡眠;ok:false = 儀器失效)
36
+ // await waitForDocsRender(frame, { docsId, timeoutMs }) // docs 頁真的渲染出來(docs 沒有 story 的 render phase,判定見該函式)
37
+
38
+ import { existsSync, readdirSync, statSync } from 'node:fs'
39
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
40
+ import { fileURLToPath } from 'node:url'
41
+ import { chromium } from 'playwright'
42
+ import { createRenderHealthMonitor } from './storybook-render-health.mjs'
43
+
44
+ /** 本 repo 沙箱起得了 Chromium 的必要參數。 */
45
+ export const SANDBOX_ARGS = ['--single-process', '--no-sandbox']
46
+
47
+ export async function launchBrowser(options = {}) {
48
+ const { args = [], ...rest } = options
49
+ return chromium.launch({ headless: true, ...rest, args: [...SANDBOX_ARGS, ...args] })
50
+ }
51
+
52
+ // ═══════════════════════════════════════════════════════════════════════════
53
+ // 「這次沒跑 / 沒量到」的機讀標記 —— 閘印、lib/gate-selftest-meta.mjs 認,兩邊都從這裡取(M17)
54
+ // ═══════════════════════════════════════════════════════════════════════════
55
+ //
56
+ // **為什麼要有明確標記**(2026-09-25):gate-selftest-meta 原本把「退出碼 2」一律讀成「起不了環境 → 略過」,
57
+ // 而 openStory 上線後十支瀏覽器閘在 story 開不起來時(儀器失效)也是 exit 2 —— 於是真正的儀器失效
58
+ // 在 meta-test 裡被印成「略過」、exit 0(M37:沒量到被讀成沒發生)。退出碼在各閘之間本來就不一致,
59
+ // 能當判據的只有閘**主動、明確**印出的標記:
60
+ // SKIPPED-ENV 起不了 Chromium,而且這個 lane 沒宣告必需瀏覽器 → 略過(誠實地說「沒驗」)
61
+ // BROWSER-REQUIRED 起不了 Chromium,但這個 lane 宣告 GOVERNANCE_BROWSER_REQUIRED=1 → 紅(不准略過)
62
+ // MISSING-BUILD 沒有 storybook 建置(gate-meta lane 的拋棄式快照裡本來就沒有)→ 缺前置,略過
63
+ // STALE-BUILD 建置比原始碼舊 → 缺前置,略過
64
+ // INSTRUMENT-FAIL 儀器失效:開了卻沒量到(StoryRenderInstrumentError / 建置快照不完整)→ 紅,**絕不略過**
65
+ export const SKIPPED_ENV_MARKER = 'SKIPPED-ENV'
66
+ export const BROWSER_REQUIRED_MARKER = 'BROWSER-REQUIRED'
67
+ export const MISSING_BUILD_MARKER = 'MISSING-BUILD'
68
+ export const STALE_BUILD_MARKER = 'STALE-BUILD'
69
+ export const INSTRUMENT_FAIL_MARKER = 'INSTRUMENT-FAIL'
70
+
71
+ // **必需瀏覽器的 lane**(2026-09-25):CI 的瀏覽器 job 裝好 Chromium 就是為了跑這些閘,在那裡起不了瀏覽器
72
+ // 不是「環境沒有瀏覽器」而是「這個 job 壞了」—— 印 SKIPPED-ENV、exit 0 等於整批閘靜默通過(M37)。
73
+ // 這些 job 在 job 層宣告 `GOVERNANCE_BROWSER_REQUIRED: '1'`(.github/workflows/ci.yml;
74
+ // infra/governance/test/ci-workflow-scope.test.mjs 斷言「裝 Chromium 的 job 必須宣告、沒瀏覽器的 job 不得宣告」)。
75
+ export const BROWSER_REQUIRED_ENV = 'GOVERNANCE_BROWSER_REQUIRED'
76
+ /**
77
+ * 目前這個 lane 是否宣告「必需瀏覽器」:'1' = 是;未設 / 空字串 / '0' = 否。
78
+ * **其他值一律丟例外**(例:'true'、'yes')—— 寫錯值卻被當成「否」,等於 CI 以為自己有把關、實際照舊靜默略過(M37)。
79
+ */
80
+ export function isBrowserRequired(env = process.env) {
81
+ const value = env[BROWSER_REQUIRED_ENV]
82
+ if (value === '1') return true
83
+ if (value === undefined || value === '' || value === '0') return false
84
+ throw new Error(`${BROWSER_REQUIRED_ENV} 只接受 '1'(必需瀏覽器)或 '0' / 未設,實得 ${JSON.stringify(value)} —— 寫錯的值不得被當成「不必需」`)
85
+ }
86
+
87
+ /**
88
+ * 起不了 Chromium 之後的**唯一**政策(launchBrowserOrSkip 與各閘自己 try/catch 的地方都走這裡):
89
+ * 一般環境 → 印 SKIPPED-ENV、exit 0(誠實地說「這次沒驗」);
90
+ * GOVERNANCE_BROWSER_REQUIRED=1 → 印 BROWSER-REQUIRED、exit 1(必需瀏覽器的 lane 不准略過)。
91
+ * 兩條路都先跑 cleanup(例:停掉靜態伺服器)。**不回傳。**
92
+ * @param {unknown} error launch 丟出的例外
93
+ * @param {{ cleanup?: () => unknown, hint?: string }} [options]
94
+ */
95
+ export async function exitOnBrowserLaunchFailure(error, { cleanup = null, hint = '' } = {}) {
96
+ const reason = String(error?.message || error).split('\n')[0]
97
+ try { await cleanup?.() } catch { /* 收尾失敗不改變判定 */ }
98
+ if (isBrowserRequired()) {
99
+ console.error(`✗ ${BROWSER_REQUIRED_MARKER}:無法啟動 Chromium(${reason})`)
100
+ console.error(` 這個 lane 宣告 ${BROWSER_REQUIRED_ENV}=1(CI 的瀏覽器 job):起不了瀏覽器 = 這次什麼都沒驗 = 紅,不得當成略過。`)
101
+ process.exit(1)
102
+ }
103
+ console.error(`⚠️ ${SKIPPED_ENV_MARKER}: 無法啟動 Chromium(${reason})`)
104
+ if (hint) console.error(` ${hint}`)
105
+ process.exit(0)
106
+ }
107
+
108
+ /**
109
+ * 起不了瀏覽器時交給 exitOnBrowserLaunchFailure:一般環境 SKIPPED-ENV exit 0,必需瀏覽器的 lane exit 1。
110
+ *
111
+ * **只給真的可能在無瀏覽器環境跑的腳本用。** 在一般環境用它就要接受「這次什麼都沒驗」也是 exit 0 ——
112
+ * 所以 CI 裡跑它的 job 必須宣告 GOVERNANCE_BROWSER_REQUIRED=1(不然就會重演上面那個 322 條的狀況)。
113
+ * @param {object} [options] 傳給 launchBrowser(chromium.launch)的選項
114
+ * @param {{ cleanup?: () => unknown, hint?: string }} [onFailure] 起不來時先收尾、再印的補充說明
115
+ */
116
+ export async function launchBrowserOrSkip(options = {}, onFailure = {}) {
117
+ try {
118
+ return await launchBrowser(options)
119
+ } catch (error) {
120
+ return exitOnBrowserLaunchFailure(error, onFailure)
121
+ }
122
+ }
123
+
124
+ /**
125
+ * 沒有 storybook 建置 → 印 MISSING-BUILD、exit 2(缺前置,不是產品裁決)。有的話什麼都不做。
126
+ * gate-meta lane 在拋棄式快照裡跑,那裡本來就沒有 storybook-static;meta-test 只在看到這個標記時才准略過
127
+ *(不再認「退出碼 2」—— 那也是儀器失效的退出碼)。CI 的瀏覽器 job 一定先 build,這條在那裡照樣是紅。
128
+ * @param {string} path 建置裡必須存在的檔(通常是 index.json / index.html / iframe.html)
129
+ * @param {string} [hint] 怎麼補(預設:先跑 npm run build-storybook)
130
+ */
131
+ export function requireStorybookBuild(path, hint = '先跑 `npm run build-storybook`') {
132
+ if (existsSync(path)) return
133
+ console.error(`✗ ${MISSING_BUILD_MARKER}:找不到 ${path} —— ${hint}(缺前置,不是產品裁決)`)
134
+ process.exit(2)
135
+ }
136
+
137
+ // ═══════════════════════════════════════════════════════════════════════════
138
+ // requireFreshStorybookBuild —— 「建置存在、而且不比被驗的原始碼舊」的**唯一**實作(2026-09-27,待辦總帳 C5)
139
+ // ═══════════════════════════════════════════════════════════════════════════
140
+ //
141
+ // **為什麼收成一支**:此前 16 支瀏覽器閘各自寫一份 stale-build 守衛,寫法五花八門 ——
142
+ // · 拿來比的建置檔不同(index.html / index.json / iframe.html 三種);
143
+ // · 有的印 STALE-BUILD exit 2(gate-selftest-meta 認得 → 略過),有的印自己的句子 exit 1(header-tabs-slot、
144
+ // storybook-smoke-test:meta-test 把「建置過時」讀成**閘紅**);
145
+ // · 兩支用 `existsSync(f) && …` 包住 —— 原始碼一改名,守衛就**靜靜關掉**(M37:「守衛存在」被當成「守衛在跑」);
146
+ // · 有的遞迴掃整棵 src、有的只列幾個檔、有的只掃一層目錄。
147
+ // 修一份其他十五份不會跟著好,正是 M17 禁止的平行實作。
148
+ //
149
+ // 判準(要保證的性質逐字):**建置目錄裡最早寫出的那個輸出檔,不早於任何一個被列出的原始碼(檔或整棵目錄下的 .ts/.tsx/.css)的最後修改時間。**
150
+ // · 建置時間取 iframe.html / index.html / index.json 三者中**最早**的 mtime(最保守:建置開始寫輸出之後改的原始碼一律算過時);
151
+ // · 三者都不存在 → MISSING-BUILD exit 2(缺前置);
152
+ // · 列出的原始碼**不存在 → exit 1、印明確訊息**(這是閘自己設定錯,不是缺前置也不是產品;不准用 existsSync 靜默放行);
153
+ // · 任一原始碼比建置新 → STALE-BUILD exit 2(缺前置;點名最新的那個檔)。
154
+ // 目錄遞迴時跳過 node_modules / dist。
155
+ //
156
+ // 用法:
157
+ // requireFreshStorybookBuild(STATIC, ['packages/design-system/src/components/DataTable/data-table.tsx'])
158
+ // requireFreshStorybookBuild(STATIC, ['packages/design-system/src', 'src'], { hint: '先跑 npm run build-storybook' })
159
+ // const { builtAtMs, newest } = requireFreshStorybookBuild(…) // 過了才回來;newest = { path, mtimeMs }
160
+ // 原始碼路徑相對於 repo 根(本檔的上上層),或絕對路徑。
161
+ const BUILD_OUTPUT_MARKERS = ['iframe.html', 'index.html', 'index.json']
162
+ const SOURCE_FILE_RE = /\.(tsx?|css)$/u
163
+ const SOURCE_SKIP_DIRS = new Set(['node_modules', 'dist'])
164
+ const REPO_ROOT_FOR_BUILD_CHECK = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
165
+
166
+ function newestSourceUnder(path, acc) {
167
+ const info = statSync(path)
168
+ if (info.isDirectory()) {
169
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
170
+ if (entry.isDirectory()) { if (!SOURCE_SKIP_DIRS.has(entry.name)) newestSourceUnder(join(path, entry.name), acc) }
171
+ else if (SOURCE_FILE_RE.test(entry.name)) { const m = statSync(join(path, entry.name)).mtimeMs; if (m > acc.mtimeMs) { acc.mtimeMs = m; acc.path = join(path, entry.name) } }
172
+ }
173
+ } else if (info.mtimeMs > acc.mtimeMs) { acc.mtimeMs = info.mtimeMs; acc.path = path }
174
+ }
175
+
176
+ /**
177
+ * @param {string} staticDir 建置目錄
178
+ * @param {string[]} sources 被驗的原始碼(檔或目錄;相對 repo 根或絕對路徑)—— 空陣列是 TypeError:沒列原始碼的「新鮮度」守衛守不了任何東西
179
+ * @param {{ hint?: string }} [options]
180
+ * @returns {{ builtAtMs: number, newest: { path: string, mtimeMs: number } }}
181
+ */
182
+ export function requireFreshStorybookBuild(staticDir, sources, { hint = '先跑 `npm run build-storybook`' } = {}) {
183
+ if (!Array.isArray(sources) || sources.length === 0) throw new TypeError('requireFreshStorybookBuild:sources 必須是非空陣列(要守哪些原始碼)')
184
+ const outputs = BUILD_OUTPUT_MARKERS.map((name) => join(staticDir, name)).filter((path) => existsSync(path))
185
+ if (outputs.length === 0) requireStorybookBuild(join(staticDir, 'index.json'), hint) // 不會回來(exit 2)
186
+ const builtAtMs = Math.min(...outputs.map((path) => statSync(path).mtimeMs))
187
+ const newest = { path: '', mtimeMs: -Infinity }
188
+ for (const source of sources) {
189
+ const path = isAbsolute(source) ? source : join(REPO_ROOT_FOR_BUILD_CHECK, source)
190
+ if (!existsSync(path)) {
191
+ console.error(`✗ stale-build 守衛設定錯:列出的原始碼不存在 ${source} —— 原始碼改名 / 搬家後守衛等於關掉,這是閘自己的錯(不是缺前置、不是產品裁決)`)
192
+ process.exit(1)
193
+ }
194
+ newestSourceUnder(path, newest)
195
+ }
196
+ if (newest.mtimeMs > builtAtMs) {
197
+ const rel = newest.path.startsWith(REPO_ROOT_FOR_BUILD_CHECK) ? newest.path.slice(REPO_ROOT_FOR_BUILD_CHECK.length + 1) : newest.path
198
+ console.error(`✗ ${STALE_BUILD_MARKER}:${rel} 比 ${staticDir} 新(原始碼 ${new Date(newest.mtimeMs).toISOString()} > 建置 ${new Date(builtAtMs).toISOString()})—— ${hint}(缺前置,不是產品裁決)`)
199
+ process.exit(2)
200
+ }
201
+ return { builtAtMs, newest }
202
+ }
203
+
204
+ // ═══════════════════════════════════════════════════════════════════════════
205
+ // gotoStory —— 2026-09-25 退役(待辦總帳 C5)
206
+ // ═══════════════════════════════════════════════════════════════════════════
207
+ //
208
+ // 2026-09-20 起它是「導覽 + 等某個元素 + 固定睡 settle 毫秒」,之後變成 openStory 的薄包裝(不驗渲染完成 / 健康)。
209
+ // 起因:`action-bar-toolbar-invariant.mjs` 用 `waitUntil:'load'` + 固定睡 900ms 當「已渲染」的代理,慢的 runner 上假紅,
210
+ // 還指控「示範沒有消費 DataToolbar」—— 拿當時剛好成立的觀察量(過了 900ms)代替要保證的性質(元素在畫面上),M37。
211
+ // 最後一個呼叫點是 story-demo-focus-invariant.mjs 的節流對照組(刻意重現那個代理),已改成直接呼叫 openStory +
212
+ // 顯式的固定睡眠。現在**全部**瀏覽器閘都走下方的 openStory;不要再加回「等元素 + 固定睡眠」的共用入口。
213
+
214
+ // ═══════════════════════════════════════════════════════════════════════════
215
+ // openStory —— 「這則 story 真的渲染完成了」的**唯一**實作(全部瀏覽器閘共用)
216
+ // ═══════════════════════════════════════════════════════════════════════════
217
+ //
218
+ // **為什麼有這個**(2026-09-25):同一天四支閘各自長出一份「等 story 真的畫完」——
219
+ // focus-geometry-browser-audit(loadStory)、overflow-indicator-containment(settleAndProbe)、
220
+ // field-size-regression-diff(shot 裡的四步等待)、field-view-typography-invariant(gotoStory + waitForFunction)。
221
+ // 四份判準各不相同(有的看 render phase、有的看 DOM 靜止幾毫秒、有的看影格、有的只看元素出現),
222
+ // 修一份其他三份不會跟著好 —— 正是 M17 禁止的平行實作。現在收成這一支,四支閘都呼叫它。
223
+ //
224
+ // 要保證的性質(M37,逐字):**被量的是「這則 story 在 Storybook 回報渲染完成(含 play / afterEach)之後」的畫面,
225
+ // 而且那個畫面不是 Storybook 的錯誤頁、不是空的、沒有關鍵資源缺檔、沒有頁面例外。**
226
+ // 任何一項等不到或不成立 → 丟 StoryRenderInstrumentError(儀器失效:沒量到,不是產品裁決),
227
+ // 訊息點名 story id、附 Storybook 錯誤頁原文與同源 404 / 失敗請求。**絕不回「成功」、也絕不靜默略過。**
228
+ //
229
+ // 依序:
230
+ // 1. 導覽前先掛好監聽(關鍵資源失敗、頁面例外、進行中請求)—— 事後才掛會漏掉最早的那一筆;只算**本次導覽之後發出**的請求
231
+ // (上一則 story 被導覽中斷的請求會晚到,不能算到這一則頭上);opts.notFound 可併入伺服器端的 404 帳本
232
+ // 2. goto(load);導覽本身失敗 → kind 'navigation'
233
+ // opts.previewFrame(管理介面)→ 等那個 iframe 元素、之後 3–9 步都在它的內容框架裡做;等不到 → kind 'preview-frame'
234
+ // 3. Storybook iframe(網址是 …/iframe.html,或 opts.storybook = true)且 requireRenderFinished:
235
+ // 等 `__STORYBOOK_PREVIEW__.currentRender` 是**這一則**(知道 id 時 render.id 必須相等)且 phase 走到 finished
236
+ // (或 opts.finishedPhases 允許的 play 之後的 phase);錯誤頁(sb-show-errordisplay)/ 無預覽頁(sb-show-nopreview)
237
+ // 也是終點 → 各自丟對應 kind。這一步的本體 awaitStorybookRender 也由 waitForStoryRender 輸出(不是導覽觸發的切換用)
238
+ // 4. render-health(lib/storybook-render-health.mjs):根節點有內容(或 portal)、無關鍵資源失敗、無頁面例外
239
+ // 5. requestsSettled:渲染期間發出的請求全部結束(遠端頭像圖等)
240
+ // 6. waitFor:選擇器(attached)或頁面函式(回 truthy 為止)—— 等**被量的元素本身**
241
+ // 7. beforeSettle(page):呼叫端在「靜止判定」之前要做的事(例:截圖前 blur)
242
+ // 8. fonts:document.fonts.ready(字寬會改幾何)
243
+ // 9. settleFrames:**連續 N 個影格**沒有任何 DOM 變動、也沒有進行中的有限長度動畫(無限動畫不算,它永遠不停)。
244
+ // 用影格不用毫秒:元件「量寬 → setState → 重畫」是一格一格推進的,機器慢只是等久一點,不會提早取樣。
245
+ // probe(可選):在最後一個靜止影格的**同一個 task** 裡執行,回傳前不讓頁面再跑任何東西
246
+ // 10. 有做 5–9 任一步時再驗一次 render-health(等待期間冒出的例外也要算)
247
+ // 逾時(timeoutMs / settleTimeoutMs / navigationTimeoutMs)只是「等不到」的上限,不是「已渲染」的代理 ——
248
+ // 成功一律由上面的訊號本身決定;慢的機器只會等久一點。
249
+ //
250
+ // 用法:
251
+ // const r = await openStory(page, url) // 渲染完成 + 健康 + 字型
252
+ // const r = await openStory(page, url, { settleFrames: 10, probe: countPlus }) // r.probe = 靜止當下的量測
253
+ // try { await openStory(page, url, { waitFor: '[data-x]' }) }
254
+ // catch (e) { if (e instanceof StoryRenderInstrumentError) … e.kind / e.storyId / e.detail … }
255
+ // 回傳 { storyId, storybook, phase, ms, settle: { framesWaited, lateChanges, longestBrokenQuiet } | null, probe, frame }
256
+ //
257
+ // requireRenderFinished 只適用 viewMode=story(docs 頁沒有同一套 render phase);非 Storybook 頁會略過第 3 步。
258
+ //
259
+ // **管理介面(index.html)裡的 story**(2026-09-25 加,取代 data-table-hover-latency 的私有副本):
260
+ // openStory(page, `${origin}/index.html?path=/story/${id}`, { previewFrame: '#storybook-preview-iframe', waitFor: '[data-x]' })
261
+ // 導覽的是管理介面,第 3–9 步(渲染完成 / render-health / waitFor / 字型 / 靜止 / probe)改在那個 iframe 的內容框架裡做;
262
+ // story id 取 opts.storyId,沒給就從網址的 path=/story/<id> 取(兩者都沒有 → TypeError:「有某一則渲染完成」≠「這一則」)。
263
+ // settleFrames 在這個模式下**同時**看 preview 與外層管理介面兩份文件(外掛面板收到結果後的重畫、iframe 位置的位移也要停)。
264
+ // 回傳的 frame = 量測所在的 Frame(呼叫端之後的量測用它)。beforeSettle 仍收到最外層 page。
265
+ // **不是導覽觸發的切換**(管理介面裡點側欄換 story:Storybook channel 驅動,沒有網址可以交給 openStory)→ 用下方的 waitForStoryRender。
266
+ //
267
+ // **哪些 phase 算「渲染完成」**(opts.finishedPhases,預設 ['finished']):Storybook 8.6 的 phase 依序是
268
+ // preparing → loading → rendering → playing → played → completed → afterEach → finished(@storybook/core preview-api StoryRender.render)。
269
+ // 只准額外接受 play 跑完之後才會走到的 'completed' / 'afterEach' —— 給「afterEach 靠計時器推進、在假時鐘暫停下永遠走不到 finished」的閘用
270
+ // (menu-message-row:a11y addon 在 afterEach 跑 axe)。更早的 phase 代表 story 或 play 還沒跑完,傳進來一律 TypeError。
271
+
272
+ /** openStory 等不到或判不健康時丟的例外:**儀器失效(沒量到),不是產品裁決**。 */
273
+ export class StoryRenderInstrumentError extends Error {
274
+ constructor({ storyId, url = '', kind, reason, storybookError = '', failedRequests = [], phase = null, cause } = {}) {
275
+ const parts = [reason]
276
+ if (storybookError) parts.push(`Storybook 錯誤:${storybookError}`)
277
+ if (failedRequests.length) parts.push(`同源 404 / 載入失敗:${failedRequests.join(', ')}`)
278
+ const detail = parts.join(';')
279
+ super(`${INSTRUMENT_FAIL_MARKER} story「${storyId}」沒有量到 —— ${detail}。這是儀器失效(沒量到),不是產品裁決:元件不一定有問題,但這次不能算通過`,
280
+ cause ? { cause } : undefined)
281
+ this.name = 'StoryRenderInstrumentError'
282
+ this.storyId = storyId
283
+ this.url = url
284
+ /** navigation | preview-frame | render-timeout | render-wait-failed | storybook-error | no-preview | render-errored | not-story | render-health | requests-timeout | wait-for-timeout | dom-not-settled */
285
+ this.kind = kind
286
+ this.reason = reason
287
+ /** 不含 story id 與「不是產品裁決」那句的原因全文(呼叫端自己點名 story 時用) */
288
+ this.detail = detail
289
+ this.storybookError = storybookError
290
+ this.failedRequests = failedRequests
291
+ this.phase = phase
292
+ }
293
+ }
294
+
295
+ const firstLine = (value) => String(value?.message ?? value).split('\n')[0]
296
+
297
+ function storyIdOf(url) {
298
+ try { return new URL(url).searchParams.get('id') } catch { return null }
299
+ }
300
+
301
+ function isStorybookIframe(url) {
302
+ try { return /\/iframe\.html$/.test(new URL(url).pathname) } catch { return false }
303
+ }
304
+
305
+ /** 管理介面網址(index.html?path=/story/<id>)裡的 story id;不是這種網址回 null。 */
306
+ function managerStoryIdOf(url) {
307
+ try { return new URL(url).searchParams.get('path')?.match(/^\/story\/([^/?#]+)$/)?.[1] ?? null } catch { return null }
308
+ }
309
+
310
+ // play 跑完之後才會走到的 phase(見 openStory 區塊註解);'finished' 恆算完成
311
+ const FINISHED_PHASE_CHOICES = ['completed', 'afterEach', 'finished']
312
+ function normalizeFinishedPhases(finishedPhases) {
313
+ if (!Array.isArray(finishedPhases) || finishedPhases.length === 0 || finishedPhases.some((phase) => !FINISHED_PHASE_CHOICES.includes(phase))) {
314
+ throw new TypeError(`finishedPhases 只接受 ${FINISHED_PHASE_CHOICES.join(' / ')} 組成的非空陣列(play 跑完之後才會走到的 phase),實得 ${JSON.stringify(finishedPhases)} —— 更早的 phase 代表 story 或 play 還沒跑完,不能當「渲染完成」`)
315
+ }
316
+ return [...new Set([...finishedPhases, 'finished'])]
317
+ }
318
+
319
+ // 頁面端:這一則 story 的渲染走到終點了沒(waitForFunction 用;回 false = 還沒)
320
+ function storybookRenderState({ storyId, finished }) {
321
+ const cls = document.body?.classList
322
+ if (cls?.contains('sb-show-errordisplay')) return { state: 'error-display' }
323
+ if (cls?.contains('sb-show-nopreview')) return { state: 'no-preview' }
324
+ const render = window.__STORYBOOK_PREVIEW__?.currentRender
325
+ if (!render || (storyId && render.id !== storyId)) return false
326
+ if (finished.includes(render.phase) || render.phase === 'errored' || render.phase === 'aborted') {
327
+ return { state: render.phase, main: Boolean(cls?.contains('sb-show-main')), bodyClass: document.body.className }
328
+ }
329
+ return false
330
+ }
331
+
332
+ // 頁面端:等不到終點時,最後看到的是什麼(寫進訊息,讓「等不到」說得出停在哪)
333
+ function storybookLastSeen() {
334
+ const render = window.__STORYBOOK_PREVIEW__?.currentRender
335
+ if (!window.__STORYBOOK_PREVIEW__) return '頁面上沒有 Storybook 預覽(__STORYBOOK_PREVIEW__ 不存在:預覽腳本沒載起來)'
336
+ if (!render) return '預覽已起來但沒有 currentRender(story 還沒開始渲染)'
337
+ return `currentRender = ${render.id ?? '(無 id)'} / ${render.phase ?? '(無 phase)'};body class = ${document.body.className || '(無)'}`
338
+ }
339
+
340
+ // 頁面端:Storybook 錯誤頁的原文
341
+ function storybookErrorText() {
342
+ const message = (document.getElementById('error-message')?.textContent || '').trim()
343
+ const stack = (document.getElementById('error-stack')?.textContent || '').trim().split('\n')[0]
344
+ return [message, stack && !message.includes(stack) ? stack : ''].filter(Boolean).join(' | ').slice(0, 300)
345
+ }
346
+
347
+ // 頁面端:連續 frames 個影格沒有 DOM 變動、也沒有進行中的有限長度動畫。**以 page.evaluate 序列化傳入,不得引用外部變數。**
348
+ // includeParent(previewFrame 模式):同時看外層的管理介面文件 —— 外掛面板收到結果後的重畫、版面位移(iframe 位置)也要停,
349
+ // 兩份文件在**同一段**連續影格裡都沒動才算靜止;讀不到外層文件(跨來源)回 parentUnreadable,不得當成「外層沒動」。
350
+ function waitForQuietFrames({ frames, capMs, includeParent = false }) {
351
+ const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()))
352
+ const docs = [document]
353
+ if (includeParent && window.parent !== window) {
354
+ try { docs.push(window.parent.document) } catch { return Promise.resolve({ ok: false, parentUnreadable: true, framesWaited: 0, lateChanges: 0, longestBrokenQuiet: 0 }) }
355
+ }
356
+ // 無限動畫(載入中轉圈)不算「還在動」—— 它永遠不會停,而且不改版面
357
+ const finiteAnimationRunning = () => docs.some((doc) => doc.getAnimations().some((animation) => {
358
+ if (animation.playState !== 'running') return false
359
+ const end = animation.effect?.getComputedTiming?.().endTime
360
+ return Number.isFinite(end)
361
+ }))
362
+ return (async () => {
363
+ let quiet = 0, longestBrokenQuiet = 0, lateChanges = 0, framesWaited = 0
364
+ if (frames <= 0) return { ok: true, framesWaited, lateChanges, longestBrokenQuiet }
365
+ const reset = () => { if (quiet > longestBrokenQuiet) longestBrokenQuiet = quiet; quiet = 0; lateChanges++ }
366
+ const observer = new MutationObserver(reset)
367
+ for (const doc of docs) observer.observe(doc.documentElement, { subtree: true, childList: true, characterData: true, attributes: true })
368
+ const start = performance.now()
369
+ try {
370
+ while (quiet < frames) {
371
+ if (performance.now() - start > capMs) return { ok: false, framesWaited, lateChanges, longestBrokenQuiet }
372
+ await frame()
373
+ framesWaited++
374
+ if (finiteAnimationRunning()) reset(); else quiet++
375
+ }
376
+ } finally { observer.disconnect() }
377
+ return { ok: true, framesWaited, lateChanges, longestBrokenQuiet }
378
+ })()
379
+ }
380
+
381
+ /**
382
+ * **互動之後**等「版面真的停了」(2026-09-25,待辦總帳 C5)—— 與 openStory 第 9 步同一份靜止判定(waitForQuietFrames:
383
+ * 連續 frames 個影格沒有任何 DOM 變動、也沒有進行中的有限長度動畫 / CSS 過渡)。
384
+ *
385
+ * 取代「點開浮層後固定睡 N 毫秒」:那是「浮層已開、開啟動畫已停」的代理(M37)。慢的機器上固定睡眠會提早量,
386
+ * 把「還沒開」讀成「這裡沒有浮層 / 不適用」—— 覆蓋率在沒有任何訊號的情況下縮水(overlay-footer-gutter 的點開下拉、
387
+ * dialog-height H5 的點開 dialog、focus-indicator 的點開檢視器都是這個形狀)。
388
+ * 用影格不用毫秒:點擊觸發的 React 更新與開啟動畫是一格一格推進的,主執行緒忙時影格也不跑,所以機器慢只會等久一點;
389
+ * 點擊若什麼都沒觸發,版面本來就靜止,等滿 frames 格就回來 —— 「這一下沒開出任何東西」因此也是可靠的結論,不是提早放棄。
390
+ * 限制:靠計時器延後才開的東西(例:有 openDelay 的 HoverCard / Tooltip)不在這個保證裡,那種要等元素本身。
391
+ * @param {object} target Playwright 的 Page 或 Frame
392
+ * @param {{ frames?: number, capMs?: number }} [options] capMs 只是「等不到」的天花板
393
+ * @returns {Promise<{ ok: boolean, framesWaited: number, lateChanges: number, longestBrokenQuiet: number }>}
394
+ * ok:false = capMs 內等不到靜止 —— 呼叫端必須當**儀器失效**,不得當成「沒開」也不得當成「已開」
395
+ */
396
+ export async function settleAfterInteraction(target, { frames = 10, capMs = 10_000 } = {}) {
397
+ if (!target || typeof target.evaluate !== 'function') throw new TypeError('settleAfterInteraction:target 必須是 Playwright 的 Page 或 Frame')
398
+ if (!Number.isInteger(frames) || frames < 1) throw new TypeError(`settleAfterInteraction:frames 必須是 ≥ 1 的整數,實得 ${JSON.stringify(frames)}`)
399
+ return target.evaluate(waitForQuietFrames, { frames, capMs })
400
+ }
401
+
402
+ // 頁面端:焦點連續 frames 個影格落在同一個元素上。**以 page.evaluate 序列化傳入,不得引用外部變數。**
403
+ // 焦點搬家不改 DOM(`.focus()` 不產生 mutation record),所以 waitForQuietFrames 看不到它 —— 這裡逐影格讀 activeElement。
404
+ function waitForStableFocus({ frames, capMs }) {
405
+ const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()))
406
+ const describe = (el) => {
407
+ if (!el || el === document.body || el === document.documentElement) return 'BODY'
408
+ const role = el.getAttribute('role')
409
+ const name = el.getAttribute('aria-label') || (el.textContent || '').trim().slice(0, 24)
410
+ return `${el.tagName.toLowerCase()}${role ? `[${role}]` : ''}${el.id ? `#${el.id}` : ''}「${name}」`
411
+ }
412
+ return (async () => {
413
+ let quiet = 0, changes = 0, framesWaited = 0
414
+ let last = document.activeElement
415
+ const start = performance.now()
416
+ while (quiet < frames) {
417
+ if (performance.now() - start > capMs) return { ok: false, framesWaited, changes, active: describe(last) }
418
+ await frame()
419
+ framesWaited++
420
+ const now = document.activeElement
421
+ if (now !== last) { changes++; quiet = 0; last = now } else quiet++
422
+ }
423
+ return { ok: true, framesWaited, changes, active: describe(last) }
424
+ })()
425
+ }
426
+
427
+ /**
428
+ * **負向主張的觀測窗**(2026-09-27,M37):「焦點沒被搶回 / 沒被搬走」這種主張不能靠固定睡 N 毫秒再讀一次 ——
429
+ * 睡得比搶焦點的那一步短就假綠(慢的機器上關閉動畫還沒跑完、Radix FocusScope 卸載後的 setTimeout 0 還沒到),
430
+ * 睡得長只是把同一個賭注押大一點。改成:先等**該事件的證據**(浮層卸載、版面靜止 —— settleAfterInteraction),
431
+ * 再用這支證明「相反證據穩定 N 個影格」:焦點連續 frames 個影格都落在同一個元素上,之後呼叫端才讀落點。
432
+ * 焦點搬家不改 DOM,settleAfterInteraction 看不到它,所以要另外逐影格讀 activeElement。
433
+ * 用影格不用毫秒:主執行緒忙時影格也不跑,機器慢只會等久一點;搶焦點是同步或下一個 macrotask 的事,一格之內就看得到。
434
+ * @param {object} target Playwright 的 Page 或 Frame
435
+ * @param {{ frames?: number, capMs?: number }} [options] capMs 只是「等不到」的天花板
436
+ * @returns {Promise<{ ok: boolean, framesWaited: number, changes: number, active: string }>}
437
+ * ok:false = capMs 內焦點一直在跳 —— 呼叫端必須當**儀器失效**,不得當成「沒被搶」也不得當成「被搶了」;
438
+ * changes = 觀測期間焦點換了幾次(呼叫端可用它說明「搶焦點發生在等待期間」);active = 最後落點的描述(只供訊息)
439
+ */
440
+ export async function waitForFocusStable(target, { frames = 10, capMs = 10_000 } = {}) {
441
+ if (!target || typeof target.evaluate !== 'function') throw new TypeError('waitForFocusStable:target 必須是 Playwright 的 Page 或 Frame')
442
+ if (!Number.isInteger(frames) || frames < 1) throw new TypeError(`waitForFocusStable:frames 必須是 ≥ 1 的整數,實得 ${JSON.stringify(frames)}`)
443
+ return target.evaluate(waitForStableFocus, { frames, capMs })
444
+ }
445
+
446
+ // 頁面端:docs 頁渲染到哪了(waitForDocsRender 用;回 false = 還沒)。**以 waitForFunction 序列化傳入,不得引用外部變數。**
447
+ function docsRenderState({ docsId }) {
448
+ const cls = document.body?.classList
449
+ if (cls?.contains('sb-show-errordisplay')) return { state: 'error-display' }
450
+ if (cls?.contains('sb-show-nopreview')) return { state: 'no-preview' }
451
+ const render = window.__STORYBOOK_PREVIEW__?.currentRender
452
+ if (!render || render.type !== 'docs' || (docsId && render.id !== docsId)) return false
453
+ if (typeof render.isPreparing === 'function' && render.isPreparing()) return false
454
+ const docs = document.getElementById('storybook-docs')
455
+ // 被 Storybook 藏起來的 docs 容器(切去看 story 時的 `hidden`)有內容也不算:那正是 docs 競態閘要抓的殭屍
456
+ if (!docs || docs.hasAttribute('hidden') || docs.childElementCount === 0) return false
457
+ return { state: 'rendered', id: render.id }
458
+ }
459
+
460
+ /**
461
+ * **docs 頁**(viewMode=docs,或管理介面裡點開元件節點)真的渲染出來了 —— 全部閘共用的唯一判定(2026-09-25,待辦總帳 C5;
462
+ * 取代 storybook-docs-race-invariant 的私有輪詢與 verify-published-deploy 的 DOCS_SETTLED,M17)。
463
+ * docs 頁沒有 story 那一套 render phase(`@storybook/core` preview-api 的 CsfDocsRender 只有 preparing 旗標、渲染完 emit DOCS_RENDERED),
464
+ * 所以判定是:Storybook 的 currentRender 是 **docs**(給 docsId 時必須是這一則)、不在 preparing、`#storybook-docs` 沒被藏起來而且有內容;
465
+ * 錯誤頁 / 無預覽頁當場停。可選 settleFrames:之後再等版面連續 N 個影格靜止(docs 裡的 story 是陸續畫出來的)。
466
+ * 等不到 / 錯誤頁 / 無預覽 / 不靜止 → 丟 StoryRenderInstrumentError(儀器失效,不是產品裁決)。
467
+ * @param {object} target Playwright 的 Page 或 Frame(管理介面 → preview iframe 的內容框架)
468
+ * @param {{ docsId?: string, timeoutMs?: number, settleFrames?: number, label?: string, url?: string, notFound?: string[] }} [options]
469
+ * @returns {Promise<{ docsId: string, ms: number }>}
470
+ */
471
+ export async function waitForDocsRender(target, options = {}) {
472
+ const { docsId = null, timeoutMs = 30_000, settleFrames = 0, url = '', notFound = null } = options
473
+ if (!target || typeof target.waitForFunction !== 'function' || typeof target.evaluate !== 'function') {
474
+ throw new TypeError('waitForDocsRender:target 必須是 Playwright 的 Page 或 Frame')
475
+ }
476
+ const label = options.label ?? docsId ?? '(docs 頁)'
477
+ const from = Array.isArray(notFound) ? notFound.length : 0
478
+ const t0 = Date.now()
479
+ const instrument = (kind, reason, extra = {}) => new StoryRenderInstrumentError({
480
+ storyId: label, url, kind, reason, ...extra,
481
+ failedRequests: Array.isArray(notFound) ? [...new Set(notFound.slice(from))].map((path) => `404 ${path}`).slice(0, 8) : [],
482
+ })
483
+ let done = null
484
+ try {
485
+ const handle = await target.waitForFunction(docsRenderState, { docsId }, { timeout: timeoutMs, polling: 'raf' })
486
+ done = await handle.jsonValue()
487
+ } catch (error) {
488
+ if (error?.name !== 'TimeoutError') throw instrument('render-wait-failed', `等 docs 頁渲染時等待本身中斷:${firstLine(error)}`, { cause: error })
489
+ const lastSeen = await target.evaluate(() => {
490
+ const render = window.__STORYBOOK_PREVIEW__?.currentRender
491
+ const docs = document.getElementById('storybook-docs')
492
+ return `currentRender = ${render ? `${render.type ?? '?'} / ${render.id ?? '(無 id)'}` : '(無)'};#storybook-docs ${docs ? `${docs.hasAttribute('hidden') ? 'hidden、' : ''}子節點 ${docs.childElementCount}` : '(不存在)'}`
493
+ }).catch(() => '讀不到頁面狀態')
494
+ throw instrument('render-timeout', `${timeoutMs / 1000} 秒內等不到 docs 頁渲染出來(最後看到:${lastSeen})`)
495
+ }
496
+ if (done.state === 'error-display') {
497
+ throw instrument('storybook-error', 'Storybook 顯示錯誤頁(docs 渲染拋錯或模組載入失敗)', { storybookError: await target.evaluate(storybookErrorText).catch(() => '') })
498
+ }
499
+ if (done.state === 'no-preview') throw instrument('no-preview', 'Storybook 顯示「無預覽」頁(索引裡沒有這一則 docs)')
500
+ if (settleFrames > 0) {
501
+ const settled = await target.evaluate(waitForQuietFrames, { frames: settleFrames, capMs: timeoutMs })
502
+ if (!settled.ok) throw instrument('dom-not-settled', `docs 頁渲染出來後 ${timeoutMs / 1000} 秒內版面沒有連續靜止 ${settleFrames} 個影格(等了 ${settled.framesWaited} 格,期間變動 ${settled.lateChanges} 次)`)
503
+ }
504
+ return { docsId: done.id, ms: Date.now() - t0 }
505
+ }
506
+
507
+ /**
508
+ * 第 3 步本體(openStory 與 waitForStoryRender 共用,**唯一實作**):在 target(Page 或 Frame)裡等 Storybook 回報**這一則**
509
+ * 渲染走到 finished(或 finishedPhases 允許的 play 之後的 phase),錯誤頁 / 無預覽頁 / errored / 不是 story 畫面各自丟對應 kind。
510
+ * target 在等待期間重新載入(管理介面的 preview 離開渲染完整的 docs 頁時會整個 reload)不影響:Playwright 的 waitForFunction
511
+ * 會在新的執行環境裡續等(playwright-core frames.js retryWithProgressAndTimeouts);輪詢用的是 Playwright 自己保存的 rAF,假時鐘暫停也照跑。
512
+ * @returns {Promise<string>} 終點的 phase
513
+ */
514
+ async function awaitStorybookRender(target, storyId, { timeoutMs, finished, instrument }) {
515
+ const deadline = Date.now() + timeoutMs
516
+ let done = null
517
+ for (;;) {
518
+ try {
519
+ const handle = await target.waitForFunction(storybookRenderState, { storyId, finished }, { timeout: Math.max(1, deadline - Date.now()), polling: 'raf' })
520
+ done = await handle.jsonValue()
521
+ break
522
+ } catch (error) {
523
+ if (error?.name === 'TimeoutError') break
524
+ // 拿結果的那一瞬間 preview 剛好重新載入(執行環境被換掉)→ 還有時間就續等同一個訊號;其他錯(頁面關了、frame 被拆掉)= 儀器失效
525
+ if (/Execution context was destroyed|Cannot find context/i.test(String(error?.message)) && Date.now() < deadline) continue
526
+ throw instrument('render-wait-failed', `等 Storybook 回報渲染完成時等待本身中斷:${firstLine(error)}`, { cause: error })
527
+ }
528
+ }
529
+ if (!done) {
530
+ const lastSeen = await target.evaluate(storybookLastSeen).catch(() => '讀不到頁面狀態')
531
+ throw instrument('render-timeout', `${timeoutMs / 1000} 秒內等不到 Storybook 回報這則 story 渲染完成(最後看到:${lastSeen})`)
532
+ }
533
+ const phase = done.state
534
+ if (phase === 'error-display') {
535
+ throw instrument('storybook-error', 'Storybook 顯示錯誤頁(story 不存在、模組載入失敗或渲染拋錯)',
536
+ { storybookError: await target.evaluate(storybookErrorText).catch(() => ''), phase })
537
+ }
538
+ if (phase === 'no-preview') {
539
+ throw instrument('no-preview', 'Storybook 顯示「無預覽」頁(索引裡沒有這則 story,或沒有任何 story 被選到)', { phase })
540
+ }
541
+ if (!finished.includes(phase)) {
542
+ throw instrument('render-errored', `Storybook 回報渲染結果 = ${phase}`,
543
+ { storybookError: await target.evaluate(storybookErrorText).catch(() => ''), phase })
544
+ }
545
+ if (!done.main) throw instrument('not-story', `渲染完成但畫面不是 story(body class:${done.bodyClass || '(無)'})`, { phase })
546
+ return phase
547
+ }
548
+
549
+ /**
550
+ * **不是由導覽觸發的** story 切換用的「這一則真的渲染完成了」—— 與 openStory 第 3 步同一份實作(awaitStorybookRender)。
551
+ * 例:管理介面裡點側欄換 story(Storybook channel 驅動,preview iframe 不一定重新導覽,沒有網址可以交給 openStory)。
552
+ * 等不到 / 錯誤頁 / 無預覽頁 / errored → 丟 StoryRenderInstrumentError(儀器失效,不是產品裁決),附伺服器 404 帳本裡本次新增的路徑。
553
+ * 只做第 3 步:要 render-health / waitFor / 靜止的,請用 openStory(導覽觸發,含 previewFrame 模式)。
554
+ * @param {object} target Playwright Page 或 Frame(例:(await page.$('#storybook-preview-iframe')).contentFrame())
555
+ * @param {object} options
556
+ * @param {string} options.storyId 必填 ——「有某一則渲染完成」不等於「這一則渲染完成」(M37)
557
+ * @param {string} [options.label] 訊息裡點名用(預設 = storyId)
558
+ * @param {string} [options.url] 寫進例外的網址(僅供訊息)
559
+ * @param {number} [options.timeoutMs] 等不到的上限(預設 30000;不是「已渲染」的代理)
560
+ * @param {string[]} [options.finishedPhases] 見 openStory(預設 ['finished'])
561
+ * @param {string[]} [options.notFound] 伺服器端 404 帳本(startA11yStaticServer().notFound)
562
+ * @param {number} [options.notFoundFrom] 帳本從第幾筆起算(預設 = 呼叫當下的長度;切換前先記下長度再傳,切換途中的 404 才算得到)
563
+ * @returns {Promise<{ storyId: string, phase: string, ms: number }>}
564
+ */
565
+ export async function waitForStoryRender(target, options = {}) {
566
+ const { storyId, url = '', timeoutMs = 30_000, finishedPhases = ['finished'], notFound = null } = options
567
+ if (!target || typeof target.waitForFunction !== 'function' || typeof target.evaluate !== 'function') {
568
+ throw new TypeError('waitForStoryRender:target 必須是 Playwright 的 Page 或 Frame')
569
+ }
570
+ if (typeof storyId !== 'string' || !storyId) {
571
+ throw new TypeError('waitForStoryRender:storyId 必填 ——「有某一則渲染完成」不等於「這一則渲染完成」')
572
+ }
573
+ const finished = normalizeFinishedPhases(finishedPhases)
574
+ const label = options.label ?? storyId
575
+ const from = Number.isInteger(options.notFoundFrom) ? options.notFoundFrom : (Array.isArray(notFound) ? notFound.length : 0)
576
+ const t0 = Date.now()
577
+ const instrument = (kind, reason, extra = {}) => new StoryRenderInstrumentError({
578
+ storyId: label, url, kind, reason, ...extra,
579
+ failedRequests: Array.isArray(notFound) ? [...new Set(notFound.slice(from))].map((path) => `404 ${path}`).slice(0, 8) : [],
580
+ })
581
+ const phase = await awaitStorybookRender(target, storyId, { timeoutMs, finished, instrument })
582
+ return { storyId: label, phase, ms: Date.now() - t0 }
583
+ }
584
+
585
+ /**
586
+ * 開一則 story,證明它真的渲染完成才回傳;證明不了就丟 StoryRenderInstrumentError。語意見上方區塊註解。
587
+ * @param {object} page Playwright Page
588
+ * @param {string} url
589
+ * @param {object} [options]
590
+ * @param {string|Function|null} [options.waitFor] 選擇器(attached)或頁面函式(回 truthy 為止)
591
+ * @param {*} [options.waitForArg] waitFor 是函式時傳給它的參數
592
+ * @param {number|'raf'} [options.waitForPolling] waitFor 是函式時的輪詢方式(預設 'raf')
593
+ * @param {number} [options.settleFrames] 連續幾個影格無 DOM 變動才算靜止(0 = 不等)
594
+ * @param {Function} [options.probe] 在靜止當下同一個 task 裡執行的頁面函式(箭頭函式或 function 運算式)
595
+ * @param {*} [options.probeArg] 傳給 probe 的參數(須可 JSON 序列化)
596
+ * @param {boolean} [options.requireRenderFinished] Storybook iframe 時等 render phase 走到終點(預設 true)
597
+ * @param {boolean} [options.health] 跑 render-health(預設 true)
598
+ * @param {boolean} [options.fonts] 等 document.fonts.ready(預設 true)
599
+ * @param {boolean} [options.requestsSettled] 等渲染期間發出的請求全部結束(預設 false)
600
+ * @param {Function} [options.beforeSettle] async (page) => {},在字型 / 靜止判定之前執行
601
+ * @param {boolean} [options.storybook] 覆寫「這是不是 Storybook iframe」(預設看網址是否為 …/iframe.html;有 previewFrame 時預設 true)
602
+ * @param {string[]} [options.finishedPhases] 哪些 phase 算渲染完成(預設 ['finished'];只准加 'completed' / 'afterEach',見區塊註解)
603
+ * @param {string} [options.previewFrame] 導覽的是管理介面時,preview iframe 的選擇器(例 '#storybook-preview-iframe');第 3–9 步改在它的內容框架裡做
604
+ * @param {string} [options.storyId] 第 3 步要等的 story id(預設 = 網址的 id 參數;previewFrame 模式再退到 path=/story/<id>)
605
+ * @param {string[]} [options.notFound] 伺服器端 404 帳本(startA11yStaticServer().notFound);本次新增的列進失敗訊息
606
+ * @param {string} [options.label] 訊息裡點名用(預設 = story id,沒有則用網址)
607
+ * @param {number} [options.timeoutMs] 等渲染完成 / waitFor / 請求結束各自的上限(預設 30000)
608
+ * @param {number} [options.navigationTimeoutMs] goto 的上限(預設 60000)
609
+ * @param {number} [options.settleTimeoutMs] 等靜止的上限(預設 10000)
610
+ * @param {number} [options.healthTimeoutMs] render-health 等根節點有內容的上限(預設 5000;previewFrame 模式 30000,理由見解構處)
611
+ */
612
+ export async function openStory(page, url, options = {}) {
613
+ const {
614
+ waitFor = null, waitForArg, waitForPolling = 'raf',
615
+ settleFrames = 0, probe = null, probeArg = null,
616
+ requireRenderFinished = true, health = true, fonts = true, requestsSettled = false,
617
+ beforeSettle = null, previewFrame = null, storybook = previewFrame ? true : isStorybookIframe(url), notFound = null,
618
+ timeoutMs = 30_000, navigationTimeoutMs = 60_000, settleTimeoutMs = 10_000,
619
+ // previewFrame 模式預設 30 秒(render-health 的上限):管理介面與 preview 同源、共用同一條主執行緒,story finished 之後
620
+ // 管理介面自己的外掛面板重畫會把它塞住好幾秒 —— 2026-09-25 實測 CPU ×6 下 story 已 finished、根節點有內容,
621
+ // 5 秒上限卻因為檢查本身排不到執行而被判成「empty story root」(誤紅);30 秒下同一趟通過、之後 0 次版面變動。
622
+ // 上限只是「等不到」的天花板,成功仍由訊號本身決定,放寬不會造成假綠。
623
+ healthTimeoutMs = previewFrame ? 30_000 : 5_000,
624
+ } = options
625
+ // 呼叫端寫錯選項 = 程式錯誤,導覽前就丟(不是儀器失效,也不能默默退回預設)
626
+ const finished = normalizeFinishedPhases(options.finishedPhases ?? ['finished'])
627
+ if (previewFrame != null && (typeof previewFrame !== 'string' || !previewFrame)) throw new TypeError('openStory:previewFrame 必須是 preview iframe 的選擇器字串')
628
+ const storyId = options.storyId ?? storyIdOf(url) ?? (previewFrame ? managerStoryIdOf(url) : null)
629
+ if (previewFrame && storybook && requireRenderFinished && !storyId) {
630
+ throw new TypeError('openStory:previewFrame 模式要知道等哪一則(opts.storyId 或網址 path=/story/<id>)——「有某一則渲染完成」不等於「這一則渲染完成」')
631
+ }
632
+ const label = options.label ?? storyId ?? String(url).slice(0, 120)
633
+ const t0 = Date.now()
634
+ // 第 3–9 步的量測對象:一般是 page 本身;previewFrame 模式在導覽後換成 preview iframe 的內容框架
635
+ let target = page
636
+
637
+ // 1. 導覽前掛監聽
638
+ const origin = (() => { try { return new URL(url).origin } catch { return null } })()
639
+ const failed = []
640
+ const describe = (requestUrl) => {
641
+ try { const u = new URL(requestUrl); return u.origin === origin ? u.pathname : requestUrl.slice(0, 160) } catch { return String(requestUrl).slice(0, 160) }
642
+ }
643
+ // 只記**新文件發出**的請求(加上導覽請求本身):上一則 story 的文件在新文件 commit 之前還活著、還會發請求,
644
+ // 那些請求隨即被導覽中斷(net::ERR_ABORTED)並在這之後才回報 —— 不是這一則的問題。以主框架 commit(framenavigated)
645
+ // 為界,而不是「監聽掛上之後」:掛上監聽到 commit 之間那一段,舊文件照樣在發請求(field-view 對照組 B 實際踩到)。
646
+ const seen = new Set()
647
+ const inflight = new Set()
648
+ let committed = false
649
+ const onFrameNavigated = (frame) => { if (frame === page.mainFrame()) committed = true }
650
+ const onRequest = (request) => {
651
+ if (!committed && !request.isNavigationRequest()) return
652
+ seen.add(request)
653
+ if (requestsSettled) inflight.add(request)
654
+ }
655
+ const onResponse = (response) => {
656
+ if (response.status() >= 400 && seen.has(response.request())) failed.push(`${response.status()} ${describe(response.url())}`)
657
+ }
658
+ const onRequestFailed = (request) => {
659
+ inflight.delete(request)
660
+ if (seen.has(request)) failed.push(`${describe(request.url())}(${request.failure()?.errorText ?? '失敗'})`)
661
+ }
662
+ const onRequestFinished = (request) => inflight.delete(request)
663
+ page.on('framenavigated', onFrameNavigated)
664
+ page.on('request', onRequest)
665
+ page.on('response', onResponse)
666
+ page.on('requestfailed', onRequestFailed)
667
+ page.on('requestfinished', onRequestFinished)
668
+ // 伺服器端的 404 帳本(startA11yStaticServer().notFound)是最確定的來源:瀏覽器事件可能晚到,伺服器記的不會
669
+ const ledgerStart = Array.isArray(notFound) ? notFound.length : 0
670
+ // render-health 監聽的是整個 page:上一則 story 被本次導覽中斷的 script 請求(net::ERR_ABORTED)會在這之後才回報,
671
+ // 被它算成「這一則的關鍵資源失敗」而誤判儀器失效(test-open-story.mjs 的「晚到的 story 檔」與「上一則還在發請求」兩題實際踩到)。
672
+ // 不改 render-health 本身(其他閘也用它),只給它一個「只轉送新文件發出的請求」的 page 視圖(同上方 seen 的界線)。
673
+ const scoped = new Map()
674
+ const currentOnly = (event, handler) => (payload) => {
675
+ if (event === 'requestfailed' && !seen.has(payload)) return
676
+ if (event === 'response' && !seen.has(payload.request())) return
677
+ handler(payload)
678
+ }
679
+ // 事件(請求 / 例外)聽的是整個 page(含 preview iframe 發的請求);檢查畫面的 evaluate / waitForFunction 走量測對象(target)
680
+ const scopedPage = {
681
+ on(event, handler) { const wrapped = currentOnly(event, handler); scoped.set(handler, wrapped); page.on(event, wrapped) },
682
+ off(event, handler) { page.off(event, scoped.get(handler) ?? handler); scoped.delete(handler) },
683
+ evaluate: (...args) => target.evaluate(...args),
684
+ waitForFunction: (...args) => target.waitForFunction(...args),
685
+ }
686
+ const monitor = health ? createRenderHealthMonitor(scopedPage, { mode: storybook ? 'storybook' : 'document' }) : null
687
+
688
+ const instrument = (kind, reason, extra = {}) => new StoryRenderInstrumentError({
689
+ storyId: label, url, kind, reason, ...extra,
690
+ failedRequests: [...new Set([
691
+ ...(Array.isArray(notFound) ? notFound.slice(ledgerStart).map((path) => `404 ${path}`) : []),
692
+ ...failed,
693
+ ])].slice(0, 8),
694
+ })
695
+ const assertHealthy = async () => {
696
+ if (!monitor) return
697
+ try {
698
+ await monitor.assertHealthy({ label, timeoutMs: healthTimeoutMs })
699
+ } catch (error) {
700
+ const storybookError = storybook ? await target.evaluate(storybookErrorText).catch(() => '') : ''
701
+ // render-health 的訊息前綴是「render health:<label>:」—— 例外本身會點名 story,去掉重複的那段
702
+ const reason = firstLine(error).replace(`render health:${label}:`, '').replace(/^render health:/, '')
703
+ throw instrument('render-health', reason, { storybookError: reason.includes(storybookError) ? '' : storybookError })
704
+ }
705
+ }
706
+
707
+ try {
708
+ // 2. 導覽
709
+ try {
710
+ await page.goto(url, { waitUntil: 'load', timeout: navigationTimeoutMs })
711
+ } catch (error) {
712
+ throw instrument('navigation', `導覽失敗:${firstLine(error)}`, { cause: error })
713
+ }
714
+
715
+ // 2b. 管理介面:量測對象換成 preview iframe 的內容框架(iframe 元素本身等不到 = 儀器失效)
716
+ if (previewFrame) {
717
+ const handle = await page.waitForSelector(previewFrame, { state: 'attached', timeout: timeoutMs }).catch(() => null)
718
+ const frame = await handle?.contentFrame().catch(() => null)
719
+ if (!frame) throw instrument('preview-frame', `${timeoutMs / 1000} 秒內在頁面上等不到 preview iframe「${previewFrame}」(或它沒有內容框架)`)
720
+ target = frame
721
+ }
722
+
723
+ // 3. Storybook 自己的渲染完成訊號(含 play / afterEach)—— 與 waitForStoryRender 同一份實作
724
+ let phase = null
725
+ if (storybook && requireRenderFinished) phase = await awaitStorybookRender(target, storyId, { timeoutMs, finished, instrument })
726
+
727
+ // 4. 錯誤頁文字 / 空畫面 / 關鍵資源失敗 / 頁面例外
728
+ await assertHealthy()
729
+
730
+ // 5. 渲染期間發出的請求全部結束
731
+ if (requestsSettled) {
732
+ const deadline = Date.now() + timeoutMs
733
+ while (inflight.size) {
734
+ if (Date.now() > deadline) {
735
+ throw instrument('requests-timeout', `請求 ${timeoutMs / 1000} 秒未結束:${[...inflight].slice(0, 3).map((r) => describe(r.url())).join(', ')}`)
736
+ }
737
+ await page.waitForTimeout(50)
738
+ }
739
+ }
740
+
741
+ // 6. 被量的元素本身
742
+ if (waitFor) {
743
+ const appeared = typeof waitFor === 'string'
744
+ ? await target.waitForSelector(waitFor, { timeout: timeoutMs, state: 'attached' }).then(() => true, () => false)
745
+ : await target.waitForFunction(waitFor, waitForArg, { timeout: timeoutMs, polling: waitForPolling }).then(() => true, () => false)
746
+ if (!appeared) {
747
+ const what = typeof waitFor === 'string' ? `「${waitFor}」` : '呼叫端指定的元素(waitFor 函式)'
748
+ throw instrument('wait-for-timeout', `${timeoutMs / 1000} 秒內沒等到${what}`, { phase })
749
+ }
750
+ }
751
+
752
+ // 7. 呼叫端在靜止判定前要做的事
753
+ if (beforeSettle) await beforeSettle(page)
754
+
755
+ // 8. 字型
756
+ if (fonts) await target.evaluate(() => document.fonts.ready.then(() => true))
757
+
758
+ // 9. 版面靜止(影格)+ 同一個 task 裡的量測
759
+ let settle = null
760
+ let probed
761
+ if (settleFrames > 0 || probe) {
762
+ const args = { frames: settleFrames, capMs: settleTimeoutMs, includeParent: Boolean(previewFrame) }
763
+ const result = probe
764
+ // 把兩支頁面函式接成同一個運算式,probe 才會在最後一個靜止影格的同一個 task 裡執行(Playwright 本身也是以原始碼序列化頁面函式)
765
+ ? await target.evaluate(`(${waitForQuietFrames.toString()})(${JSON.stringify(args)}).then(async (s) => s.ok ? { ...s, probe: await (${probe.toString()})(${JSON.stringify(probeArg ?? null)}) } : s)`)
766
+ : await target.evaluate(waitForQuietFrames, args)
767
+ if (result.parentUnreadable) {
768
+ throw instrument('dom-not-settled', `讀不到 preview iframe 外層的管理介面文件(跨來源?)—— 證明不了管理介面已靜止,不能當成「它沒動」`, { phase })
769
+ }
770
+ if (!result.ok) {
771
+ throw instrument('dom-not-settled', `渲染完成後 ${settleTimeoutMs / 1000} 秒內版面沒有連續靜止 ${settleFrames} 個影格(等了 ${result.framesWaited} 格,期間變動 ${result.lateChanges} 次)`, { phase })
772
+ }
773
+ settle = settleFrames > 0 ? { framesWaited: result.framesWaited, lateChanges: result.lateChanges, longestBrokenQuiet: result.longestBrokenQuiet } : null
774
+ probed = result.probe
775
+ }
776
+
777
+ // 10. 等待期間冒出的例外 / 失敗也要算
778
+ if (waitFor || beforeSettle || settleFrames > 0 || requestsSettled) await assertHealthy()
779
+
780
+ // frame = 量測所在的 Frame(previewFrame 模式是 preview iframe 的內容框架;一般是主框架)
781
+ return { storyId: label, storybook, phase, ms: Date.now() - t0, settle, probe: probed, frame: target === page ? page.mainFrame() : target }
782
+ } finally {
783
+ page.off('framenavigated', onFrameNavigated)
784
+ page.off('request', onRequest)
785
+ page.off('response', onResponse)
786
+ page.off('requestfailed', onRequestFailed)
787
+ page.off('requestfinished', onRequestFinished)
788
+ monitor?.dispose()
789
+ }
790
+ }