@micha.bigler/ui-core-micha 3.14.4 → 4.0.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 (341) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +7 -0
  3. package/dist/chat/kitSxRegistry.js +9 -0
  4. package/dist/components/charts/kitSxRegistry.js +6 -0
  5. package/dist/entries/charts.js +6 -0
  6. package/dist/entries/chat.js +8 -0
  7. package/dist/entries/markdown.js +1 -0
  8. package/dist/index.js +0 -14
  9. package/dist/pages/AccountPage.js +1 -2
  10. package/dist/pages/LoginPage.js +1 -2
  11. package/dist/pages/PasswordChangePage.js +1 -2
  12. package/dist/pages/PasswordInvitePage.js +1 -2
  13. package/dist/pages/PasswordResetRequestPage.js +1 -2
  14. package/dist/pages/SignUpPage.js +1 -2
  15. package/dist/pages/SignupConfirmPage.js +1 -2
  16. package/dist/theme/kitSxEntry.js +11 -0
  17. package/dist/theme/kitSxRegistry.js +2 -15
  18. package/package.json +32 -7
  19. package/.github/workflows/ci.yml +0 -35
  20. package/.github/workflows/publish.yml +0 -126
  21. package/DESIGN.md +0 -60
  22. package/WORK_ORDERS.md +0 -150
  23. package/dev/entries.jsx +0 -721
  24. package/dev/fixtures.js +0 -26
  25. package/dev/main.jsx +0 -75
  26. package/dev/mockTransport.jsx +0 -128
  27. package/docs/CHART-LAYOUT.md +0 -157
  28. package/docs/DARK-TOKENS.md +0 -140
  29. package/docs/TYPE-SCALE.md +0 -111
  30. package/docs/messaging-deviations.md +0 -118
  31. package/index.html +0 -12
  32. package/renovate.json +0 -10
  33. package/scripts/chart-api-census.mjs +0 -415
  34. package/src/auth/AuthContext.jsx +0 -158
  35. package/src/auth/ReauthModal.jsx +0 -82
  36. package/src/auth/UserMenu.jsx +0 -121
  37. package/src/auth/apiClient.jsx +0 -232
  38. package/src/auth/authApi.jsx +0 -539
  39. package/src/auth/authConfig.jsx +0 -34
  40. package/src/auth/reauth.js +0 -47
  41. package/src/chat/ChatComposer.jsx +0 -36
  42. package/src/chat/ChatList.jsx +0 -87
  43. package/src/chat/ChatThread.jsx +0 -128
  44. package/src/chat/ChatWorkspace.jsx +0 -244
  45. package/src/chat/MarkdownReply.jsx +0 -22
  46. package/src/chat/ModelPicker.jsx +0 -85
  47. package/src/chat/api.js +0 -18
  48. package/src/chat/useChatSocket.js +0 -32
  49. package/src/chat/wire.js +0 -7
  50. package/src/components/AccessCodeManager.jsx +0 -288
  51. package/src/components/AccessCodeSingleUseToggle.jsx +0 -92
  52. package/src/components/AllowedEmailDomainsManager.jsx +0 -92
  53. package/src/components/AuthFactorRequirementCard.jsx +0 -95
  54. package/src/components/BulkInviteCsvTab.jsx +0 -198
  55. package/src/components/EmptyState.jsx +0 -38
  56. package/src/components/LoginForm.jsx +0 -159
  57. package/src/components/MFAComponent.jsx +0 -350
  58. package/src/components/MfaLoginComponent.jsx +0 -252
  59. package/src/components/MobileBottomNav.jsx +0 -82
  60. package/src/components/PasskeysComponent.jsx +0 -252
  61. package/src/components/PasswordChangeForm.jsx +0 -87
  62. package/src/components/PasswordResetRequestForm.jsx +0 -43
  63. package/src/components/PasswordSetForm.jsx +0 -97
  64. package/src/components/ProfileComponent.jsx +0 -348
  65. package/src/components/QrSignupManager.jsx +0 -404
  66. package/src/components/QrSignupValidityManager.jsx +0 -106
  67. package/src/components/RegistrationMethodsManager.jsx +0 -150
  68. package/src/components/SecurityComponent.jsx +0 -141
  69. package/src/components/SocialLoginButtons.jsx +0 -83
  70. package/src/components/SoftChip.jsx +0 -93
  71. package/src/components/StatTile.jsx +0 -39
  72. package/src/components/SupportRecoveryRequestsTab.jsx +0 -482
  73. package/src/components/TurnstileWidget.jsx +0 -99
  74. package/src/components/UserInviteComponent.jsx +0 -76
  75. package/src/components/UserListComponent.jsx +0 -618
  76. package/src/components/charts/BarChart.jsx +0 -102
  77. package/src/components/charts/ChartFrame.jsx +0 -214
  78. package/src/components/charts/LineChart.jsx +0 -126
  79. package/src/components/charts/ScatterChart.jsx +0 -299
  80. package/src/components/charts/TimeSeriesChart.jsx +0 -300
  81. package/src/components/charts/chartDefaults.js +0 -644
  82. package/src/components/charts/chartLabels.js +0 -36
  83. package/src/components/charts/exportChart.js +0 -258
  84. package/src/components/charts/formatters.js +0 -39
  85. package/src/components/charts/palette.js +0 -33
  86. package/src/components/charts/yearTickInterval.js +0 -7
  87. package/src/i18n/authTranslations.ts +0 -2651
  88. package/src/i18n/chartsTranslations.ts +0 -91
  89. package/src/i18n/chatTranslations.ts +0 -55
  90. package/src/i18n/messagingTranslations.ts +0 -120
  91. package/src/i18n/notificationsTranslations.ts +0 -32
  92. package/src/i18n/onboardingTranslations.ts +0 -29
  93. package/src/i18n/sectionNavTranslations.ts +0 -20
  94. package/src/i18n/uiCoreTranslations.js +0 -29
  95. package/src/i18n/userMenuTranslations.ts +0 -5
  96. package/src/index.js +0 -158
  97. package/src/layout/PageLayout.jsx +0 -36
  98. package/src/layout/SectionNav.jsx +0 -320
  99. package/src/messaging/AttachmentList.jsx +0 -145
  100. package/src/messaging/Composer.jsx +0 -197
  101. package/src/messaging/ConversationLaunchers.jsx +0 -51
  102. package/src/messaging/ConversationList.jsx +0 -109
  103. package/src/messaging/DirectMessageLauncher.jsx +0 -112
  104. package/src/messaging/MessageBubble.jsx +0 -183
  105. package/src/messaging/MessagingProvider.jsx +0 -0
  106. package/src/messaging/MessagingScopeConfig.jsx +0 -24
  107. package/src/messaging/PollCard.jsx +0 -163
  108. package/src/messaging/ReactionBar.jsx +0 -43
  109. package/src/messaging/ReadTicks.jsx +0 -82
  110. package/src/messaging/Thread.jsx +0 -195
  111. package/src/messaging/api.js +0 -48
  112. package/src/messaging/composerImageCompression.js +0 -35
  113. package/src/messaging/linkifyText.jsx +0 -37
  114. package/src/notifications/NotificationBell.jsx +0 -84
  115. package/src/notifications/NotificationSettings.jsx +0 -330
  116. package/src/notifications/NotificationsProvider.jsx +0 -224
  117. package/src/notifications/PopupSurface.jsx +0 -105
  118. package/src/notifications/api.js +0 -55
  119. package/src/notifications/feedApi.js +0 -18
  120. package/src/notifications/realtime.jsx +0 -138
  121. package/src/notifications/serviceWorker/sw.js +0 -50
  122. package/src/onboarding/OnboardingProvider.jsx +0 -326
  123. package/src/onboarding/OnboardingWizard.jsx +0 -61
  124. package/src/onboarding/WizardDialogShell.jsx +0 -51
  125. package/src/onboarding/api.js +0 -13
  126. package/src/onboarding/stepSelection.js +0 -16
  127. package/src/onboarding/steps/BrowserPushStep.jsx +0 -121
  128. package/src/onboarding/steps/CompleteNameStep.jsx +0 -46
  129. package/src/onboarding/steps/CookieConsentStep.jsx +0 -133
  130. package/src/onboarding/steps/PwaInstallStep.jsx +0 -47
  131. package/src/pages/AccountPage.jsx +0 -394
  132. package/src/pages/LoginPage.jsx +0 -343
  133. package/src/pages/PasswordChangePage.jsx +0 -62
  134. package/src/pages/PasswordInvitePage.jsx +0 -177
  135. package/src/pages/PasswordResetRequestPage.jsx +0 -68
  136. package/src/pages/SignUpPage.jsx +0 -268
  137. package/src/pages/SignupConfirmPage.jsx +0 -155
  138. package/src/testing/checkKitIntegration.js +0 -233
  139. package/src/theme/createAppTheme.js +0 -423
  140. package/src/theme/fonts.js +0 -3
  141. package/src/theme/index.js +0 -21
  142. package/src/theme/kitSxRegistry.js +0 -285
  143. package/src/theme/themeCompleteness.js +0 -1045
  144. package/src/theme/tokens.js +0 -478
  145. package/src/utils/auth-errors.js +0 -76
  146. package/src/utils/authService.js +0 -223
  147. package/src/utils/resolveErrorText.js +0 -28
  148. package/src/utils/webauthn.js +0 -51
  149. package/tests/AccountPage.profileStatements.test.jsx +0 -162
  150. package/tests/AccountPage.test.jsx +0 -200
  151. package/tests/AuthContext.test.jsx +0 -129
  152. package/tests/BarChart.test.jsx +0 -112
  153. package/tests/BrowserPushStep.test.jsx +0 -110
  154. package/tests/ChartFrame.test.jsx +0 -336
  155. package/tests/ConversationList.test.jsx +0 -144
  156. package/tests/CookieConsentStep.test.jsx +0 -69
  157. package/tests/EmptyState.test.jsx +0 -62
  158. package/tests/LineChart.test.jsx +0 -80
  159. package/tests/MessagingProvider.test.jsx +0 -50
  160. package/tests/MobileBottomNav.test.jsx +0 -319
  161. package/tests/NotificationBell.test.jsx +0 -60
  162. package/tests/NotificationSettings.test.jsx +0 -276
  163. package/tests/NotificationsProvider.test.jsx +0 -240
  164. package/tests/OnboardingProvider.test.jsx +0 -294
  165. package/tests/OnboardingWizard.test.jsx +0 -143
  166. package/tests/PasswordChangeForm.test.jsx +0 -70
  167. package/tests/PasswordInvitePage.test.jsx +0 -142
  168. package/tests/PasswordSetForm.test.jsx +0 -70
  169. package/tests/PopupSurface.test.jsx +0 -142
  170. package/tests/ProfileComponent.refresh.test.jsx +0 -194
  171. package/tests/QrSignupManager.registrationContext.test.jsx +0 -106
  172. package/tests/QrSignupManagerInfoBox.test.jsx +0 -49
  173. package/tests/ScatterChart.test.jsx +0 -270
  174. package/tests/SectionNav.test.jsx +0 -363
  175. package/tests/SignUpPage.test.jsx +0 -208
  176. package/tests/SoftChip.test.jsx +0 -105
  177. package/tests/StatTile.test.jsx +0 -57
  178. package/tests/Thread.test.jsx +0 -151
  179. package/tests/TimeSeriesChart.test.jsx +0 -367
  180. package/tests/UserListComponent.test.jsx +0 -192
  181. package/tests/UserMenu.test.jsx +0 -171
  182. package/tests/apiClientLanguage.test.js +0 -33
  183. package/tests/apiClientPublicPaths.test.js +0 -178
  184. package/tests/authApiLogin.test.js +0 -42
  185. package/tests/authApiRegistration.test.js +0 -31
  186. package/tests/authErrorCodeRendering.test.jsx +0 -77
  187. package/tests/authErrors.test.js +0 -74
  188. package/tests/authTranslations.test.js +0 -74
  189. package/tests/chart-api-census.test.js +0 -366
  190. package/tests/chartAxisTickBand.test.jsx +0 -95
  191. package/tests/chartDefaults.test.js +0 -902
  192. package/tests/chartLabels.test.js +0 -50
  193. package/tests/chartsPalette.test.js +0 -99
  194. package/tests/chatApi.test.js +0 -48
  195. package/tests/chatDriverNames.test.jsx +0 -71
  196. package/tests/chatExports.test.js +0 -3
  197. package/tests/chatList.test.jsx +0 -84
  198. package/tests/chatPicker.test.jsx +0 -82
  199. package/tests/chatSocket.test.jsx +0 -26
  200. package/tests/chatThread.test.jsx +0 -116
  201. package/tests/chatTranslations.test.js +0 -47
  202. package/tests/chatWorkspace.test.jsx +0 -234
  203. package/tests/checkKitIntegration.test.jsx +0 -219
  204. package/tests/createAppTheme.test.js +0 -132
  205. package/tests/createAppThemeDark.test.js +0 -127
  206. package/tests/createAppThemeLightGuard.test.js +0 -40
  207. package/tests/createAppThemePageContrast.test.js +0 -108
  208. package/tests/createAppThemeTypography.test.js +0 -53
  209. package/tests/errorCallSiteGuard.test.js +0 -32
  210. package/tests/exportChart.test.js +0 -313
  211. package/tests/feedApi.test.js +0 -46
  212. package/tests/fixtures/light-theme-baseline.json +0 -2234
  213. package/tests/i18nAggregate.test.js +0 -120
  214. package/tests/i18nGermanVariant.test.js +0 -65
  215. package/tests/loginPageErrorResolution.test.jsx +0 -102
  216. package/tests/messagingApi.test.js +0 -32
  217. package/tests/messagingChunk6.test.jsx +0 -116
  218. package/tests/messagingCompactBubble.test.jsx +0 -64
  219. package/tests/messagingComposer.test.jsx +0 -136
  220. package/tests/messagingComposerChunk5.test.jsx +0 -113
  221. package/tests/messagingContractConformance.test.js +0 -69
  222. package/tests/messagingDirectMessageLauncher.test.jsx +0 -151
  223. package/tests/messagingExports.test.js +0 -21
  224. package/tests/messagingInteractions.test.jsx +0 -136
  225. package/tests/messagingLastMessagePreview.test.jsx +0 -93
  226. package/tests/messagingLinkify.test.jsx +0 -51
  227. package/tests/messagingMessageActions.test.jsx +0 -163
  228. package/tests/messagingMsg6.test.jsx +0 -105
  229. package/tests/messagingMsg6f.test.jsx +0 -155
  230. package/tests/messagingMsg6g.test.jsx +0 -166
  231. package/tests/messagingMsg6h.test.jsx +0 -136
  232. package/tests/messagingMsg6i.test.jsx +0 -129
  233. package/tests/messagingPollRendering.test.jsx +0 -142
  234. package/tests/messagingReactionMerge.test.jsx +0 -77
  235. package/tests/messagingReadTicksTooltip.test.jsx +0 -36
  236. package/tests/messagingThreadReceipts.test.jsx +0 -178
  237. package/tests/messagingThreadingQuoting.test.jsx +0 -87
  238. package/tests/messagingUnreadLifecycle.test.jsx +0 -78
  239. package/tests/notificationsApi.test.js +0 -41
  240. package/tests/notificationsExports.test.js +0 -34
  241. package/tests/onboardingDescriptors.test.js +0 -132
  242. package/tests/packageTreeShaking.test.js +0 -126
  243. package/tests/realtime.test.jsx +0 -219
  244. package/tests/resolveErrorText.test.js +0 -72
  245. package/tests/stepSelection.test.js +0 -53
  246. package/tests/themeCompleteness.test.js +0 -626
  247. package/tests/timeSeriesLegend.test.js +0 -99
  248. package/tests/userScreenTranslations.test.jsx +0 -218
  249. package/tsconfig.build.json +0 -18
  250. package/vite.config.mjs +0 -6
  251. package/work-orders/AUTH-1.md +0 -74
  252. package/work-orders/AUTH-2.md +0 -74
  253. package/work-orders/AUTH-4.md +0 -183
  254. package/work-orders/AUTH-5.md +0 -154
  255. package/work-orders/AUTH-7.md +0 -181
  256. package/work-orders/AUTH-8.md +0 -184
  257. package/work-orders/CHART-1.md +0 -285
  258. package/work-orders/CHART-2.md +0 -222
  259. package/work-orders/CHART-3.md +0 -163
  260. package/work-orders/CHART-4.md +0 -203
  261. package/work-orders/CHART-5.md +0 -226
  262. package/work-orders/CHART-6.md +0 -428
  263. package/work-orders/CHART-7.md +0 -175
  264. package/work-orders/DX-1.md +0 -169
  265. package/work-orders/DX-2.md +0 -188
  266. package/work-orders/DX-3.md +0 -195
  267. package/work-orders/I18N-1.md +0 -254
  268. package/work-orders/I18N-3.md +0 -207
  269. package/work-orders/MSG-3.md +0 -375
  270. package/work-orders/MSG-3b.md +0 -1002
  271. package/work-orders/MSG-3c.md +0 -378
  272. package/work-orders/MSG-3d.md +0 -268
  273. package/work-orders/MSG-3e.md +0 -164
  274. package/work-orders/MSG-3f.md +0 -391
  275. package/work-orders/MSG-6e.md +0 -308
  276. package/work-orders/MSG-6f.md +0 -214
  277. package/work-orders/MSG-6g.md +0 -244
  278. package/work-orders/MSG-6h.md +0 -238
  279. package/work-orders/MSG-6i.md +0 -271
  280. package/work-orders/MSG-6j.md +0 -117
  281. package/work-orders/NOTIF-12.md +0 -214
  282. package/work-orders/NOTIF-13.md +0 -142
  283. package/work-orders/NOTIF-14.md +0 -151
  284. package/work-orders/PRIM-1.md +0 -177
  285. package/work-orders/SHELL-1.md +0 -255
  286. package/work-orders/SHELL-2.md +0 -197
  287. package/work-orders/SHELL-3.md +0 -359
  288. package/work-orders/SHELL-4.md +0 -351
  289. package/work-orders/SHELL-5.md +0 -345
  290. package/work-orders/SHELL-6.md +0 -210
  291. package/work-orders/THEME-1.md +0 -587
  292. package/work-orders/THEME-10.md +0 -303
  293. package/work-orders/THEME-11.md +0 -208
  294. package/work-orders/THEME-2.md +0 -339
  295. package/work-orders/THEME-3.md +0 -210
  296. package/work-orders/THEME-4.md +0 -271
  297. package/work-orders/THEME-5.md +0 -341
  298. package/work-orders/THEME-6.md +0 -247
  299. package/work-orders/THEME-7.md +0 -82
  300. package/work-orders/THEME-8.md +0 -211
  301. package/work-orders/THEME-9.md +0 -194
  302. package/work-orders/UCM-AUTH-10.md +0 -490
  303. package/work-orders/UCM-AUTH-11.md +0 -185
  304. package/work-orders/UCM-AUTH-12.md +0 -98
  305. package/work-orders/UCM-AUTH-13.md +0 -47
  306. package/work-orders/UCM-AUTH-9.md +0 -207
  307. package/work-orders/UCM-CHART-10.md +0 -150
  308. package/work-orders/UCM-CHART-11.md +0 -132
  309. package/work-orders/UCM-CHART-12.md +0 -348
  310. package/work-orders/UCM-CHART-13.md +0 -122
  311. package/work-orders/UCM-CHART-14.md +0 -198
  312. package/work-orders/UCM-CHART-15.md +0 -187
  313. package/work-orders/UCM-CHART-16.md +0 -169
  314. package/work-orders/UCM-CHART-17.md +0 -195
  315. package/work-orders/UCM-CHART-18.md +0 -200
  316. package/work-orders/UCM-CHART-8.md +0 -147
  317. package/work-orders/UCM-CHART-9.md +0 -265
  318. package/work-orders/UCM-CHAT-1.md +0 -190
  319. package/work-orders/UCM-CHAT-2.md +0 -75
  320. package/work-orders/UCM-CI-1.md +0 -65
  321. package/work-orders/UCM-DEP-1.md +0 -63
  322. package/work-orders/UCM-I18N-4.md +0 -226
  323. package/work-orders/UCM-I18N-5.md +0 -211
  324. package/work-orders/UCM-MSG-20.md +0 -192
  325. package/work-orders/UCM-PRIM-2.md +0 -114
  326. package/work-orders/UCM-SHELL-7.md +0 -134
  327. package/work-orders/UCM-SHELL-8.md +0 -125
  328. package/work-orders/UCM-TEST-1.md +0 -304
  329. package/work-orders/UCM-THEME-13.md +0 -69
  330. package/work-orders/UCM-THEME-14.md +0 -312
  331. package/work-orders/UCM-THEME-15.md +0 -356
  332. package/work-orders/UCM-THEME-16.md +0 -80
  333. package/work-orders/UCM-THEME-17.md +0 -111
  334. package/work-orders/UCM-THEME-18.md +0 -184
  335. package/work-orders/assets/AUTH-4-user-list-card.html +0 -407
  336. package/work-orders/assets/PRIM-1-dashboard-primitives.html +0 -624
  337. package/work-orders/assets/SHELL-2-account-navigation.html +0 -1159
  338. package/work-orders/assets/THEME-1-baseline-reference-sheet.html +0 -1352
  339. package/work-orders/assets/UCM-CHAT-1-ai-chat.html +0 -447
  340. package/work-orders/assets/UCM-THEME-13-dark-prototype.html +0 -390
  341. package/work-orders/assets/UCM-THEME-17-type-scale.html +0 -391
