@hyperframes/studio 0.8.50 → 0.8.53-desktop.0

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 (330) hide show
  1. package/dist/assets/{hyperframes-player-CxxDxHTW.js → hyperframes-player-PTEnAHhg.js} +3 -3
  2. package/dist/assets/{index-DUAIWxkI.js → index--myXp8ui.js} +1 -1
  3. package/dist/assets/{index-Dt3qBI-9.js → index-BjslOS9g.js} +1 -1
  4. package/dist/assets/index-BnXZuIJD.css +1 -0
  5. package/dist/assets/index-CNyviK_l.js +390 -0
  6. package/dist/index.css +29 -0
  7. package/dist/index.css.map +1 -0
  8. package/dist/index.d.ts +825 -24
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +8911 -9853
  11. package/dist/index.js.map +1 -1
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +13 -12
  14. package/src/App.tsx +145 -163
  15. package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
  16. package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
  17. package/src/captions/components/CaptionOverlay.tsx +8 -4
  18. package/src/captions/components/CaptionOverlayUtils.ts +2 -3
  19. package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
  20. package/src/captions/components/CaptionTimeline.tsx +1 -1
  21. package/src/captions/components/shared.tsx +2 -2
  22. package/src/components/AskAgentModal.tsx +5 -5
  23. package/src/components/EditorShell.selectionSync.test.tsx +9 -4
  24. package/src/components/EditorShell.tsx +59 -62
  25. package/src/components/ExternalFileConflictBanner.tsx +16 -16
  26. package/src/components/LintModal.tsx +6 -16
  27. package/src/components/MediaPreview.tsx +2 -2
  28. package/src/components/RightPanelTabs.test.tsx +151 -0
  29. package/src/components/RightPanelTabs.tsx +59 -0
  30. package/src/components/SaveQueuePausedBanner.tsx +2 -2
  31. package/src/components/StudioHeader.dom.test.tsx +181 -0
  32. package/src/components/StudioHeader.test.ts +0 -12
  33. package/src/components/StudioHeader.tsx +118 -221
  34. package/src/components/StudioLeftPanels.tsx +202 -0
  35. package/src/components/StudioOverlays.tsx +1 -1
  36. package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -194
  37. package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
  38. package/src/components/StudioToast.tsx +2 -2
  39. package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
  40. package/src/components/TimelineHistoryButtons.tsx +54 -0
  41. package/src/components/TimelineToolbar.test.tsx +8 -0
  42. package/src/components/TimelineToolbar.tsx +8 -12
  43. package/src/components/dock/Dock.test.tsx +274 -0
  44. package/src/components/dock/Dock.tsx +291 -0
  45. package/src/components/dock/dock.css +28 -0
  46. package/src/components/dock/dockAccessibility.test.ts +166 -0
  47. package/src/components/dock/dockAccessibility.ts +98 -0
  48. package/src/components/dock/dockLayout.test.ts +134 -0
  49. package/src/components/dock/dockLayout.ts +105 -0
  50. package/src/components/dock/dockLayoutSchema.ts +66 -0
  51. package/src/components/dock/dockLayoutStore.test.ts +28 -0
  52. package/src/components/dock/dockLayoutStore.ts +108 -0
  53. package/src/components/dock/dockTestHarness.ts +11 -0
  54. package/src/components/dock/panelRegistry.ts +67 -0
  55. package/src/components/editor/AnimationCard.tsx +4 -4
  56. package/src/components/editor/AnimationCardParts.tsx +6 -6
  57. package/src/components/editor/BlockParamsPanel.tsx +4 -4
  58. package/src/components/editor/BorderRadiusEditor.tsx +1 -1
  59. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  60. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  61. package/src/components/editor/DomEditOverlay.tsx +1 -1
  62. package/src/components/editor/EaseCurveSection.tsx +1 -1
  63. package/src/components/editor/EaseParamFields.tsx +5 -5
  64. package/src/components/editor/FileTree.tsx +4 -4
  65. package/src/components/editor/FileTreeIcons.tsx +1 -1
  66. package/src/components/editor/FileTreeNodes.tsx +13 -13
  67. package/src/components/editor/InlineTextToolbar.tsx +1 -1
  68. package/src/components/editor/InspectorHeaderActions.tsx +3 -3
  69. package/src/components/editor/KeyframeDiamond.tsx +1 -1
  70. package/src/components/editor/KeyframeEaseList.tsx +1 -1
  71. package/src/components/editor/LayersPanel.test.ts +97 -1
  72. package/src/components/editor/LayersPanel.tsx +7 -5
  73. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  74. package/src/components/editor/OffCanvasIndicators.tsx +1 -1
  75. package/src/components/editor/PreviewGuides.tsx +1 -1
  76. package/src/components/editor/PromotableControl.tsx +2 -2
  77. package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
  78. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  79. package/src/components/editor/SnapToolbar.test.tsx +0 -3
  80. package/src/components/editor/SnapToolbar.tsx +2 -2
  81. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  82. package/src/components/editor/TopologyLens.test.tsx +28 -0
  83. package/src/components/editor/TopologyLens.tsx +7 -5
  84. package/src/components/editor/Transform3DCube.tsx +2 -2
  85. package/src/components/editor/audioFxRevealTarget.ts +1 -0
  86. package/src/components/editor/domEditing.test.ts +1 -1
  87. package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
  88. package/src/components/editor/domEditingAgentPrompt.ts +4 -3
  89. package/src/components/editor/propertyPanelColor.tsx +11 -11
  90. package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
  91. package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
  92. package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
  93. package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
  94. package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
  95. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  96. package/src/components/editor/propertyPanelFill.tsx +4 -4
  97. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  98. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
  99. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  100. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
  101. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
  102. package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
  103. package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
  104. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
  105. package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
  106. package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
  107. package/src/components/editor/propertyPanelFont.tsx +5 -5
  108. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  109. package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
  110. package/src/components/editor/propertyPanelFxControls.tsx +6 -6
  111. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  112. package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
  113. package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
  114. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  115. package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
  116. package/src/components/editor/propertyPanelSections.tsx +8 -8
  117. package/src/components/editor/useColorGradingController.test.ts +38 -0
  118. package/src/components/editor/useColorGradingController.ts +9 -1
  119. package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
  120. package/src/components/editor/useColorGradingPreviews.ts +11 -1
  121. package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
  122. package/src/components/editor/usePreviewCompositionRect.ts +21 -0
  123. package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
  124. package/src/components/feedback/feedbackTrigger.ts +0 -1
  125. package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
  126. package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
  127. package/src/components/nle/NLEContext.tsx +0 -43
  128. package/src/components/nle/NLEPreview.tsx +3 -3
  129. package/src/components/nle/PreviewOverlays.tsx +6 -6
  130. package/src/components/nle/PreviewPane.tsx +3 -3
  131. package/src/components/nle/TimelinePane.tsx +53 -67
  132. package/src/components/panels/SlideshowPanel.tsx +3 -3
  133. package/src/components/panels/SlideshowSubPanels.tsx +20 -17
  134. package/src/components/panels/VariablesBindElement.tsx +2 -2
  135. package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
  136. package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
  137. package/src/components/panels/VariablesPanel.tsx +4 -4
  138. package/src/components/panels/VariablesValueControls.tsx +2 -2
  139. package/src/components/pointerTargetSize.test.tsx +8 -0
  140. package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
  141. package/src/components/renders/RenderQueue.test.tsx +7 -0
  142. package/src/components/renders/RenderQueue.tsx +7 -7
  143. package/src/components/renders/RenderQueueItem.tsx +6 -6
  144. package/src/components/renders/RenderQueuePanel.tsx +1 -1
  145. package/src/components/sidebar/AssetCard.tsx +8 -8
  146. package/src/components/sidebar/AssetContextMenu.tsx +8 -8
  147. package/src/components/sidebar/AssetsTab.tsx +2 -2
  148. package/src/components/sidebar/AudioRow.tsx +6 -6
  149. package/src/components/sidebar/BlocksTab.tsx +6 -6
  150. package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
  151. package/src/components/sidebar/CompositionsPanel.tsx +15 -0
  152. package/src/components/sidebar/CompositionsTab.tsx +5 -5
  153. package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
  154. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  155. package/src/components/sidebar/SidebarLintButton.test.tsx +53 -0
  156. package/src/components/sidebar/SidebarLintButton.tsx +50 -0
  157. package/src/components/timelineToolbarStyles.ts +5 -0
  158. package/src/components/ui/Button.test.tsx +252 -0
  159. package/src/components/ui/Button.tsx +89 -126
  160. package/src/components/ui/IconButton.tsx +38 -0
  161. package/src/components/ui/Input.tsx +115 -0
  162. package/src/components/ui/Menu.test.tsx +374 -0
  163. package/src/components/ui/Menu.tsx +213 -0
  164. package/src/components/ui/NumberField.tsx +122 -0
  165. package/src/components/ui/Popover.tsx +74 -0
  166. package/src/components/ui/SearchInput.tsx +2 -2
  167. package/src/components/ui/Select.tsx +100 -0
  168. package/src/components/ui/Slider.tsx +153 -0
  169. package/src/components/ui/Tabs.test.tsx +98 -0
  170. package/src/components/ui/Tabs.tsx +71 -0
  171. package/src/components/ui/Toggle.tsx +63 -0
  172. package/src/components/ui/Tooltip.test.tsx +110 -0
  173. package/src/components/ui/Tooltip.tsx +38 -112
  174. package/src/components/ui/cn.test.ts +30 -0
  175. package/src/components/ui/cn.ts +25 -0
  176. package/src/components/ui/index.ts +29 -2
  177. package/src/components/ui/mountHost.testHelpers.ts +24 -0
  178. package/src/components/ui/valueControls.test.tsx +524 -0
  179. package/src/contexts/PanelLayoutContext.tsx +3 -57
  180. package/src/hooks/appHotkeysDispatch.ts +3 -4
  181. package/src/hooks/timelineEditPermission.ts +31 -0
  182. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
  183. package/src/hooks/useAppHotkeys.test.ts +0 -1
  184. package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
  185. package/src/hooks/useAppHotkeys.ts +21 -84
  186. package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
  187. package/src/hooks/useCaptionDetection.ts +3 -1
  188. package/src/hooks/useDomEditPreviewSync.ts +4 -6
  189. package/src/hooks/useDomEditSession.ts +0 -7
  190. package/src/hooks/useDomEditWiring.ts +4 -9
  191. package/src/hooks/useEditHistoryActions.test.tsx +90 -0
  192. package/src/hooks/useEditHistoryActions.ts +106 -0
  193. package/src/hooks/useFileManager.ts +21 -123
  194. package/src/hooks/useLintModal.test.tsx +65 -0
  195. package/src/hooks/useLintModal.ts +3 -0
  196. package/src/hooks/usePanelLayout.test.ts +108 -296
  197. package/src/hooks/usePanelLayout.ts +50 -255
  198. package/src/hooks/usePersistentEditHistory.ts +1 -1
  199. package/src/hooks/useProjectFileWriter.ts +150 -0
  200. package/src/hooks/useRightPanelIntents.test.ts +181 -0
  201. package/src/hooks/useRightPanelIntents.ts +63 -0
  202. package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
  203. package/src/hooks/useSdkSession.ts +43 -10
  204. package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
  205. package/src/hooks/useServerConnection.ts +43 -10
  206. package/src/hooks/useSlideshowTabState.test.ts +9 -31
  207. package/src/hooks/useSlideshowTabState.ts +6 -19
  208. package/src/hooks/useStudioContextValue.test.ts +6 -18
  209. package/src/hooks/useStudioContextValue.ts +5 -26
  210. package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
  211. package/src/hooks/useTimelineAssetDropOps.ts +52 -11
  212. package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
  213. package/src/hooks/useTimelineEditing.test.tsx +322 -3
  214. package/src/hooks/useTimelineEditing.ts +103 -16
  215. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  216. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
  217. package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
  218. package/src/hostMount.test.tsx +222 -0
  219. package/src/index.ts +87 -0
  220. package/src/main.tsx +9 -3
  221. package/src/player/components/AudioWaveform.tsx +1 -1
  222. package/src/player/components/AutomationSelectionMenu.tsx +3 -3
  223. package/src/player/components/ClipContextMenu.tsx +3 -3
  224. package/src/player/components/CompositionThumbnail.test.ts +0 -3
  225. package/src/player/components/CompositionThumbnail.tsx +3 -4
  226. package/src/player/components/EditModal.tsx +1 -1
  227. package/src/player/components/ImageThumbnail.tsx +1 -1
  228. package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
  229. package/src/player/components/LayerDisclosureRow.tsx +2 -2
  230. package/src/player/components/PlayerControls.tsx +1 -1
  231. package/src/player/components/ShortcutsPanel.tsx +9 -9
  232. package/src/player/components/SpeedMenu.tsx +3 -5
  233. package/src/player/components/Timeline.tsx +3 -2
  234. package/src/player/components/TimelineCanvas.tsx +21 -14
  235. package/src/player/components/TimelineClip.tsx +0 -1
  236. package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
  237. package/src/player/components/TimelineEmptyState.tsx +5 -5
  238. package/src/player/components/TimelineFxButton.tsx +6 -6
  239. package/src/player/components/TimelineGroupHeader.tsx +5 -5
  240. package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
  241. package/src/player/components/TimelineGroupRow.tsx +1 -1
  242. package/src/player/components/TimelineLanes.test.tsx +32 -0
  243. package/src/player/components/TimelineLanes.tsx +2 -5
  244. package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
  245. package/src/player/components/TimelineRuler.tsx +1 -1
  246. package/src/player/components/TimelineShortcutHint.tsx +1 -1
  247. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  248. package/src/player/components/TimelineTrackHeader.tsx +2 -2
  249. package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
  250. package/src/player/components/TimelineTypes.ts +2 -1
  251. package/src/player/components/TrackGapContextMenu.tsx +1 -1
  252. package/src/player/components/VideoThumbnail.tsx +4 -2
  253. package/src/player/components/VolumeControl.tsx +2 -2
  254. package/src/player/components/timelineCallbacks.ts +8 -8
  255. package/src/player/components/timelineClipDragPreview.ts +51 -27
  256. package/src/player/components/timelineClipDragTypes.ts +3 -0
  257. package/src/player/components/timelineDragDrop.test.tsx +61 -1
  258. package/src/player/components/timelineDragDrop.ts +57 -15
  259. package/src/player/components/timelineLaneProps.ts +3 -0
  260. package/src/player/components/timelineSnapping.test.ts +27 -0
  261. package/src/player/components/timelineSnapping.ts +14 -1
  262. package/src/player/components/timelineTheme.test.ts +4 -4
  263. package/src/player/components/timelineTheme.ts +30 -30
  264. package/src/player/components/trackHeaderLabelRows.tsx +4 -4
  265. package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
  266. package/src/player/components/useTimelineClipDrag.ts +30 -3
  267. package/src/player/lib/describeClips.ts +3 -54
  268. package/src/player/lib/playbackShortcuts.ts +2 -0
  269. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
  270. package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
  271. package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
  272. package/src/player/store/previewIframeStore.test.tsx +29 -0
  273. package/src/player/store/previewIframeStore.ts +25 -0
  274. package/src/renderQueueExports.test.tsx +58 -0
  275. package/src/styles/classCandidates.test.ts +105 -0
  276. package/src/styles/classCandidates.ts +281 -0
  277. package/src/styles/hex-baseline.json +84 -0
  278. package/src/styles/hexRatchet.test.ts +201 -0
  279. package/src/styles/iconTokens.ts +20 -0
  280. package/src/styles/palette-reset.css +6 -0
  281. package/src/styles/studio.css +24 -21
  282. package/src/styles/styleSources.test.ts +20 -0
  283. package/src/styles/styleSources.ts +47 -0
  284. package/src/styles/tailwind-preset.shared.js +3 -0
  285. package/src/styles/theme.css +270 -0
  286. package/src/styles/theme.test.ts +183 -0
  287. package/src/styles/tokenGate.test.ts +109 -0
  288. package/src/utils/sourcePatcher.ts +1 -1
  289. package/src/utils/studioHelpers.test.ts +18 -0
  290. package/src/utils/studioHelpers.ts +11 -8
  291. package/src/utils/studioUiPreferences.test.ts +10 -16
  292. package/src/utils/studioUiPreferences.ts +7 -16
  293. package/src/utils/timelineDropTrackInsert.test.ts +134 -0
  294. package/src/utils/timelineDropTrackInsert.ts +83 -0
  295. package/src/utils/typingTarget.ts +4 -0
  296. package/dist/assets/index-n2ml4tUn.js +0 -442
  297. package/dist/assets/index-pO6p0H27.css +0 -1
  298. package/src/components/PanelTabButton.tsx +0 -31
  299. package/src/components/StudioLeftSidebar.tsx +0 -204
  300. package/src/components/nle/TimelineResizeDivider.tsx +0 -101
  301. package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
  302. package/src/components/sidebar/LeftSidebar.tsx +0 -334
  303. package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
  304. package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
  305. package/src/components/storyboard/FramePoster.test.tsx +0 -61
  306. package/src/components/storyboard/FramePoster.tsx +0 -86
  307. package/src/components/storyboard/StoryboardDirection.tsx +0 -45
  308. package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
  309. package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
  310. package/src/components/storyboard/StoryboardGrid.tsx +0 -55
  311. package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
  312. package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
  313. package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
  314. package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
  315. package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
  316. package/src/components/storyboard/StoryboardView.tsx +0 -169
  317. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
  318. package/src/components/storyboard/frameComments.test.ts +0 -95
  319. package/src/components/storyboard/frameComments.ts +0 -115
  320. package/src/components/storyboard/frameStatus.ts +0 -36
  321. package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
  322. package/src/components/storyboard/storyboardReviewStage.ts +0 -45
  323. package/src/components/storyboard/useFrameComments.ts +0 -98
  324. package/src/contexts/ViewModeContext.tsx +0 -142
  325. package/src/hooks/useInspectorSplitResize.ts +0 -51
  326. package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
  327. package/src/hooks/useProjectSignaturePoll.ts +0 -68
  328. package/src/hooks/useStoryboard.ts +0 -99
  329. package/src/utils/fitPanels.test.ts +0 -164
  330. package/src/utils/fitPanels.ts +0 -151