@@ -1,644 +0,0 @@
1
- export const DEFAULT_LEGEND_POSITION = {
2
- vertical: 'bottom',
3
- horizontal: 'start',
4
- };
5
-
6
- export function withGridDefaults(grid) {
7
- return { horizontal: true, ...grid };
8
- }
9
-
10
- export function withAxisDefaults(axes, label, defaults, tickFontSize) {
11
- const values = axes?.length ? axes : [defaults];
12
- return values.map((axis, index) => ({
13
- // `defaults.scaleType` (and any other scale-defining default) must
14
- // survive even when the caller passes their OWN xAxis/yAxis array --
15
- // confirmed live in the dev harness: a caller-supplied axis with `data`
16
- // but no `scaleType` previously lost 'point'/'band' entirely (only
17
- // applied when the whole array was absent), leaving MUI's scale
18
- // undefined and every point position NaN (`<path> attribute d:
19
- // Expected number, "MNaN,..."` in the real rendered SVG -- a mocked
20
- // prop-assertion test cannot catch this, it only reproduces on an
21
- // actual MUI scale computation).
22
- ...defaults,
23
- ...axis,
24
- tickLabelStyle: {
25
- fontSize: tickFontSize,
26
- ...axis.tickLabelStyle,
27
- },
28
- // THEME-9: the single `label` argument is the caller's ONE string for
29
- // this whole axis family (xAxisLabel/yAxisLabel) -- it may only ever
30
- // fall back onto ONE axis. Applying it via .map() to every entry
31
- // broadcast the identical label onto every axis in a multi-axis array
32
- // (measured live: hram's dual-axis chart carried the same 43-character
33
- // label on both y-axes). Only the first entry -- the common single-axis
34
- // case is index 0 -- gets the fallback; a caller building a genuine
35
- // multi-axis array must label each entry itself (as TimeSeriesChart's
36
- // own secondary-axis support already does), exactly like MUI's own
37
- // default `position: 'left'` (only the first entry, MUI docs).
38
- label: axis.label || (index === 0 ? label : undefined),
39
- }));
40
- }
41
-
42
- function cssLengthToPixels(value) {
43
- if (typeof value === 'number') return value;
44
- if (typeof value !== 'string') return 0;
45
-
46
- const parsed = Number.parseFloat(value);
47
- if (!Number.isFinite(parsed)) return 0;
48
- if (value.endsWith('rem') || value.endsWith('em')) return parsed * 16;
49
- return parsed;
50
- }
51
-
52
- const MUI_LABELLED_X_AXIS_HEIGHT = 45;
53
- // Still used by `sizeYAxisForContent` (THEME-9, y-axis width) below -- unrelated to CHART-11,
54
- // which only replaces how the ROTATED x-axis path (`rotatedTickMetrics`) estimates width.
55
- const AVERAGE_GLYPH_WIDTH_EM = 0.6;
56
-
57
- // CHART-11: per-character width table (em units), the ESTIMATE fallback used only when no DOM is
58
- // available to actually measure a tick label's rendered extent (SSR, or a test environment where
59
- // `measureTickTextWidthPx` returns null -- jsdom does not implement real SVG text layout). A
60
- // single flat multiplier (the pre-CHART-11 constant, `AVERAGE_GLYPH_WIDTH_EM = 0.6`) systematically
61
- // overestimated real ward-name-shaped strings: they mix spaces, apostrophes and narrow lowercase
62
- // letters ("Mang'ula", "Msolwa Station") with a few wide/uppercase ones, and a flat multiplier
63
- // prices every character at the wide end. Three tiers, not a full per-glyph font metrics table --
64
- // proportionate to "an estimate", not a replacement for real measurement.
65
- const NARROW_GLYPH_WIDTH_EM = 0.28; // i l j I ' . , : ; ! | and space
66
- const WIDE_GLYPH_WIDTH_EM = 0.78; // uppercase (except I), plus lowercase m/w
67
- const DEFAULT_GLYPH_WIDTH_EM = 0.52; // everything else -- most lowercase letters and digits
68
- const NARROW_GLYPH_CHARS = new Set(["i", "l", "j", "I", "'", ".", ",", ":", ";", "!", "|", " "]);
69
- const WIDE_GLYPH_CHARS = new Set(["m", "w"]);
70
-
71
- function glyphWidthEm(char) {
72
- if (NARROW_GLYPH_CHARS.has(char)) return NARROW_GLYPH_WIDTH_EM;
73
- if (WIDE_GLYPH_CHARS.has(char) || (char >= 'A' && char <= 'Z')) return WIDE_GLYPH_WIDTH_EM;
74
- return DEFAULT_GLYPH_WIDTH_EM;
75
- }
76
-
77
- /**
78
- * CHART-11 ESTIMATE fallback (declared explicitly, not dressed up as a measurement): sums a
79
- * per-character width table instead of applying one flat multiplier to the whole string. Only
80
- * reached when `measureTickTextWidthPx` returns null.
81
- */
82
- function estimateTextWidthPx(text, fontSizePx) {
83
- let widthEm = 0;
84
- for (const char of text) widthEm += glyphWidthEm(char);
85
- return widthEm * fontSizePx;
86
- }
87
-
88
- let hiddenMeasurementTextNode;
89
-
90
- function getHiddenMeasurementTextNode() {
91
- if (typeof document === 'undefined') return null;
92
- if (hiddenMeasurementTextNode && hiddenMeasurementTextNode.isConnected) {
93
- return hiddenMeasurementTextNode;
94
- }
95
- const svgNS = 'http://www.w3.org/2000/svg';
96
- const svg = document.createElementNS(svgNS, 'svg');
97
- svg.setAttribute('aria-hidden', 'true');
98
- svg.style.position = 'absolute';
99
- svg.style.width = '0';
100
- svg.style.height = '0';
101
- svg.style.overflow = 'hidden';
102
- svg.style.pointerEvents = 'none';
103
- const text = document.createElementNS(svgNS, 'text');
104
- svg.appendChild(text);
105
- (document.body || document.documentElement).appendChild(svg);
106
- hiddenMeasurementTextNode = text;
107
- return text;
108
- }
109
-
110
- /**
111
- * CHART-11: measures a tick label's real rendered width via a transient, invisible, shared SVG
112
- * `<text>` node -- the same element type MUI's own axis ticks render as, so glyph metrics match
113
- * whatever font actually cascades to it (this package has no access to the consumer's theme font
114
- * at this call site; the transient node inherits the page's own cascade, same as a real tick
115
- * would). Returns `null` (never throws) when no DOM is available (SSR) or when
116
- * `getComputedTextLength` is unsupported/unreliable there (jsdom does not implement real SVG text
117
- * layout) -- callers fall back to `estimateTextWidthPx` in that case, and tests inject their own
118
- * stub via `spaceForRotatedTicks`'s optional `measureTextWidth` parameter for determinism.
119
- */
120
- function measureTickTextWidthPx(text, fontSizePx) {
121
- try {
122
- const node = getHiddenMeasurementTextNode();
123
- if (!node) return null;
124
- node.setAttribute('font-size', String(fontSizePx));
125
- node.textContent = text;
126
- const length = node.getComputedTextLength();
127
- return Number.isFinite(length) && length > 0 ? length : null;
128
- } catch {
129
- return null;
130
- }
131
- }
132
-
133
- // THEME-11: this package never formed an opinion on chart margins, so every chart fell back to
134
- // MUI's own DEFAULT_MARGINS (20px on all four sides) -- measured live (hram's KPI cards) as 167
135
- // of a 270px-tall chart being furniture. Per-side, not a uniform trim, because each side is for
136
- // something different: `left`/`bottom` are pure padding (the tick labels already live inside
137
- // axisSizeLeft / DEFAULT_AXIS_SIZE_HEIGHT), `top` keeps room for the topmost y-tick label's own
138
- // ~9px overhang past the plot (it is centred on its gridline, not below it), and `right` stays
139
- // less trimmed than `left` because a LINEAR x-axis's last tick label sits AT the edge and
140
- // overhangs by about half its width (~13px at caption size) -- a band axis wouldn't need it, but
141
- // making this conditional on scale type would be inferred behaviour with no named consumer.
142
- //
143
- // UCM-CHART-12 Rule 3: no caller-supplied `margin` exists any more (the model owns spacing
144
- // completely, `resolveChartLayout` builds the final margin object directly) -- this is now only
145
- // the base every side starts from before `resolveChartLayout` adds the legend band to one side.
146
- export const PACKAGE_DEFAULT_MARGIN = {
147
- top: 10, bottom: 8, left: 8, right: 16,
148
- };
149
-
150
- // CHART-11: small clearance between the rotated text block's own projected extent and the
151
- // bottom of the axis's reserved band -- the same order of magnitude as the package's own
152
- // PACKAGE_DEFAULT_MARGIN breathing-room, applied here for the same reason (a hard 0px gap reads
153
- // as clipped even when nothing is actually cut off).
154
- const ROTATED_TICK_CLEARANCE_PX = 8;
155
-
156
- // UCM-CHART-16: the FLAT (angle 0) tick band used to be the unconditional TICK_BAND_BASE_PX
157
- // regardless of the actual tick font size -- fine for MUI's own generic default, but MUI's
158
- // internal fit check (`shortenLabels`, against `tickLabelsMaxHeight`) blanks EVERY tick label the
159
- // moment the real rendered text is even slightly taller than what that flat floor leaves room
160
- // for once MUI's own tick/gap overhead (and, when an axis title shares the band, its own gap) is
161
- // subtracted. `TICK_BAND_BASE_PX` alone was a near-miss with essentially no margin at any font
162
- // size above whatever MUI itself implicitly assumed -- two hram consumers independently patched
163
- // around it with the same empirically-measured `height: 56`, and a third, titleless case broke at
164
- // a tick font just 1px above the untitled default.
165
- //
166
- // These three constants are DERIVED, not guessed: binary-searched against the real, unmocked
167
- // `BarChart` rendered in this repo's own dev harness (`dev/entries.jsx`'s `chart-probe` entry,
168
- // Chrome, this package's own DM Sans default -- the taller of the two fonts checked; MUI's Roboto
169
- // fallback needs visibly less and was never the trigger) at tick font sizes 11-18px, with and
170
- // without an axis title:
171
- // required flat tickBand ~= ceil(fontSize * TICK_TEXT_HEIGHT_FACTOR) + TICK_BAND_MUI_OVERHEAD_PX
172
- // required tickBand+title ~= that, plus TICK_BAND_TITLE_EXTRA_PX, plus AXIS_TITLE_BAND_PX
173
- // matched every measured threshold exactly or over by at most 1px, never under, across ten
174
- // font-size/title combinations -- see UCM-CHART-16's work order for the full measurement table.
175
- const TICK_TEXT_HEIGHT_FACTOR = 1.3;
176
- const TICK_BAND_MUI_OVERHEAD_PX = 9;
177
- const TICK_BAND_TITLE_EXTRA_PX = 2;
178
-
179
- // THEME-9: MUI X-Charts' own default y-axis width is a flat 45px (ticks) +
180
- // 20px (label, if present) = 65px regardless of what the ticks actually say
181
- // -- confirmed against @mui/x-charts' own DEFAULT_AXIS_SIZE_WIDTH /
182
- // AXIS_LABEL_DEFAULT_HEIGHT constants and its `useChartDimensions` selector
183
- // (drawingArea.left = margin.left + axisSizeLeft, i.e. margin and axis size
184
- // are ADDITIVE, not one containing the other). That flat default is both too
185
- // generous for short ticks ("0%"/"50%"/"100%") and too stingy for wide ones
186
- // ("50.0 min"), which is exactly the measured overlap on the Accessibility
187
- // y-axis. Mirrors spaceForRotatedTicks' existing text-width heuristic below.
188
- const Y_AXIS_TICK_SIZE = 6;
189
- const Y_AXIS_TICK_LABEL_GAP = 2;
190
- const Y_AXIS_LABEL_TICK_GAP = 2;
191
- // A rotated (90deg) axis-label's line-box "thickness" -- what it costs in
192
- // WIDTH once rotated -- runs a bit past its raw font size once ascender/
193
- // descender room is included; matches the ~18px measured live for a 14px
194
- // label (14 * 1.3 ~= 18).
195
- const Y_AXIS_LABEL_THICKNESS_FACTOR = 1.3;
196
- // A small safety margin on the estimate: `spaceForRotatedTicks`'s own risk
197
- // note applies here too -- a too-tight cut recreates the overlap it was
198
- // meant to fix, and glyph-width heuristics are approximate by nature.
199
- const Y_AXIS_WIDTH_SAFETY_FACTOR = 1.1;
200
- // MUI X-Charts' ChartsYAxisImpl hardcodes the axis-label font at 14px
201
- // (`additionalProps.style: {...theme.typography.body1, fontSize: 14, ...}`
202
- // -- fontSize:14 is set AFTER the theme.typography.body1 spread, so it wins
203
- // regardless of what body1 actually resolves to). Matching that literal
204
- // constant here -- rather than reading it off `theme.typography.body1` --
205
- // keeps this sizing correct even if this package's body1 size ever changes;
206
- // coupling it to body1 was only ever coincidentally right because body1
207
- // happens to be 14px today.
208
- const MUI_AXIS_LABEL_FONT_SIZE_PX = 14;
209
-
210
- function longestFormattedTick(values, valueFormatter) {
211
- return values.reduce((longest, value) => {
212
- let text;
213
- try {
214
- text = valueFormatter ? String(valueFormatter(value, { location: 'tick' })) : String(value);
215
- } catch {
216
- // Some formatters require MUI's complete scale context; falling back
217
- // to the raw value is conservative -- it reserves at least as much
218
- // space as the unformatted number would need.
219
- text = String(value);
220
- }
221
- return text.length > longest.length ? text : longest;
222
- }, '');
223
- }
224
-
225
- /**
226
- * Sizes a linear y-axis to its actual tick content instead of MUI's flat
227
- * default. Only touches axes the caller left unsized (`axis.width == null`)
228
- * and only linear/numeric ones -- a categorical axis's ticks are names, not
229
- * numbers, and sizing those is not this function's job. Candidate values come
230
- * from the axis' own `min`/`max` when the caller set them (as every current
231
- * consumer does), falling back to the series actually plotted against this
232
- * axis; if neither yields a candidate, the axis is left untouched rather than
233
- * guessed at.
234
- *
235
- * `tickFontSize` is only the FALLBACK -- an axis' own `tickLabelStyle.fontSize`
236
- * (set by `withAxisDefaults`, itself overridable per-axis by the caller) is
237
- * read first, so a caller-tuned tick font is sized correctly rather than
238
- * against the package-wide default. The axis LABEL's font is not a caller
239
- * knob at all (`MUI_AXIS_LABEL_FONT_SIZE_PX` -- MUI hardcodes it).
240
- *
241
- * The series-to-axis match for the fallback deliberately mirrors MUI's own
242
- * default-assignment rule, not a naive `undefined === undefined`: a series
243
- * with no explicit `yAxisId` is assigned to the FIRST axis in the array
244
- * (`yAxisIds[0]` in MUI's own selector), never to "no id". Matching only
245
- * `undefined === undefined` left the primary axis of a real dual-axis chart
246
- * (id `'primary'`, its series never set `yAxisId`) with zero matched series
247
- * and therefore MUI's untouched flat default -- a silent no-op on exactly
248
- * the dual-axis case this WO was measured against.
249
- */
250
- export function sizeYAxisForContent(yAxis, series, tickFontSize) {
251
- const fallbackTickFontSizePx = cssLengthToPixels(tickFontSize);
252
- const defaultAxisId = yAxis[0]?.id;
253
- return yAxis.map((axis) => {
254
- if (axis.width != null) return axis;
255
- if (axis.scaleType && axis.scaleType !== 'linear') return axis;
256
-
257
- const tickFontSizePx = cssLengthToPixels(axis.tickLabelStyle?.fontSize) || fallbackTickFontSizePx;
258
- if (tickFontSizePx === 0) return axis;
259
-
260
- const candidates = [];
261
- if (typeof axis.min === 'number') candidates.push(axis.min);
262
- if (typeof axis.max === 'number') candidates.push(axis.max);
263
- if (candidates.length === 0) {
264
- (series || [])
265
- .filter((item) => (item.yAxisId ?? defaultAxisId) === axis.id)
266
- .forEach((item) => (item.data || []).forEach((value) => {
267
- if (typeof value === 'number') candidates.push(value);
268
- }));
269
- }
270
- if (candidates.length === 0) return axis;
271
-
272
- const longestTick = longestFormattedTick(candidates, axis.valueFormatter);
273
- // UCM-CHART-12 Rule 2 (codex second-pass review finding): candidates existed, but a caller
274
- // `valueFormatter` reduced every one of them to a blank string -- this is the y-axis analogue
275
- // of `xAxisHasVisibleTicks`'s Access-scatter case, and must collapse the same way. Distinct
276
- // from `candidates.length === 0` above (nothing to measure at all, MUI's own default applies,
277
- // untouched): here there WAS content, it just renders as nothing. With no axis title either,
278
- // the axis has genuinely nothing to show, so the whole band collapses to 0 -- not merely the
279
- // text-width term floored, matching the x-axis band's own all-or-nothing zero.
280
- if (longestTick.length === 0 && !axis.label) return { ...axis, width: 0 };
281
- const tickTextWidth = longestTick.length === 0 ? 0 : Math.max(
282
- tickFontSizePx,
283
- longestTick.length * tickFontSizePx * AVERAGE_GLYPH_WIDTH_EM,
284
- );
285
- const labelThickness = axis.label
286
- ? Math.ceil(MUI_AXIS_LABEL_FONT_SIZE_PX * Y_AXIS_LABEL_THICKNESS_FACTOR) + Y_AXIS_LABEL_TICK_GAP
287
- : 0;
288
- const width = Math.ceil(
289
- (tickTextWidth + Y_AXIS_TICK_SIZE + Y_AXIS_TICK_LABEL_GAP + labelThickness)
290
- * Y_AXIS_WIDTH_SAFETY_FACTOR,
291
- );
292
-
293
- return { ...axis, width };
294
- });
295
- }
296
-
297
- /**
298
- * Locale-correct, unit-free tick numbers as the default -- BarChart set none
299
- * at all (raw numbers straight from the domain), and a hand-rolled `Intl`
300
- * compact-notation default was measured to be actively wrong: German does
301
- * not compact thousands at all (`12'500` stays `12'500`) and Swahili prepends
302
- * a word that is WIDER than the raw number (`elfu 12.5`) -- compact notation
303
- * does not solve the width problem and worsens it in one locale. Plain
304
- * grouped formatting (no `notation: 'compact'`) is safe across all four
305
- * locales this package's consumers use. Ticks get a short, rounded form;
306
- * tooltip/legend keep full precision -- teaching screens must not round away
307
- * a meaningful difference. A caller's own `valueFormatter` always wins (this
308
- * is only ever used as a default, per `withAxisDefaults`' `{...defaults,
309
- * ...axis}` merge).
310
- */
311
- export function defaultNumericTickFormatter(locale) {
312
- return (value, context) => (context?.location === 'tick'
313
- ? new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value)
314
- : new Intl.NumberFormat(locale).format(value));
315
- }
316
-
317
- // UCM-CHART-15: named size tokens, resolved through the theme's 8px spacing unit
318
- // (`src/theme/tokens.js`'s `spacing: 8`). Through UCM-CHART-12/14, `standard` stayed pinned to
319
- // the pre-existing deployed default (`TimeSeriesChart`'s old `CHART_HEIGHT = 320`) so that
320
- // migration did not also silently redraw every already-shipped default-sized chart -- that pin
321
- // was a migration guardrail, not a design verdict. The migration it protected is complete, and
322
- // the operator judged the whole scale too cramped (`tall` had already forced hram to use the
323
- // `height` px escape to get past the old ceiling), so this WO shifts every token up one step and
324
- // adds two steps above the old `tall`, chosen rather than inherited. Documented plainly in
325
- // `docs/CHART-LAYOUT.md` -- these are visible product decisions, not internal plumbing.
326
- export const CHART_SIZE_SPACING_UNITS = {
327
- compact: 40, standard: 50, tall: 60, extra_tall: 70, super_tall: 80,
328
- };
329
-
330
- // UCM-CHART-12 Rule 2: every band is its own measured content, or zero -- no band is ever a
331
- // constant taken on faith. These two ARE constants, but only for the "content is present" case;
332
- // each one collapses to 0 the moment its content is absent (`resolveXAxisGeometry`,
333
- // `resolveChartLayout` below).
334
- //
335
- // ui_reviewer finding U1: before this WO, a plain (unrotated, untitled) axis got NO explicit
336
- // `axis.height` at all -- MUI computed its own default internally. This resolver now always sets
337
- // `axis.height` explicitly (Rule 1: nothing outside the resolver does chart arithmetic), which
338
- // looked like a silent behaviour change until checked against MUI's own source
339
- // (`@mui/x-charts/constants`): `DEFAULT_AXIS_SIZE_HEIGHT = 25` and `AXIS_LABEL_DEFAULT_HEIGHT =
340
- // 20` -- i.e. `TICK_BAND_BASE_PX`/`AXIS_TITLE_BAND_PX` below are not an approximation, they are
341
- // MUI's own constants, confirmed pixel-for-pixel. Making them explicit changes nothing about what
342
- // renders for that common case; `TICK_BAND_BASE_PX + AXIS_TITLE_BAND_PX` = 45 is also exactly the
343
- // pre-CHART-12 `MUI_LABELLED_X_AXIS_HEIGHT`, decomposed into the two bands Rule 2 requires instead
344
- // of one opaque "labelled axis height" figure.
345
- const TICK_BAND_BASE_PX = 25;
346
- const AXIS_TITLE_BAND_PX = MUI_LABELLED_X_AXIS_HEIGHT - TICK_BAND_BASE_PX;
347
- // One row of legend chips + gap -- MUI does not reserve this space itself (confirmed: nothing in
348
- // this package's pre-CHART-12 margin/axis math ever accounted for the legend), so a visible
349
- // legend growing the SVG uncounted was the sixth defect this WO's Rule 2 also closes.
350
- const LEGEND_BAND_PX = 32;
351
-
352
- // UCM-CHART-12 `xLabels: "auto"`: with no real container-width measurement available before
353
- // render (the presets stay responsive -- MUI measures actual width via ResizeObserver, which this
354
- // pure function cannot see), "auto" is a documented HEURISTIC, not a true fit computation: rotate
355
- // once there are enough categories AND the longest one, MEASURED (same mechanism as the rotated
356
- // projection below -- real DOM measurement when available, the per-glyph estimate otherwise), is
357
- // wide enough that a horizontal row would plausibly collide. Codex second-pass review finding:
358
- // the first cut of this heuristic used a raw character-count threshold instead of the measured
359
- // width this function already has available -- replaced, since "decide from the measured labels"
360
- // (Rule 3) is achievable even without a real container width to compare against. The count
361
- // threshold stays as the OTHER half of the heuristic: a handful of wide labels can still fit
362
- // spread across a wide chart, so width alone should not trigger rotation either.
363
- const AUTO_ROTATE_TICK_COUNT = 6;
364
- const AUTO_ROTATE_LABEL_WIDTH_PX = 50;
365
-
366
- function xAxisTickContentText(axis) {
367
- const data = axis?.data;
368
- if (!Array.isArray(data)) return null; // no data array -- can't tell (e.g. a numeric scale); assume content
369
- return longestFormattedTick(data, axis.valueFormatter);
370
- }
371
-
372
- // The Access-scatter case (WO Part A): an axis whose tick labels are all blank/absent must
373
- // collapse its reserved band to zero, not keep the package's floor. `null` (no data array at
374
- // all -- a linear numeric axis, whose ticks come from the scale, not a caller-supplied list)
375
- // is treated as "assume content", since there is nothing here to measure as empty.
376
- function xAxisHasVisibleTicks(axis) {
377
- const text = xAxisTickContentText(axis);
378
- return text === null || text.length > 0;
379
- }
380
-
381
- /**
382
- * UCM-CHART-12: decides the x-axis tick band's height and rotation angle. Replaces CHART-10/11's
383
- * `spaceForRotatedTicks`/`rotatedTickMetrics` -- same measured-text-width mechanism
384
- * (`measureTickTextWidthPx`, with `estimateTextWidthPx` as the no-DOM fallback and
385
- * `measureTextWidth` as the test injection point), but now driven by `xLabels`
386
- * ("auto"|"horizontal"|"angled") instead of a caller-supplied `tickLabelStyle.angle`, and
387
- * returning 0 outright when the axis has no visible tick content (Rule 2).
388
- */
389
- function resolveXAxisGeometry(axis, xLabelsMode, defaultLineHeight, measureTextWidth, fallbackTickFontSize) {
390
- if (!xAxisHasVisibleTicks(axis)) return { tickBand: 0, angle: 0 };
391
-
392
- const fontSize = cssLengthToPixels(axis?.tickLabelStyle?.fontSize) || cssLengthToPixels(fallbackTickFontSize);
393
- const longestLabel = xAxisTickContentText(axis) || '';
394
- const dataCount = Array.isArray(axis?.data) ? axis.data.length : 0;
395
- const measure = measureTextWidth || measureTickTextWidthPx;
396
-
397
- // Measured once, up front -- both "auto"'s rotation DECISION and the rotated-band PROJECTION
398
- // below need the label's real width, and must agree on what "real" means (same measurement,
399
- // same estimate fallback) or the two could disagree about the same label.
400
- const labelWidthPx = fontSize > 0
401
- ? Math.max(fontSize, (() => {
402
- const measured = measure(longestLabel, fontSize);
403
- return Number.isFinite(measured) && measured > 0 ? measured : estimateTextWidthPx(longestLabel, fontSize);
404
- })())
405
- : 0;
406
-
407
- let angle = 0;
408
- if (xLabelsMode === 'angled') {
409
- angle = Number(axis?.tickLabelStyle?.angle) || -45;
410
- } else if (xLabelsMode === 'auto') {
411
- const needsRotation = dataCount > AUTO_ROTATE_TICK_COUNT && labelWidthPx > AUTO_ROTATE_LABEL_WIDTH_PX;
412
- angle = needsRotation ? -45 : 0;
413
- } // "horizontal" always stays at angle 0
414
-
415
- if (angle === 0) {
416
- // UCM-CHART-16: derive the flat band from the actual tick font size instead of the
417
- // unconditional floor -- `axis?.label` is the SAME signal `resolveChartLayout` uses to decide
418
- // whether an axis title also shares this band (`xTitleBand`), so a titled axis reserves the
419
- // extra room its own gap costs, precisely where the near-miss was.
420
- const hasTitle = Boolean(axis?.label);
421
- const flatTickBand = fontSize > 0
422
- ? Math.ceil(fontSize * TICK_TEXT_HEIGHT_FACTOR) + TICK_BAND_MUI_OVERHEAD_PX
423
- + (hasTitle ? TICK_BAND_TITLE_EXTRA_PX : 0)
424
- : TICK_BAND_BASE_PX;
425
- return { tickBand: Math.max(TICK_BAND_BASE_PX, flatTickBand), angle: 0 };
426
- }
427
- // Rotation is a visual instruction independent of whether a font size is knowable here -- an
428
- // "angled" axis still rotates even when `fontSize` can't be resolved (no theme fallback given),
429
- // it just can't project a tighter band than the floor without one.
430
- if (fontSize === 0) return { tickBand: TICK_BAND_BASE_PX, angle };
431
-
432
- const lineHeight = Number(axis?.tickLabelStyle?.lineHeight) || defaultLineHeight;
433
- const textWidth = labelWidthPx;
434
- const radians = (angle * Math.PI) / 180;
435
- const projectedHeight = Math.abs(Math.cos(radians)) * fontSize * lineHeight
436
- + Math.abs(Math.sin(radians)) * textWidth;
437
- const tickBand = Math.max(TICK_BAND_BASE_PX, Math.ceil(projectedHeight) + ROTATED_TICK_CLEARANCE_PX);
438
- return { tickBand, angle };
439
- }
440
-
441
- function resolveChartHeightPx(size, height, spacing) {
442
- if (height != null) return height;
443
- const units = CHART_SIZE_SPACING_UNITS[size];
444
- if (units == null) {
445
- throw new Error(
446
- `[ui-core-micha] Unknown chart size="${size}". Use "compact" | "standard" | "tall" | `
447
- + '"extra_tall" | "super_tall", or pass height (in px) for the documented escape.',
448
- );
449
- }
450
- // MUI's `theme.spacing(n)` returns a CSS length STRING ("320px"), not a number -- the chart
451
- // height feeds straight into MUI X-Charts' own numeric `height` prop, so it must be unwrapped
452
- // back to a px number via the same `cssLengthToPixels` the rest of this file already uses.
453
- return typeof spacing === 'function' ? cssLengthToPixels(spacing(units)) : units * 8;
454
- }
455
-
456
- /**
457
- * UCM-CHART-12: the one function that owns a chart's geometry (Rule 1-3), replacing
458
- * `resolveChartHeight` + `spaceForRotatedTicks` + `warnOnHeightMismatch`. Returns the COMPLETE
459
- * geometry a preset needs -- wrapper `sx`, chart height, margins, and both resolved axis arrays --
460
- * plus the named `bands` the composition invariant is asserted against:
461
- *
462
- * chartHeight === bands.plotHeight + bands.xAxisBand + bands.xTitleBand + bands.legendBand
463
- * containerWidth === bands.yAxisBand + bands.plotWidth + bands.rightPad (when `containerWidth` is given)
464
- *
465
- * `plotHeight`/`plotWidth` are the RESIDUAL of `chartHeight`/`containerWidth` minus the OTHER
466
- * fields this function itself returns (`margin.top/bottom`, `xAxis[0].height`, `yAxisBand`) --
467
- * by construction, summing them back up can never fail to balance, so that arithmetic identity
468
- * ALONE proves nothing about a `UCM-CHART-10`-shaped bug (a term double-counted between
469
- * `xAxis.height` and `margin.bottom`). What actually catches that class of bug is asserting each
470
- * individual band lands in exactly one place downstream, independently of the others:
471
- * `xAxis[0].height === xAxisBand + xTitleBand` (nothing else may also carry the tick/title
472
- * allowance) and `margin.top + margin.bottom - PACKAGE_DEFAULT_MARGIN.top -
473
- * PACKAGE_DEFAULT_MARGIN.bottom === legendBand` (the ONLY thing margin grows for, over its own
474
- * package baseline, is the legend). The test suite asserts both forms side by side.
475
- *
476
- * `plotHeight` deliberately does NOT subtract the base `PACKAGE_DEFAULT_MARGIN` breathing room --
477
- * that margin is intentional spacing around the plot (THEME-11), not one of the three itemized
478
- * "furniture" bands (tick/title/legend) this WO exists to zero out, so it is treated as part of
479
- * "the rest of the chart", the same as `plotHeight` itself.
480
- *
481
- * `containerWidth` is optional and only meaningful for tests / the browser-level check -- the
482
- * presets themselves stay responsive (no explicit width) and never pass it.
483
- */
484
- export function resolveChartLayout({
485
- size = 'standard',
486
- height,
487
- xAxis,
488
- yAxis,
489
- series = [],
490
- xLabels = 'auto',
491
- hideLegend = false,
492
- legendPosition = DEFAULT_LEGEND_POSITION,
493
- slotProps,
494
- tickFontSize,
495
- spacing,
496
- defaultLineHeight = 1,
497
- measureTextWidth,
498
- containerWidth,
499
- } = {}) {
500
- const chartHeight = resolveChartHeightPx(size, height, spacing);
501
-
502
- const xAxisList = xAxis?.length ? xAxis : [{}];
503
- const primaryXAxis = xAxisList[0];
504
- const { tickBand: xAxisBand, angle } = resolveXAxisGeometry(
505
- primaryXAxis, xLabels, defaultLineHeight, measureTextWidth, tickFontSize,
506
- );
507
- const xTitleBand = primaryXAxis?.label ? AXIS_TITLE_BAND_PX : 0;
508
-
509
- // Codex second-pass review finding: a caller-set `slotProps.legend.position` overrides
510
- // `legendPosition` at RENDER time (`withChartSlotDefaults`) but was invisible here, so the
511
- // margin/height this function reserved could disagree with where the legend actually rendered.
512
- // `resolveLegendPosition` is the one merge both call sites now share.
513
- const effectiveLegendPosition = resolveLegendPosition(legendPosition, slotProps);
514
-
515
- // A `vertical: 'middle'` legend renders on the SIDE (consuming width, not height) -- reviewer
516
- // finding: only branching on 'top' vs. not-'top' always reserved the legend band vertically,
517
- // over-reserving height and under-reserving width for a side-placed legend. This WO's width
518
- // invariant does not itemize a legend-width band, so a side legend simply gets none of the
519
- // height reservation here rather than a wrong one -- consistent with the WO's own text ("0 when
520
- // legend is off or horizontal").
521
- const legendVertical = effectiveLegendPosition.vertical;
522
- const legendIsSidePlaced = legendVertical === 'middle';
523
- const legendHeightBand = hideLegend || legendIsSidePlaced ? 0 : LEGEND_BAND_PX;
524
- const legendOnTop = legendHeightBand > 0 && legendVertical === 'top';
525
-
526
- const sizedYAxis = sizeYAxisForContent(yAxis?.length ? yAxis : [{}], series, tickFontSize);
527
- // Codex second-pass review finding: a dual-axis chart (TimeSeriesChart's `axis: 'secondary'`
528
- // feature, CHART-5) has a SECOND y-axis on the right, consuming its own width -- the resolver
529
- // only ever accounted for `sizedYAxis[0]`. `position` defaults to `'left'` for an unmarked axis
530
- // (matching MUI's own default-assignment rule, same as `sizeYAxisForContent`'s own series-match
531
- // logic above), so only an explicit `'right'` counts as the secondary axis.
532
- const primaryYAxis = sizedYAxis.find((axisEntry) => axisEntry.position !== 'right') ?? sizedYAxis[0];
533
- const secondaryYAxisEntry = sizedYAxis.find((axisEntry) => axisEntry.position === 'right');
534
- const yAxisBand = primaryYAxis?.width ?? 0;
535
- const secondaryYAxisBand = secondaryYAxisEntry?.width ?? 0;
536
- const rightPad = PACKAGE_DEFAULT_MARGIN.right;
537
-
538
- const resolvedXAxis = xAxisList.map((axisEntry, index) => {
539
- if (index !== 0) return axisEntry;
540
- // `xLabels` is the SOLE source of truth for rotation (Rule 3) -- `angle` is always written
541
- // explicitly here, even when 0, rather than only overriding a truthy value, and regardless of
542
- // whether the caller also set an explicit `height` (own `height` bypasses the model's SIZE,
543
- // never its ROTATION -- codex second-pass review finding: the two were previously coupled,
544
- // so a caller-set `height` could silently keep a stale rotated `tickLabelStyle.angle` even
545
- // under `xLabels="horizontal"`). Leaving a stale caller-supplied angle in place when `xLabels`
546
- // resolved to no rotation would let MUI still render a rotated label whose band this resolver
547
- // reserved as if it were flat -- exactly the space/render mismatch this WO exists to close,
548
- // just inverted (under-reservation instead of CHART-10's over-reservation).
549
- const { angle: _callerAngle, ...restTickLabelStyle } = axisEntry.tickLabelStyle || {};
550
- return {
551
- ...axisEntry,
552
- height: axisEntry.height ?? (xAxisBand + xTitleBand),
553
- tickLabelStyle: { ...restTickLabelStyle, angle },
554
- };
555
- });
556
-
557
- const margin = {
558
- top: PACKAGE_DEFAULT_MARGIN.top + (legendOnTop ? legendHeightBand : 0),
559
- bottom: PACKAGE_DEFAULT_MARGIN.bottom + (legendOnTop ? 0 : legendHeightBand),
560
- left: PACKAGE_DEFAULT_MARGIN.left,
561
- right: rightPad + secondaryYAxisBand,
562
- };
563
-
564
- // `plotHeight`/`plotWidth`: the residual of `chartHeight`/`containerWidth` minus the three named
565
- // furniture bands (see this function's own docblock for why this alone does not prove the
566
- // absence of a double-counted term, and what does).
567
- const plotHeight = chartHeight - xAxisBand - xTitleBand - legendHeightBand;
568
- const plotWidth = containerWidth != null
569
- ? containerWidth - yAxisBand - secondaryYAxisBand - rightPad
570
- : undefined;
571
-
572
- return {
573
- chartHeight,
574
- sx: { width: '100%', height: chartHeight },
575
- margin,
576
- xAxis: resolvedXAxis,
577
- yAxis: sizedYAxis,
578
- bands: {
579
- plotHeight,
580
- xAxisBand,
581
- secondaryYAxisBand,
582
- xTitleBand,
583
- legendBand: legendHeightBand,
584
- yAxisBand,
585
- rightPad,
586
- ...(containerWidth != null ? { plotWidth, containerWidth } : {}),
587
- },
588
- };
589
- }
590
-
591
- /**
592
- * UCM-CHART-12 Rule 1/3: `minHeight`, `aspect`, and `margin` are gone from the four chart presets
593
- * -- the model owns sizing and spacing completely. Passing one is a dev-mode error naming its
594
- * replacement (never a silent ignore, so the 38 existing call sites this breaks get a clear
595
- * migration path instead of a layout that quietly stops matching what the prop says) -- gated on
596
- * `NODE_ENV !== 'production'` like the deleted `warnOnHeightMismatch` was, so a shared package
597
- * cannot crash a consumer's production page over a layout prop; loud in dev, inert in prod.
598
- *
599
- * `removedIn` (codex review finding, UCM-CHART-13): a shared helper hardcoding ONE WO/version
600
- * string would misattribute the removal for every OTHER call site that reuses it -- `ChartFrame`
601
- * (UCM-CHART-13, v3.1.0) removes `height`/`aspect` for a different reason, in a different WO, than
602
- * the four presets' own `minHeight`/`aspect`/`margin` (UCM-CHART-12, v3.0.0). The default keeps
603
- * every existing call site's message byte-identical; only a NEW call site needs to override it.
604
- */
605
- export function assertRemovedChartProp(componentName, propName, value, replacement, removedIn = 'UCM-CHART-12, v3.0.0') {
606
- if (value === undefined || process.env.NODE_ENV === 'production') return;
607
- throw new Error(
608
- `[ui-core-micha] <${componentName}> no longer accepts "${propName}" (removed in ${removedIn} `
609
- + `-- see docs/CHART-LAYOUT.md). ${replacement}`,
610
- );
611
- }
612
-
613
- /**
614
- * UCM-CHART-12 (codex second-pass review finding): the single source of truth for where the
615
- * legend actually renders -- `legendPosition` merged with a `slotProps.legend.position` override,
616
- * same precedence `withChartSlotDefaults` already applied. Before this fix, `resolveChartLayout`
617
- * only ever looked at `legendPosition` when deciding how much space to reserve and on which side,
618
- * while `withChartSlotDefaults` (below) separately let `slotProps.legend.position` win at RENDER
619
- * time -- a caller passing both could get a legend rendered top/side while the resolver had
620
- * reserved space for bottom (or none), the exact reserved-vs-rendered mismatch this WO exists to
621
- * close. Both call sites now merge through this one function.
622
- */
623
- export function resolveLegendPosition(legendPosition, slotProps) {
624
- return {
625
- ...DEFAULT_LEGEND_POSITION,
626
- ...legendPosition,
627
- ...slotProps?.legend?.position,
628
- };
629
- }
630
-
631
- export function withChartSlotDefaults(slotProps, legendPosition) {
632
- const position = resolveLegendPosition(legendPosition, slotProps);
633
- return {
634
- ...slotProps,
635
- tooltip: {
636
- trigger: 'axis',
637
- ...slotProps?.tooltip,
638
- },
639
- legend: {
640
- ...slotProps?.legend,
641
- position,
642
- },
643
- };
644
- }