@@ -0,0 +1,281 @@
1
+ // Pulls Tailwind class candidates out of a TS/TSX file: only strings unambiguously used as class lists
2
+ // (className/class attributes, cn/clsx/cva-style calls, `*class` properties, identifiers those use,
3
+ // `*Styles`/`*Classes` bindings). Bare strings are ignored so prose and paths never become "classes".
4
+ // Pure: text in, candidates out.
5
+
6
+ import { escapeRegex } from "../utils/sourcePatcher";
7
+
8
+ /** A class candidate and where it came from. */
9
+ export interface ClassCandidate {
10
+ /** The utility with its variant prefixes removed (`hover:bg-x` -> `bg-x`). */
11
+ readonly base: string;
12
+ /** The token exactly as it was written, for the failure message. */
13
+ readonly raw: string;
14
+ /** True when the utility carries a bracketed arbitrary value. */
15
+ readonly arbitrary: boolean;
16
+ }
17
+
18
+ const OPEN: Record<string, string> = { "{": "}", "(": ")", "[": "]" };
19
+
20
+ // Index of the last char of the non-code span (string, template, line or block comment) starting at
21
+ // `i`, else -1. Known limit: a regex literal containing a quote reads as a string; the cost is a
22
+ // spurious candidate, which the gate reports.
23
+ function endOfSpan(text: string, i: number): number {
24
+ const ch = text[i];
25
+ if (ch === '"' || ch === "'" || ch === "`") {
26
+ return Math.min(endOfString(text, i), text.length - 1);
27
+ }
28
+ if (ch !== "/") return -1;
29
+ if (text[i + 1] === "/") {
30
+ const end = text.indexOf("\n", i);
31
+ return end === -1 ? text.length - 1 : end - 1;
32
+ }
33
+ if (text[i + 1] === "*") {
34
+ const end = text.indexOf("*/", i + 2);
35
+ return end === -1 ? text.length - 1 : end + 1;
36
+ }
37
+ return -1;
38
+ }
39
+
40
+ // Index just past the region starting at `start` (an opening bracket), skipping strings, templates
41
+ // and comments; `text.length` when it never closes.
42
+ function endOfRegion(text: string, start: number): number {
43
+ const stack: string[] = [];
44
+ for (let i = start; i < text.length; i += 1) {
45
+ const span = endOfSpan(text, i);
46
+ if (span >= i) {
47
+ i = span;
48
+ continue;
49
+ }
50
+ const ch = text[i];
51
+ if (OPEN[ch]) {
52
+ stack.push(OPEN[ch]);
53
+ } else if (stack.length > 0 && ch === stack[stack.length - 1]) {
54
+ stack.pop();
55
+ if (stack.length === 0) return i + 1;
56
+ }
57
+ }
58
+ return text.length;
59
+ }
60
+
61
+ /** Index of the closing quote of the string that opens at `start`. */
62
+ function endOfString(text: string, start: number): number {
63
+ const quote = text[start];
64
+ for (let i = start + 1; i < text.length; i += 1) {
65
+ if (text[i] === "\\") {
66
+ i += 1;
67
+ continue;
68
+ }
69
+ if (text[i] === quote) return i;
70
+ if (quote === "`" && text[i] === "$" && text[i + 1] === "{") {
71
+ i = endOfRegion(text, i + 1) - 1;
72
+ }
73
+ }
74
+ return text.length;
75
+ }
76
+
77
+ // Splits a template literal body into safe chunks. A chunk touching an interpolation is dropped at
78
+ // that edge (`w-${size}` claims no class `w-`) unless whitespace separates it from the hole.
79
+ function templateChunks(body: string): string[] {
80
+ const chunks: string[] = [];
81
+ let cursor = 0;
82
+ let precededByHole = false;
83
+ while (cursor <= body.length) {
84
+ const hole = body.indexOf("${", cursor);
85
+ const end = hole === -1 ? body.length : hole;
86
+ let chunk = body.slice(cursor, end);
87
+ if (precededByHole && !/^\s/.test(chunk)) chunk = chunk.replace(/^\S+/, "");
88
+ if (hole !== -1 && !/\s$/.test(chunk)) chunk = chunk.replace(/\S+$/, "");
89
+ chunks.push(chunk);
90
+ if (hole === -1) break;
91
+ cursor = endOfRegion(body, hole + 1);
92
+ precededByHole = true;
93
+ }
94
+ return chunks;
95
+ }
96
+
97
+ // A same-length copy with comment and string bodies blanked, so offsets line up. Anchors match
98
+ // against it to keep out prose (one apostrophe in a comment opens a string that swallows the rest)
99
+ // and generated code (`captions/generator.ts` writes `x.className = 'word'` into a string).
100
+ function maskLiterals(text: string): string {
101
+ let out = "";
102
+ for (let i = 0; i < text.length; i += 1) {
103
+ const span = endOfSpan(text, i);
104
+ if (span < i) {
105
+ out += text[i];
106
+ continue;
107
+ }
108
+ // A comment goes entirely, newlines kept so line numbers survive. A string
109
+ // keeps its quotes, so the region scan still finds where it ends.
110
+ const body = " ".repeat(span - i - 1);
111
+ out +=
112
+ text[i] === "/"
113
+ ? text.slice(i, span + 1).replace(/[^\n]/g, " ")
114
+ : text[i] + body + text[span];
115
+ i = span;
116
+ }
117
+ return out;
118
+ }
119
+
120
+ // `activeTool === "razor" ? a : b` inside a `className=` region: the operand
121
+ // of a comparison is a value being tested, never a class list.
122
+ const COMPARISON = /(?:[=!]==?|\bcase)\s*$/;
123
+ // A class list never carries a declaration, so `position: absolute; ...` in a
124
+ // constant called `visualStyles` is a stylesheet, not classes.
125
+ const CSS_TEXT = /[a-z-]+\s*:\s*[^;]+;/;
126
+ // The calls whose arguments are class lists.
127
+ const CLASS_BUILDER = /^(?:cn|clsx|classnames|classNames|twMerge|twJoin|cva|tv)$/;
128
+
129
+ // Written with escapes, not literal quotes: `maskLiterals` reads a quote in
130
+ // a regular-expression literal as the start of a string, and this file is
131
+ // one of the files the gate scans.
132
+ const CLOSE = /[)\]}]/;
133
+ const QUOTE = /[\u0022\u0027\u0060]/;
134
+
135
+ /**
136
+ * A string is a class list unless it is being compared against or is a sheet
137
+ * of CSS text.
138
+ */
139
+ function isClassList(mask: string, start: number, i: number, body: string): boolean {
140
+ return !COMPARISON.test(mask.slice(Math.max(start, i - 8), i)) && !CSS_TEXT.test(body);
141
+ }
142
+
143
+ /**
144
+ * Does a string inside the bracket that opens at `i` hold classes? A bracket opened by a
145
+ * non-class-builder name closes the door: `T[f.inlineStyles["font-weight"]]` has a lookup key.
146
+ */
147
+ function opensClassContext(mask: string, from: number, i: number): boolean {
148
+ const name = /([A-Za-z_$][\w$]*)\s*$/.exec(mask.slice(Math.max(from, i - 40), i))?.[1];
149
+ return name === undefined || CLASS_BUILDER.test(name);
150
+ }
151
+
152
+ /**
153
+ * Every string and template body inside a source region. Where the strings are
154
+ * is decided on the mask; what they contain is read from the real text.
155
+ */
156
+ function stringsIn(text: string, mask: string, start: number, end: number): string[] {
157
+ const found: string[] = [];
158
+ const classy: boolean[] = [];
159
+ for (let i = start; i < end; i += 1) {
160
+ const ch = mask[i];
161
+ if (OPEN[ch]) classy.push(opensClassContext(mask, start, i));
162
+ else if (CLOSE.test(ch)) classy.pop();
163
+ else if (QUOTE.test(ch)) {
164
+ const close = endOfString(mask, i);
165
+ const body = text.slice(i + 1, close);
166
+ if (classy.at(-1) !== false && isClassList(mask, start, i, body)) {
167
+ found.push(...(ch === "`" ? templateChunks(body) : [body]));
168
+ }
169
+ i = close;
170
+ }
171
+ }
172
+ return found;
173
+ }
174
+
175
+ /** Split on `:` that sits outside brackets, so `[&:hover]:x` yields `x`. */
176
+ function stripVariants(token: string): string {
177
+ let depth = 0;
178
+ let last = 0;
179
+ for (let i = 0; i < token.length; i += 1) {
180
+ const ch = token[i];
181
+ if (ch === "[" || ch === "(") depth += 1;
182
+ else if (ch === "]" || ch === ")") depth -= 1;
183
+ else if (ch === ":" && depth === 0) last = i + 1;
184
+ }
185
+ return token.slice(last);
186
+ }
187
+
188
+ // A utility starts with a letter, a digit, `-` (negative), `@` (container
189
+ // query) or `[` (fully arbitrary), and never contains a quote or a backslash.
190
+ // The quotes are written as escapes so this file can be read by its own
191
+ // comment stripper, which treats a quote in a regex literal as a string.
192
+ const TOKEN = /^[-@[a-z0-9][^\s\u0022\u0027\u0060\\]*$/;
193
+
194
+ // Class contexts: each anchor's balanced region is read for strings. The first `CLASS_POSITIONS` are
195
+ // places a class lands, so identifiers used inside them are followed to their bindings.
196
+ const ANCHORS = [
197
+ // className={...} / className="..." / class="..."
198
+ /\bclass(?:Name)?\s*=\s*/g,
199
+ // cn("..."), clsx(...), twMerge(...), cva(...)
200
+ /\b(?:cn|clsx|classnames|classNames|twMerge|twJoin|cva|tv)\s*\(/g,
201
+ // { className: "...", itemClass: "..." }
202
+ /\b\w*[Cc]lass(?:Names?)?\s*:\s*/g,
203
+ // const sizeStyles: Record<Size, string> = { ... } / baseClasses = "..." (exported maps used elsewhere)
204
+ // Prefix and plural both required: bare `style=` is the JSX attribute, `fxPresetStyle` is CSS text.
205
+ // The optional group is a type annotation; without it the gap spans `computedStyles["z-index"] !== "auto"`.
206
+ /\b\w+(?:Styles|Classes|ClassNames?)\b(?:\s*:[^=;{}()[\]]*)?\s*=(?!=)\s*/g,
207
+ ] as const;
208
+
209
+ const CLASS_POSITIONS = 3;
210
+
211
+ /** Bare identifiers in a region of the mask; property accesses excluded. */
212
+ const REFERENCE = /(?<![.\w$])[A-Za-z_$][\w$]*/g;
213
+
214
+ // A subscript, i.e. the `[variant]` of `variantStyles[variant]`. In
215
+ // `cn(variantStyles[variant])` the class list is the map; the key is a value
216
+ // being looked up, and `variant = "secondary"` is a default, not a class.
217
+ const SUBSCRIPT = /(?<=[\w$)\]])\s*\[[^[\]]*\]/g;
218
+
219
+ // End of the bracketed region or string at `at`, else -1: `const styles = getComputedStyle(el)`
220
+ // matches the binding-name anchor but is not a class list.
221
+ function regionAfter(mask: string, at: number): number {
222
+ if (OPEN[mask[at]]) return endOfRegion(mask, at);
223
+ if (QUOTE.test(mask[at])) return endOfString(mask, at) + 1;
224
+ return -1;
225
+ }
226
+
227
+ /** Split one class list into candidates, keeping the first spelling of each. */
228
+ function collect(byRaw: Map<string, ClassCandidate>, list: string): void {
229
+ for (const raw of list.split(/\s+/)) {
230
+ if (raw === "" || !TOKEN.test(raw) || byRaw.has(raw)) continue;
231
+ const base = stripVariants(raw);
232
+ if (base === "") continue;
233
+ byRaw.set(raw, { base, raw, arbitrary: base.includes("[") });
234
+ }
235
+ }
236
+
237
+ // Every `[start, end)` an anchor opens. `lastIndex` jumps past each region so a nested `cn()`
238
+ // inside a `className=` is not read twice.
239
+ function regionsAfter(mask: string, anchor: RegExp): [number, number][] {
240
+ const pattern = new RegExp(anchor.source, "g");
241
+ const regions: [number, number][] = [];
242
+ let match: RegExpExecArray | null;
243
+ while ((match = pattern.exec(mask)) !== null) {
244
+ const start = match.index + match[0].length - (match[0].endsWith("(") ? 1 : 0);
245
+ const end = regionAfter(mask, start);
246
+ if (end <= start) continue;
247
+ regions.push([start, end]);
248
+ pattern.lastIndex = Math.max(pattern.lastIndex, end);
249
+ }
250
+ return regions;
251
+ }
252
+
253
+ // Every string bound to `name` by an assignment in this file. A name with no literal binding
254
+ // (parameter, import, call result) yields nothing, so an over-broad reference scan is safe.
255
+ function boundStrings(text: string, mask: string, name: string): string[] {
256
+ const anchor = new RegExp(
257
+ `(?<![\\w$])${escapeRegex(name)}(?![\\w$])(?:\\s*:[^=;{}()[\\]]*)?\\s*=(?!=)\\s*`,
258
+ );
259
+ return regionsAfter(mask, anchor).flatMap(([start, end]) => stringsIn(text, mask, start, end));
260
+ }
261
+
262
+ /** Every class candidate claimed by one source file. */
263
+ export function extractClassCandidates(source: string): ClassCandidate[] {
264
+ // Anchors and region bounds come from the mask (code only); the strings
265
+ // themselves are read back out of the real text.
266
+ const mask = maskLiterals(source);
267
+ const byRaw = new Map<string, ClassCandidate>();
268
+ const referenced = new Set<string>();
269
+ for (const [index, anchor] of ANCHORS.entries()) {
270
+ for (const [start, end] of regionsAfter(mask, anchor)) {
271
+ for (const text of stringsIn(source, mask, start, end)) collect(byRaw, text);
272
+ if (index >= CLASS_POSITIONS) continue;
273
+ const used = mask.slice(start, end).replace(SUBSCRIPT, "");
274
+ for (const [name] of used.matchAll(REFERENCE)) referenced.add(name);
275
+ }
276
+ }
277
+ for (const name of referenced) {
278
+ for (const text of boundStrings(source, mask, name)) collect(byRaw, text);
279
+ }
280
+ return [...byRaw.values()];
281
+ }
@@ -0,0 +1,84 @@
1
+ {
2
+ "total": 394,
3
+ "files": {
4
+ "packages/studio/src/captions/components/CaptionOverlay.tsx": 5,
5
+ "packages/studio/src/captions/components/CaptionTimeline.tsx": 11,
6
+ "packages/studio/src/captions/generator.ts": 1,
7
+ "packages/studio/src/captions/parser.ts": 1,
8
+ "packages/studio/src/captions/types.ts": 4,
9
+ "packages/studio/src/components/StudioToast.tsx": 10,
10
+ "packages/studio/src/components/TimelineToolbar.tsx": 2,
11
+ "packages/studio/src/components/editor/ArcPathControls.tsx": 1,
12
+ "packages/studio/src/components/editor/BorderRadiusEditor.tsx": 10,
13
+ "packages/studio/src/components/editor/DomEditCropHandles.tsx": 1,
14
+ "packages/studio/src/components/editor/EaseCurveSection.tsx": 2,
15
+ "packages/studio/src/components/editor/FileTree.tsx": 2,
16
+ "packages/studio/src/components/editor/FileTreeIcons.tsx": 17,
17
+ "packages/studio/src/components/editor/FileTreeNodes.tsx": 4,
18
+ "packages/studio/src/components/editor/GestureTrailOverlay.tsx": 1,
19
+ "packages/studio/src/components/editor/GridOverlay.tsx": 4,
20
+ "packages/studio/src/components/editor/InlineTextToolbar.tsx": 2,
21
+ "packages/studio/src/components/editor/KeyframeDiamond.tsx": 2,
22
+ "packages/studio/src/components/editor/KeyframeNavigation.tsx": 2,
23
+ "packages/studio/src/components/editor/MotionPathNode.tsx": 5,
24
+ "packages/studio/src/components/editor/SnapGuideOverlay.tsx": 3,
25
+ "packages/studio/src/components/editor/TimelineFxPopover.tsx": 1,
26
+ "packages/studio/src/components/editor/Transform3DCube.tsx": 6,
27
+ "packages/studio/src/components/editor/colorValue.ts": 3,
28
+ "packages/studio/src/components/editor/domEditingElement.ts": 1,
29
+ "packages/studio/src/components/editor/easeCurveSvg.tsx": 2,
30
+ "packages/studio/src/components/editor/gradientValue.ts": 6,
31
+ "packages/studio/src/components/editor/panelTokens.ts": 4,
32
+ "packages/studio/src/components/editor/propertyPanelColor.tsx": 7,
33
+ "packages/studio/src/components/editor/propertyPanelColorCurveGraph.tsx": 17,
34
+ "packages/studio/src/components/editor/propertyPanelColorScopes.tsx": 8,
35
+ "packages/studio/src/components/editor/propertyPanelColorWheels.tsx": 8,
36
+ "packages/studio/src/components/editor/propertyPanelFlatEffectsSection.tsx": 5,
37
+ "packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx": 2,
38
+ "packages/studio/src/components/editor/propertyPanelFxCarveModule.tsx": 1,
39
+ "packages/studio/src/components/editor/propertyPanelFxFamily.ts": 1,
40
+ "packages/studio/src/components/editor/propertyPanelFxPresetStyle.ts": 24,
41
+ "packages/studio/src/components/editor/propertyPanelHelpers.ts": 4,
42
+ "packages/studio/src/components/editor/propertyPanelPrimitives.tsx": 1,
43
+ "packages/studio/src/components/editor/propertyPanelSections.tsx": 1,
44
+ "packages/studio/src/components/editor/propertyPanelStyleSections.tsx": 3,
45
+ "packages/studio/src/components/editor/transform3dProjection.ts": 3,
46
+ "packages/studio/src/components/feedback/StudioFeedbackCard.tsx": 10,
47
+ "packages/studio/src/components/panels/VariablesBindElement.tsx": 2,
48
+ "packages/studio/src/components/panels/VariablesValueControls.tsx": 2,
49
+ "packages/studio/src/components/sidebar/AudioRow.tsx": 2,
50
+ "packages/studio/src/components/ui/HyperframesMark.tsx": 4,
51
+ "packages/studio/src/hooks/useInlineTextEdit.ts": 1,
52
+ "packages/studio/src/player/components/AudioWaveform.tsx": 6,
53
+ "packages/studio/src/player/components/BeatStrip.tsx": 6,
54
+ "packages/studio/src/player/components/CompositionThumbnail.tsx": 2,
55
+ "packages/studio/src/player/components/ImageThumbnail.tsx": 6,
56
+ "packages/studio/src/player/components/LayerDisclosureRow.tsx": 3,
57
+ "packages/studio/src/player/components/Player.tsx": 2,
58
+ "packages/studio/src/player/components/PlayerControls.tsx": 1,
59
+ "packages/studio/src/player/components/PlayheadIndicator.tsx": 3,
60
+ "packages/studio/src/player/components/ShortcutsPanel.tsx": 7,
61
+ "packages/studio/src/player/components/SpeedMenu.tsx": 2,
62
+ "packages/studio/src/player/components/TimelineAutomationLane.tsx": 4,
63
+ "packages/studio/src/player/components/TimelineCanvas.tsx": 13,
64
+ "packages/studio/src/player/components/TimelineClip.tsx": 2,
65
+ "packages/studio/src/player/components/TimelineClipDiamonds.tsx": 2,
66
+ "packages/studio/src/player/components/TimelineDiamondConnectors.tsx": 2,
67
+ "packages/studio/src/player/components/TimelineEmptyState.tsx": 1,
68
+ "packages/studio/src/player/components/TimelineFxButton.tsx": 8,
69
+ "packages/studio/src/player/components/TimelineGroupHeader.tsx": 6,
70
+ "packages/studio/src/player/components/TimelineGroupLaneLabels.tsx": 1,
71
+ "packages/studio/src/player/components/TimelineGroupRow.tsx": 1,
72
+ "packages/studio/src/player/components/TimelineRazorInteraction.tsx": 1,
73
+ "packages/studio/src/player/components/TimelineRuler.tsx": 1,
74
+ "packages/studio/src/player/components/TimelineShortcutHint.tsx": 2,
75
+ "packages/studio/src/player/components/TimelineTrackHeader.tsx": 2,
76
+ "packages/studio/src/player/components/TimelineTrackPlainHeader.tsx": 2,
77
+ "packages/studio/src/player/components/VideoThumbnail.tsx": 6,
78
+ "packages/studio/src/player/components/timelineTheme.ts": 5,
79
+ "packages/studio/src/player/components/trackHeaderLabelRows.tsx": 6,
80
+ "packages/studio/src/styles/studio.css": 70,
81
+ "packages/studio/src/telemetry/client.ts": 1,
82
+ "packages/studio/src/webmcp/webmcpTestUtils.ts": 1
83
+ }
84
+ }
@@ -0,0 +1,201 @@
1
+ // The hex ratchet (R12, AE2): a per-file baseline of colour literals that only ever goes down.
2
+ // Per file so two sweep PRs touching one file conflict on its line and force a recount.
3
+ // The baseline is never regenerated by a normal run: a missing one fails, and rewriting it needs
4
+ // the named flag, so the number in git is one a human accepted.
5
+
6
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { describe, expect, it } from "vitest";
9
+ import { listSourceFiles, REPO_ROOT, STYLES_DIR } from "./styleSources";
10
+
11
+ const BASELINE_PATH = path.join(STYLES_DIR, "hex-baseline.json");
12
+
13
+ const WRITE_FLAG = "HEX_BASELINE_WRITE";
14
+ const LOWER_COMMAND = `${WRITE_FLAG}=1 bunx vitest run src/styles/hexRatchet.test.ts`;
15
+
16
+ // A literal colour: `#abc`, `#aabbcc`, `#aabbccdd`, or functional notation. This test owns the
17
+ // regex, so the baseline is whatever this rule sees.
18
+ const COLOR_LITERAL = /#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})\b|\b(?:rgba?|hsla?)\(/gi;
19
+
20
+ /** Files whose whole job is to hold colour values. */
21
+ function isTokenSource(relative: string): boolean {
22
+ return /styles\/(theme\.css|tailwind-preset\.shared\.js|tailwind-preset\.ts)$/.test(relative);
23
+ }
24
+
25
+ function isScanned(relative: string): boolean {
26
+ if (isTokenSource(relative) || /\.test\.tsx?$/.test(relative)) return false;
27
+ return /\.(tsx?|css)$/.test(relative);
28
+ }
29
+
30
+ /** Strings match first and are kept, so `"image/*"` or `"a//b"` never opens a comment. */
31
+ const STRING_OR_COMMENT =
32
+ /("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g;
33
+
34
+ /** Comments hold issue numbers like `#2291` that read as hex; count code only. */
35
+ function stripComments(text: string, lineComments: boolean): string {
36
+ return text.replace(STRING_OR_COMMENT, (match, string?: string) => {
37
+ if (string !== undefined) return match;
38
+ return match.startsWith("//") && !lineComments ? match : "";
39
+ });
40
+ }
41
+
42
+ function countColorLiterals(text: string, lineComments = true): number {
43
+ return [...stripComments(text, lineComments).matchAll(COLOR_LITERAL)].length;
44
+ }
45
+
46
+ /** Repository-relative path to colour-literal count, for every scanned file. */
47
+ function scan(): Map<string, number> {
48
+ const counts = new Map<string, number>();
49
+ for (const [file, text] of listSourceFiles(isScanned, REPO_ROOT)) {
50
+ const count = countColorLiterals(text, !file.endsWith(".css"));
51
+ if (count > 0) counts.set(file, count);
52
+ }
53
+ return counts;
54
+ }
55
+
56
+ interface Baseline {
57
+ readonly total: number;
58
+ readonly files: Readonly<Record<string, number>>;
59
+ }
60
+
61
+ function toBaseline(counts: ReadonlyMap<string, number>): Baseline {
62
+ const files: Record<string, number> = {};
63
+ let total = 0;
64
+ for (const key of [...counts.keys()].sort()) {
65
+ files[key] = counts.get(key) ?? 0;
66
+ total += files[key];
67
+ }
68
+ return { total, files };
69
+ }
70
+
71
+ /** Every file whose count moved, split by direction. */
72
+ function compare(counts: ReadonlyMap<string, number>, baseline: Baseline) {
73
+ const risen: string[] = [];
74
+ const fallen: string[] = [];
75
+ // A file missing from the baseline has a baseline of zero, so a rename
76
+ // cannot smuggle its colours past the ratchet.
77
+ for (const [file, count] of counts) {
78
+ const allowed = baseline.files[file] ?? 0;
79
+ if (count > allowed) risen.push(`${file}: ${count} colour literals, baseline ${allowed}`);
80
+ if (count < allowed) fallen.push(`${file}: ${count}, baseline ${allowed}`);
81
+ }
82
+ for (const [file, allowed] of Object.entries(baseline.files)) {
83
+ if (!counts.has(file)) fallen.push(`${file}: now 0, baseline ${allowed}`);
84
+ }
85
+ return { risen, fallen };
86
+ }
87
+
88
+ // A rise is a failure. A fall is reported with the command that banks it, because a baseline
89
+ // left high is a budget the next change can spend.
90
+ function verdict(counts: ReadonlyMap<string, number>, baseline: Baseline | undefined): string[] {
91
+ if (baseline === undefined) {
92
+ return [`src/styles/hex-baseline.json is missing. Write it with: ${LOWER_COMMAND}`];
93
+ }
94
+ const { risen, fallen } = compare(counts, baseline);
95
+ if (risen.length > 0) {
96
+ return [
97
+ "Colour literals belong in src/styles/theme.css.",
98
+ ...risen,
99
+ `If these are unavoidable, lower is the only direction: ${LOWER_COMMAND}`,
100
+ ];
101
+ }
102
+ if (fallen.length > 0) return [`Colour literals went down. Bank it: ${LOWER_COMMAND}`, ...fallen];
103
+ return [];
104
+ }
105
+
106
+ function readBaseline(): Baseline | undefined {
107
+ if (!existsSync(BASELINE_PATH)) return undefined;
108
+ return JSON.parse(readFileSync(BASELINE_PATH, "utf8")) as Baseline;
109
+ }
110
+
111
+ describe("colour literal counter", () => {
112
+ it("counts a hex in a style object, in canvas code and in a stylesheet", () => {
113
+ expect(countColorLiterals(`<div style={{ background: "#161618" }} />`)).toBe(1);
114
+ expect(countColorLiterals(`context.fillStyle = "#090b0e";`)).toBe(1);
115
+ expect(countColorLiterals(`.ring { box-shadow: 0 0 0 1px rgb(110 231 183); }`)).toBe(1);
116
+ expect(countColorLiterals(`a { color: rgba(0, 0, 0, 0.5); border-color: hsl(0 0% 4%); }`)).toBe(
117
+ 2,
118
+ );
119
+ });
120
+
121
+ it("does not mistake a fragment or an issue number for a colour", () => {
122
+ // Known limit: an identifier that is exactly 3, 4, 6 or 8 hex characters
123
+ // in code reads as a colour; the baseline absorbs it.
124
+ expect(countColorLiterals(`href="#section-two" // see #12345`)).toBe(0);
125
+ });
126
+
127
+ it("keeps a colour that follows comment-shaped text inside a string", () => {
128
+ expect(countColorLiterals(`accept="image/*"; const c = "#161618"; x = "*/";`)).toBe(1);
129
+ expect(countColorLiterals("const u = `x//y`; const c = '#161618';")).toBe(1);
130
+ expect(countColorLiterals(`.a { background: url(//cdn.test/a.png) #161618; }`, false)).toBe(1);
131
+ });
132
+
133
+ it("ignores a colour-shaped issue number in a line or block comment", () => {
134
+ expect(countColorLiterals(`const a = 1; // decision (#2291)`)).toBe(0);
135
+ expect(countColorLiterals(`/** see #2291 and rgba(0,0,0,1) */\nconst a = 1;`)).toBe(0);
136
+ expect(countColorLiterals(`fetch("https://x.test/#abc"); const c = "#161618"; // #2291`)).toBe(
137
+ 2,
138
+ );
139
+ });
140
+
141
+ it("leaves the token sources and the tests out of the scan", () => {
142
+ expect(isScanned("packages/studio/src/styles/theme.css")).toBe(false);
143
+ expect(isScanned("packages/studio/src/styles/tailwind-preset.shared.js")).toBe(false);
144
+ expect(isScanned("packages/studio/src/styles/theme.test.ts")).toBe(false);
145
+ expect(isScanned("packages/studio/src/components/ui/Button.tsx")).toBe(true);
146
+ });
147
+ });
148
+
149
+ describe("hex ratchet", () => {
150
+ const baseline: Baseline = { total: 2, files: { "a.tsx": 2 } };
151
+
152
+ it("fails when a file's count rises, naming the file and both numbers", () => {
153
+ // Covers AE2.
154
+ const message = verdict(new Map([["a.tsx", 3]]), baseline).join("\n");
155
+
156
+ expect(message).toContain("a.tsx: 3 colour literals, baseline 2");
157
+ expect(message).toContain(LOWER_COMMAND);
158
+ });
159
+
160
+ it("passes when a count falls and says how to bank it", () => {
161
+ const message = verdict(new Map([["a.tsx", 1]]), baseline).join("\n");
162
+
163
+ expect(message).toContain("Colour literals went down");
164
+ expect(message).toContain(LOWER_COMMAND);
165
+ });
166
+
167
+ it("treats a file the baseline has never seen as a baseline of zero", () => {
168
+ const empty: Baseline = { total: 0, files: {} };
169
+
170
+ expect(verdict(new Map([["new.tsx", 0]]), empty)).toEqual([]);
171
+ expect(verdict(new Map([["new.tsx", 1]]), empty).join("\n")).toContain(
172
+ "new.tsx: 1 colour literals, baseline 0",
173
+ );
174
+ });
175
+
176
+ it("round-trips: a baseline written from a scan accepts that same scan", () => {
177
+ const counts = new Map([
178
+ ["b.css", 4],
179
+ ["a.tsx", 2],
180
+ ]);
181
+ const written = toBaseline(counts);
182
+
183
+ expect(Object.keys(written.files)).toEqual(["a.tsx", "b.css"]);
184
+ expect(written.total).toBe(6);
185
+ expect(verdict(counts, written)).toEqual([]);
186
+ });
187
+
188
+ it("fails with the flag named when there is no baseline at all", () => {
189
+ expect(verdict(new Map([["a.tsx", 0]]), undefined).join("\n")).toContain(`${WRITE_FLAG}=1`);
190
+ });
191
+
192
+ it("holds Studio at or below its committed colour-literal baseline", () => {
193
+ const counts = scan();
194
+ if (process.env[WRITE_FLAG] === "1") {
195
+ writeFileSync(BASELINE_PATH, `${JSON.stringify(toBaseline(counts), null, 2)}\n`);
196
+ }
197
+ const committed = readBaseline();
198
+
199
+ expect(verdict(counts, committed)).toEqual([]);
200
+ });
201
+ });
@@ -0,0 +1,20 @@
1
+ import type { IconWeight } from "@phosphor-icons/react";
2
+
3
+ const WEIGHTS = ["thin", "light", "regular", "bold", "fill", "duotone"] as const;
4
+
5
+ function asWeight(value: string): IconWeight | null {
6
+ return (WEIGHTS as readonly string[]).includes(value) ? (value as IconWeight) : null;
7
+ }
8
+
9
+ /** Studio's Phosphor defaults, read off `theme.css` so size and weight have one
10
+ * owner instead of a CSS variable and a JS constant that drift. A missing or
11
+ * unrecognized value falls back to Phosphor's own default. */
12
+ export function readIconTokens(root: Element = document.documentElement): {
13
+ size: string;
14
+ weight: IconWeight;
15
+ } {
16
+ const styles = getComputedStyle(root);
17
+ const size = styles.getPropertyValue("--icon-size").trim();
18
+ const weight = asWeight(styles.getPropertyValue("--icon-weight").trim());
19
+ return { size: size || "1em", weight: weight ?? "regular" };
20
+ }
@@ -0,0 +1,6 @@
1
+ /* Studio's own app clears Tailwind's stock palette so only Studio tokens are
2
+ * reachable from markup. Kept out of theme.css so a host importing the tokens
3
+ * keeps its own colours. Must be imported before theme.css. */
4
+ @theme static {
5
+ --color-*: initial;
6
+ }