@salsita-ai/sdk 0.50.0-develop.f81ab6ac76 → 0.50.0-develop.f87b940e67

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 (617) hide show
  1. package/dist/application/index.d.ts +1 -0
  2. package/dist/application/index.js +1 -1
  3. package/dist/application/mount-application.d.ts +1 -1
  4. package/dist/application/mount-application.js +1 -1
  5. package/dist/application/mount-configurator.d.ts +19 -0
  6. package/dist/application/mount-configurator.js +1 -0
  7. package/dist/application/mounted-application-runtime.d.ts +4 -0
  8. package/dist/application/mounted-application-runtime.js +1 -0
  9. package/dist/application/register-web-component.d.ts +2 -0
  10. package/dist/application/register-web-component.js +1 -0
  11. package/dist/application-spec/errors/diagnostic-codes.d.ts +9 -0
  12. package/dist/application-spec/errors/diagnostic-codes.js +1 -1
  13. package/dist/application-spec/errors/diagnostic-messages.d.ts +10 -1
  14. package/dist/application-spec/errors/diagnostic-messages.js +1 -1
  15. package/dist/application-spec/generate-application/code/domain-runtime-files.js +1 -1
  16. package/dist/application-spec/generate-application/code/templates/application-types.js +6 -6
  17. package/dist/application-spec/generate-application/code/templates/common/doc-comment.d.ts +2 -0
  18. package/dist/application-spec/generate-application/code/templates/common/doc-comment.js +1 -0
  19. package/dist/application-spec/generate-application/code/templates/configurator-globals.d.ts +2 -1
  20. package/dist/application-spec/generate-application/code/templates/configurator-globals.js +19 -6
  21. package/dist/application-spec/generate-application/code/templates/global-definition/generate-action-types.js +7 -7
  22. package/dist/application-spec/generate-application/code/templates/global-definition/generate-assistant-form-element-types.d.ts +12 -0
  23. package/dist/application-spec/generate-application/code/templates/global-definition/generate-assistant-form-element-types.js +7 -0
  24. package/dist/application-spec/generate-application/code/templates/global-definition/generate-phx-types.js +24 -22
  25. package/dist/application-spec/generate-application/code/templates/global-definition/index.d.ts +1 -0
  26. package/dist/application-spec/generate-application/code/templates/global-definition/index.js +1 -1
  27. package/dist/application-spec/generate-application/code/templates/skills/sdk-content.d.ts +13 -6
  28. package/dist/application-spec/generate-application/code/templates/skills/sdk-content.js +2 -2
  29. package/dist/application-spec/generate-application/implementations/phx-application-types.d.ts +25 -0
  30. package/dist/application-spec/generate-application/implementations/phx-application-types.js +1 -0
  31. package/dist/application-spec/generate-application/implementations/phx-domain.d.ts +36 -0
  32. package/dist/application-spec/generate-application/implementations/phx-domain.js +1 -0
  33. package/dist/application-spec/generate-application/implementations/phx-model.d.ts +24 -0
  34. package/dist/application-spec/generate-application/implementations/phx-model.js +1 -0
  35. package/dist/application-spec/generate-application/implementations/phx.d.ts +22 -0
  36. package/dist/application-spec/generate-application/implementations/phx.js +1 -0
  37. package/dist/application-spec/generate-application/index.d.ts +2 -0
  38. package/dist/application-spec/generate-application/index.js +1 -1
  39. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-model-runtime.d.ts +3 -3
  40. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-runtime.d.ts +7 -4
  41. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-runtime.js +1 -1
  42. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-spec-runtime.d.ts +16 -0
  43. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-spec-runtime.js +1 -0
  44. package/dist/application-spec/generate-application/runtime/application-runtime.d.ts +17 -4
  45. package/dist/application-spec/generate-application/runtime/application-runtime.js +1 -1
  46. package/dist/application-spec/generate-application/runtime/application-spec-slice.d.ts +71 -0
  47. package/dist/application-spec/generate-application/runtime/application-spec-slice.js +1 -0
  48. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-model-runtime.d.ts +3 -3
  49. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-runtime.d.ts +7 -5
  50. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-runtime.js +1 -1
  51. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-spec-runtime.d.ts +16 -0
  52. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-spec-runtime.js +1 -0
  53. package/dist/application-spec/generate-application/runtime/create-application.d.ts +57 -0
  54. package/dist/application-spec/generate-application/runtime/create-application.js +1 -0
  55. package/dist/application-spec/generate-application/runtime/index.d.ts +8 -0
  56. package/dist/application-spec/generate-application/runtime/index.js +1 -0
  57. package/dist/application-spec/generate-application/runtime/sections/expression-runtime.d.ts +9 -3
  58. package/dist/application-spec/generate-application/runtime/sections/expression-runtime.js +1 -1
  59. package/dist/application-spec/generate-application/runtime/spec-runtime.d.ts +21 -0
  60. package/dist/application-spec/generate-application/runtime/spec-runtime.js +1 -0
  61. package/dist/application-spec/generate-application/runtime-spec/encoded-runtime-spec.d.ts +3 -2
  62. package/dist/application-spec/generate-application/runtime-spec/runtime-spec.d.ts +44 -9
  63. package/dist/application-spec/generate-application/runtime-spec/runtime-spec.js +1 -1
  64. package/dist/application-spec/parse-application-files/raw-spec/raw-assistant.d.ts +33 -3
  65. package/dist/application-spec/parse-application-files/raw-spec/raw-assistant.js +1 -1
  66. package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-form-elements.d.ts +9 -0
  67. package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-form-elements.js +1 -0
  68. package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-schema.d.ts +4 -1
  69. package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-schema.js +1 -1
  70. package/dist/configurator/ConfiguratorApp.js +1 -1
  71. package/dist/configurator/app-loader-content/DefaultAppLoaderContent.d.ts +2 -0
  72. package/dist/configurator/app-loader-content/DefaultAppLoaderContent.js +5 -0
  73. package/dist/configurator/app-loader-content/index.d.ts +1 -0
  74. package/dist/configurator/app-loader-content/index.js +1 -0
  75. package/dist/configurator/application-runtime/extensions/selectors.js +1 -1
  76. package/dist/configurator/application-runtime/extensions/systems/assistant-system/assistant-system.d.ts +4 -3
  77. package/dist/configurator/application-runtime/extensions/systems/assistant-system/assistant-system.js +1 -1
  78. package/dist/configurator/application-runtime/extensions/systems/assistant-system/graphql-tools.d.ts +33 -7
  79. package/dist/configurator/application-runtime/extensions/systems/assistant-system/graphql-tools.js +1 -1
  80. package/dist/configurator/application-runtime/extensions/systems/camera-control-system/camera-control-system.d.ts +1 -1
  81. package/dist/configurator/application-runtime/extensions/systems/canvas-system.d.ts +8 -0
  82. package/dist/configurator/application-runtime/extensions/systems/canvas-system.js +1 -1
  83. package/dist/configurator/application-runtime/extensions/systems/export-system/export-busy-error.d.ts +8 -0
  84. package/dist/configurator/application-runtime/extensions/systems/export-system/export-busy-error.js +1 -0
  85. package/dist/configurator/application-runtime/extensions/systems/export-system/export-system.d.ts +23 -1
  86. package/dist/configurator/application-runtime/extensions/systems/export-system/export-system.js +1 -1
  87. package/dist/configurator/application-runtime/extensions/systems/graphql-system/entity-meta.d.ts +14 -1
  88. package/dist/configurator/application-runtime/extensions/systems/graphql-system/entity-meta.js +1 -1
  89. package/dist/configurator/application-runtime/extensions/systems/graphql-system/graphql-system.d.ts +45 -4
  90. package/dist/configurator/application-runtime/extensions/systems/graphql-system/graphql-system.js +1 -1
  91. package/dist/configurator/application-runtime/extensions/systems/image-rendering-system/image-rendering-system.d.ts +14 -5
  92. package/dist/configurator/application-runtime/extensions/systems/image-rendering-system/image-rendering-system.js +1 -1
  93. package/dist/configurator/application-runtime/extensions/systems/index.d.ts +1 -1
  94. package/dist/configurator/common/configurator-interface.d.ts +111 -5
  95. package/dist/configurator/common/configurator-interface.js +1 -1
  96. package/dist/configurator/configuration-not-found-page/BuiltInConfigurationNotFoundPage.d.ts +5 -0
  97. package/dist/configurator/configuration-not-found-page/BuiltInConfigurationNotFoundPage.js +1 -0
  98. package/dist/configurator/configuration-not-found-page/ConfigurationNotFoundPageElement.d.ts +0 -6
  99. package/dist/configurator/configuration-not-found-page/ConfigurationNotFoundPageElement.js +1 -1
  100. package/dist/configurator/configuration-not-found-page/index.d.ts +2 -1
  101. package/dist/configurator/configuration-not-found-page/index.js +1 -1
  102. package/dist/configurator/configurator-context/ConfiguratorResponsiveProvider.d.ts +12 -0
  103. package/dist/configurator/configurator-context/ConfiguratorResponsiveProvider.js +1 -0
  104. package/dist/configurator/configurator-context/PreviewContextProvider.js +1 -1
  105. package/dist/configurator/configurator-context/ViewportStateSync.d.ts +2 -2
  106. package/dist/configurator/configurator-context/ViewportStateSync.js +1 -1
  107. package/dist/configurator/configurator-context/configurator-hooks/layout-hooks.d.ts +10 -0
  108. package/dist/configurator/configurator-context/configurator-routing/ConfiguratorRoutingProvider.js +1 -1
  109. package/dist/configurator/configurator-error-page/BuiltInConfiguratorErrorPage.d.ts +6 -0
  110. package/dist/configurator/configurator-error-page/BuiltInConfiguratorErrorPage.js +1 -0
  111. package/dist/configurator/configurator-error-page/ConfiguratorErrorPageElement.d.ts +7 -0
  112. package/dist/configurator/configurator-error-page/ConfiguratorErrorPageElement.js +1 -0
  113. package/dist/configurator/configurator-error-page/index.d.ts +2 -0
  114. package/dist/configurator/configurator-error-page/index.js +1 -0
  115. package/dist/configurator/index.d.ts +10 -0
  116. package/dist/configurator/index.js +1 -1
  117. package/dist/configurator/pages/ConfiguratorErrorPage.d.ts +0 -1
  118. package/dist/configurator/pages/ConfiguratorErrorPage.js +1 -1
  119. package/dist/configurator/pages/LoadedConfiguratorDetailPage.js +1 -1
  120. package/dist/configurator/pages/PresetCreationErrorPage.d.ts +1 -1
  121. package/dist/configurator/pages/PresetCreationErrorPage.js +1 -1
  122. package/dist/configurator/pages/default-configurator-elements.d.ts +4 -2
  123. package/dist/configurator/pages/default-configurator-elements.js +1 -1
  124. package/dist/configurator/pages/default-elements/index.d.ts +1 -0
  125. package/dist/configurator/pages/default-elements/index.js +1 -1
  126. package/dist/configurator/pages/default-elements/scene-controls/BuiltInAddEntityFab.d.ts +3 -0
  127. package/dist/configurator/pages/default-elements/scene-controls/BuiltInAddEntityFab.js +1 -0
  128. package/dist/configurator/pages/default-elements/scene-controls/BuiltInDeleteEntityFab.d.ts +3 -0
  129. package/dist/configurator/pages/default-elements/scene-controls/BuiltInDeleteEntityFab.js +1 -0
  130. package/dist/configurator/pages/default-elements/scene-controls/BuiltInNavigationHotspot.d.ts +3 -0
  131. package/dist/configurator/pages/default-elements/scene-controls/BuiltInNavigationHotspot.js +1 -0
  132. package/dist/configurator/pages/default-elements/scene-controls/BuiltInSelectionHotspot.d.ts +3 -0
  133. package/dist/configurator/pages/default-elements/scene-controls/BuiltInSelectionHotspot.js +1 -0
  134. package/dist/configurator/pages/default-elements/scene-controls/BuiltInToolMenuFab.d.ts +3 -0
  135. package/dist/configurator/pages/default-elements/scene-controls/BuiltInToolMenuFab.js +1 -0
  136. package/dist/configurator/pages/default-elements/scene-controls/index.d.ts +6 -0
  137. package/dist/configurator/pages/default-elements/scene-controls/index.js +1 -0
  138. package/dist/configurator/pages/default-elements/scene-controls/scene-control-elements.d.ts +6 -0
  139. package/dist/configurator/pages/default-elements/scene-controls/scene-control-elements.js +1 -0
  140. package/dist/configurator/pages/error-boundaries/GeneralErrorBoundary.js +1 -1
  141. package/dist/configurator/pages/error-boundaries/LoadingErrorBoundary.js +1 -1
  142. package/dist/configurator/scene/index.d.ts +1 -0
  143. package/dist/configurator/scene/index.js +1 -1
  144. package/dist/configurator/scene/scene-controls/ToolFab.d.ts +4 -0
  145. package/dist/configurator/scene/scene-controls/ToolFab.js +1 -1
  146. package/dist/configurator/scene/scene-controls/use-scene-control-tooltip.d.ts +2 -1
  147. package/dist/configurator/scene/scene-entity/scene-controls/SceneEntityHtmlHotspots.js +1 -1
  148. package/dist/configurator/toolbar-actions/index.d.ts +3 -0
  149. package/dist/configurator/toolbar-actions/index.js +1 -0
  150. package/dist/configurator/toolbar-actions/use-assistant-panel.d.ts +18 -0
  151. package/dist/configurator/toolbar-actions/use-assistant-panel.js +1 -0
  152. package/dist/configurator/toolbar-actions/use-measurements.d.ts +35 -0
  153. package/dist/configurator/toolbar-actions/use-measurements.js +1 -0
  154. package/dist/configurator/toolbar-actions/use-toolbar-actions.d.ts +100 -0
  155. package/dist/configurator/toolbar-actions/use-toolbar-actions.js +1 -0
  156. package/dist/configurator/toolbar-actions/use-toolbar-help.d.ts +7 -0
  157. package/dist/configurator/toolbar-actions/use-toolbar-help.js +1 -0
  158. package/dist/configurator/ui/assistant/AssistantChips.d.ts +3 -2
  159. package/dist/configurator/ui/assistant/AssistantChips.js +1 -1
  160. package/dist/configurator/ui/assistant/AssistantMarkdown.d.ts +1 -1
  161. package/dist/configurator/ui/assistant/AssistantMarkdown.js +1 -1
  162. package/dist/configurator/ui/assistant/alert/AssistantAlert.d.ts +8 -0
  163. package/dist/configurator/ui/assistant/alert/AssistantAlert.js +1 -0
  164. package/dist/configurator/ui/assistant/alert/index.d.ts +2 -0
  165. package/dist/configurator/ui/assistant/alert/index.js +1 -0
  166. package/dist/configurator/ui/assistant/alert/remark-alerts.d.ts +10 -0
  167. package/dist/configurator/ui/assistant/alert/remark-alerts.js +1 -0
  168. package/dist/configurator/ui/assistant/form/AssistantForm.js +1 -1
  169. package/dist/configurator/ui/assistant/form/compose-answer.d.ts +1 -1
  170. package/dist/configurator/ui/assistant/form/compose-answer.js +1 -1
  171. package/dist/configurator/ui/assistant/form/form-model.d.ts +3 -4
  172. package/dist/configurator/ui/assistant/form/form-state.d.ts +1 -1
  173. package/dist/configurator/ui/assistant/form/index.d.ts +2 -0
  174. package/dist/configurator/ui/assistant/form/index.js +1 -1
  175. package/dist/configurator/ui/assistant/form/parse-form-block.d.ts +3 -2
  176. package/dist/configurator/ui/assistant/form/parse-form-block.js +1 -1
  177. package/dist/configurator/ui/assistant/form/read-form-attributes.d.ts +1 -1
  178. package/dist/configurator/ui/assistant/form/read-form-attributes.js +1 -1
  179. package/dist/configurator/ui/assistant/form/registry/built-in-form-elements.d.ts +3 -0
  180. package/dist/configurator/ui/assistant/form/registry/built-in-form-elements.js +1 -0
  181. package/dist/configurator/ui/assistant/form/registry/create-assistant-form-element.d.ts +31 -0
  182. package/dist/configurator/ui/assistant/form/registry/create-assistant-form-element.js +1 -0
  183. package/dist/configurator/ui/assistant/form/{form-element-definition.d.ts → registry/form-element-definition.d.ts} +3 -1
  184. package/dist/configurator/ui/assistant/form/registry/form-element-definition.js +1 -0
  185. package/dist/configurator/ui/assistant/form/{form-registry.d.ts → registry/form-registry.d.ts} +2 -2
  186. package/dist/configurator/ui/assistant/form/registry/form-registry.js +1 -0
  187. package/dist/configurator/ui/assistant/form/registry/index.d.ts +6 -0
  188. package/dist/configurator/ui/assistant/form/registry/index.js +1 -0
  189. package/dist/configurator/ui/assistant/form/registry/use-form-registry.d.ts +8 -0
  190. package/dist/configurator/ui/assistant/form/registry/use-form-registry.js +1 -0
  191. package/dist/configurator/ui/assistant/form-elements/FormCard.d.ts +9 -0
  192. package/dist/configurator/ui/assistant/form-elements/FormCard.js +1 -0
  193. package/dist/configurator/ui/assistant/form-elements/FormConfirm.d.ts +9 -0
  194. package/dist/configurator/ui/assistant/form-elements/FormConfirm.js +1 -0
  195. package/dist/configurator/ui/assistant/form-elements/FormGroup.d.ts +4 -1
  196. package/dist/configurator/ui/assistant/form-elements/FormNumber.d.ts +9 -0
  197. package/dist/configurator/ui/assistant/form-elements/FormNumber.js +1 -0
  198. package/dist/configurator/ui/assistant/form-elements/FormOptions.d.ts +4 -1
  199. package/dist/configurator/ui/assistant/form-elements/FormReply.d.ts +4 -1
  200. package/dist/configurator/ui/assistant/form-elements/FormReply.js +1 -1
  201. package/dist/configurator/ui/assistant/form-elements/FormSwatch.d.ts +10 -0
  202. package/dist/configurator/ui/assistant/form-elements/FormSwatch.js +1 -0
  203. package/dist/configurator/ui/assistant/form-elements/FormText.d.ts +4 -1
  204. package/dist/configurator/ui/assistant/form-elements/FormText.js +1 -1
  205. package/dist/configurator/ui/assistant/form-elements/clamp-form-number.d.ts +10 -0
  206. package/dist/configurator/ui/assistant/form-elements/clamp-form-number.js +1 -0
  207. package/dist/configurator/ui/assistant/form-elements/form-element-props.d.ts +20 -16
  208. package/dist/configurator/ui/assistant/form-elements/index.d.ts +4 -0
  209. package/dist/configurator/ui/assistant/form-elements/index.js +1 -1
  210. package/dist/configurator/ui/assistant/index.d.ts +3 -0
  211. package/dist/configurator/ui/assistant/index.js +1 -1
  212. package/dist/configurator/ui/assistant/resource-image/index.d.ts +1 -1
  213. package/dist/configurator/ui/assistant/resource-image/index.js +1 -1
  214. package/dist/configurator/ui/assistant/resource-image/resource-path.d.ts +0 -9
  215. package/dist/configurator/ui/assistant/resource-image/resource-path.js +1 -1
  216. package/dist/configurator/ui/assistant/screenshots/ScreenshotThumbnails.d.ts +9 -0
  217. package/dist/configurator/ui/assistant/screenshots/ScreenshotThumbnails.js +1 -0
  218. package/dist/configurator/ui/assistant/screenshots/index.d.ts +2 -0
  219. package/dist/configurator/ui/assistant/screenshots/index.js +1 -0
  220. package/dist/configurator/ui/assistant/screenshots/tool-screenshots.d.ts +14 -0
  221. package/dist/configurator/ui/assistant/screenshots/tool-screenshots.js +1 -0
  222. package/dist/configurator/ui/assistant/streamed-markdown.d.ts +2 -2
  223. package/dist/configurator/ui/assistant/streamed-markdown.js +2 -2
  224. package/dist/configurator/ui/assistant/tool-chip.js +1 -1
  225. package/dist/configurator/ui/configurator-layout/slots/canvas/LayoutCanvas.js +1 -1
  226. package/dist/configurator/ui/configurator-layout/slots/canvas/SharedCanvas.js +1 -1
  227. package/dist/configurator/ui/configurator-layout/universal/ExportingOverlay.d.ts +5 -0
  228. package/dist/configurator/ui/configurator-layout/universal/ExportingOverlay.js +1 -0
  229. package/dist/configurator/ui/configurator-layout/universal/Layout.js +1 -1
  230. package/dist/configurator/ui/configurator-layout/universal/use-layout-scene-safe-area.d.ts +1 -1
  231. package/dist/configurator/ui/configurator-layout/universal/use-layout-scene-safe-area.js +1 -1
  232. package/dist/configurator/ui/configurator-layout/universal/use-scene-safe-area.d.ts +1 -1
  233. package/dist/configurator/ui/index.d.ts +1 -0
  234. package/dist/configurator/ui/index.js +1 -1
  235. package/dist/configurator/ui/legacy/layout/common/ReactiveInterfaceLayout.js +1 -1
  236. package/dist/configurator/ui/phoenix-layout-elements/AppLoader.d.ts +0 -7
  237. package/dist/configurator/ui/phoenix-layout-elements/AppLoader.js +2 -6
  238. package/dist/configurator/ui/phoenix-layout-elements/AssistantToolbarButton.js +1 -1
  239. package/dist/configurator/ui/phoenix-layout-elements/LanguageMenuAction.js +1 -1
  240. package/dist/configurator/ui/phoenix-layout-elements/LanguageToolbarButton.js +1 -1
  241. package/dist/configurator/ui/phoenix-layout-elements/MeasurementsMenuAction.js +1 -1
  242. package/dist/configurator/ui/phoenix-layout-elements/MeasurementsToolbarButton.js +1 -1
  243. package/dist/configurator/ui/phoenix-layout-elements/ShareToolbarButton.js +1 -1
  244. package/dist/configurator/ui/phoenix-layout-elements/header/common/step-navigation/DesktopNavigationSteps.d.ts +1 -1
  245. package/dist/configurator/ui/phoenix-layout-elements/header/common/step-navigation/DesktopNavigationSteps.js +1 -1
  246. package/dist/configurator/ui/phoenix-layout-elements/header/common/step-navigation/StepsNavigation.d.ts +2 -8
  247. package/dist/configurator/ui/phoenix-layout-elements/header/common/step-navigation/StepsNavigation.js +1 -1
  248. package/dist/configurator/ui/phoenix-layout-elements/index.d.ts +8 -1
  249. package/dist/configurator/ui/phoenix-layout-elements/index.js +1 -1
  250. package/dist/configurator/ui/phoenix-layout-elements/measurements/DimensionUnitSelector.js +1 -1
  251. package/dist/configurator/ui/phoenix-layout-elements/measurements/MeasurementsIndicatorsSwitch.js +1 -1
  252. package/dist/configurator/ui/phoenix-layout-elements/share/ShareModal.js +1 -1
  253. package/dist/configurator/ui/phoenix-layout-elements/share/use-share.d.ts +7 -8
  254. package/dist/configurator/ui/phoenix-layout-elements/share/use-share.js +1 -1
  255. package/dist/configurator/ui/phoenix-layout-elements/sidebar-footer/common/FinalizeActions.js +1 -1
  256. package/dist/configurator/ui/phoenix-layout-elements/sidebar-footer/common/NextStepButton.js +1 -1
  257. package/dist/configurator/ui/phoenix-layout-elements/sidebar-footer/common/PreviousStepButton.js +1 -1
  258. package/dist/configurator/ui/preset-layout/PresetUi.d.ts +0 -2
  259. package/dist/configurator/ui/preset-layout/PresetUi.js +1 -1
  260. package/dist/configurator/ui/preset-layout/index.d.ts +6 -1
  261. package/dist/configurator/ui/preset-layout/index.js +1 -1
  262. package/dist/configurator/ui/save-message-snackbars/SaveMessageSnackbars.js +1 -1
  263. package/dist/docs/complex-use-cases/focus-mode.mdx +2 -0
  264. package/dist/docs/fabs/basic.mdx +4 -0
  265. package/dist/docs/index.json +95 -1
  266. package/dist/docs/integrations/webcomponent-api/basics.mdx +2 -0
  267. package/dist/docs/layouts/custom-layout.mdx +321 -159
  268. package/dist/docs/layouts/gallery/ardent-compressor.mdx +3516 -0
  269. package/dist/docs/layouts/gallery/corvid-drone.mdx +3428 -0
  270. package/dist/docs/layouts/gallery/fenwick-window.mdx +2803 -0
  271. package/dist/docs/layouts/gallery/ferrule-bar-station.mdx +6567 -0
  272. package/dist/docs/layouts/gallery/kestrel-kettle.mdx +1858 -0
  273. package/dist/docs/layouts/gallery/nightjar-guitar.mdx +3698 -0
  274. package/dist/docs/layouts/gallery/sunfold-solar.mdx +2683 -0
  275. package/dist/docs/layouts/gallery/tidepool-aquascape.mdx +5203 -0
  276. package/dist/docs/other-features/client-side-rendering/declarative.mdx +1 -1
  277. package/dist/docs/other-features/error-5xx.mdx +1 -1
  278. package/dist/docs/other-features/navigation-hotspot.mdx +2 -0
  279. package/dist/docs/other-features/not-found-404.mdx +1 -1
  280. package/dist/docs/phoenix/configurator-interface.mdx +202 -67
  281. package/dist/docs/phoenix/finalization.mdx +1 -1
  282. package/dist/docs/phoenix/layout-slots.mdx +5 -5
  283. package/dist/docs/phoenix/sdk-builder-workflow.mdx +1 -1
  284. package/dist/docs/phoenix/sessions-and-access.mdx +2 -0
  285. package/dist/docs/phoenix/testing.mdx +2 -2
  286. package/dist/docs/selection-and-highlight/hotspots.mdx +2 -0
  287. package/dist/domain/application/assistant/assistant-manifest.d.ts +16 -1
  288. package/dist/domain/application/assistant/assistant-resolved-manifest.d.ts +8 -1
  289. package/dist/domain/application/assistant/build-assistant-manifest.js +1 -1
  290. package/dist/domain/application/assistant/form/assistant-form-element-name.d.ts +11 -0
  291. package/dist/domain/application/assistant/form/assistant-form-element-name.js +8 -0
  292. package/dist/domain/application/assistant/form/assistant-form-elements.d.ts +174 -0
  293. package/dist/domain/application/assistant/form/assistant-form-elements.js +1 -0
  294. package/dist/domain/application/assistant/form/assistant-resource-path.d.ts +10 -0
  295. package/dist/domain/application/assistant/form/assistant-resource-path.js +1 -0
  296. package/dist/domain/application/assistant/form/index.d.ts +4 -0
  297. package/dist/domain/application/assistant/form/index.js +1 -0
  298. package/dist/domain/application/assistant/generate-assistant-prompt.d.ts +3 -0
  299. package/dist/domain/application/assistant/generate-assistant-prompt.js +1 -1
  300. package/dist/domain/application/assistant/index.d.ts +2 -3
  301. package/dist/domain/application/assistant/index.js +1 -1
  302. package/dist/domain/application/assistant/session-setup/apply-assistant-setup.js +1 -1
  303. package/dist/domain/application/assistant/session-setup/assistant-setup-manifest.d.ts +2 -0
  304. package/dist/domain/application/assistant/session-setup/assistant-setup-manifest.js +1 -1
  305. package/dist/domain/application/configurator-builder.d.ts +7 -1
  306. package/dist/domain/application/configurator-builder.js +1 -1
  307. package/dist/domain/application/index.d.ts +1 -1
  308. package/dist/domain/application/index.js +1 -1
  309. package/dist/domain/application/standard-properties.js +1 -1
  310. package/dist/domain/application/web-component-property.d.ts +3 -1
  311. package/dist/domain/application/web-component-property.js +1 -1
  312. package/dist/domain/available-system-components.d.ts +1 -1
  313. package/dist/domain/available-system-components.js +1 -1
  314. package/dist/domain/domain-builder.d.ts +9 -3
  315. package/dist/domain/domain-builder.js +1 -1
  316. package/dist/domain/domain-context/store-system/store-system.d.ts +5 -0
  317. package/dist/domain/domain-context/store-system/store-system.js +1 -1
  318. package/dist/domain/domain-runtime/application.d.ts +39 -0
  319. package/dist/domain/domain-runtime/application.js +1 -0
  320. package/dist/domain/domain-runtime/domain-context/selectors.d.ts +1 -0
  321. package/dist/domain/domain-runtime/domain-context/selectors.js +1 -1
  322. package/dist/domain/domain-runtime/domain-runtime.d.ts +44 -4
  323. package/dist/domain/domain-runtime/domain-runtime.js +1 -1
  324. package/dist/domain/domain-runtime/environment/initialize.d.ts +1 -1
  325. package/dist/domain/domain-runtime/environment/types.d.ts +1 -1
  326. package/dist/domain/domain-runtime/errors/implementation-error-collector.d.ts +2 -0
  327. package/dist/domain/domain-runtime/errors/implementation-error-collector.js +1 -1
  328. package/dist/domain/domain-runtime/index.d.ts +2 -1
  329. package/dist/domain/domain-runtime/index.js +1 -1
  330. package/dist/domain/domain-runtime/spec/index.d.ts +1 -1
  331. package/dist/domain/domain-runtime/spec/index.js +1 -1
  332. package/dist/domain/domain-runtime/spec/initialize.js +1 -1
  333. package/dist/domain/domain-runtime/spec/module-checks/element-registration.d.ts +15 -1
  334. package/dist/domain/domain-runtime/spec/module-checks/element-registration.js +1 -1
  335. package/dist/domain/domain-runtime/spec/module-checks/find-assistant-form-element-errors.d.ts +12 -0
  336. package/dist/domain/domain-runtime/spec/module-checks/find-assistant-form-element-errors.js +1 -0
  337. package/dist/domain/domain-runtime/spec/module-checks/index.d.ts +3 -1
  338. package/dist/domain/domain-runtime/spec/module-checks/index.js +1 -1
  339. package/dist/domain/domain-runtime/spec/module-checks/merge-expression-modules.d.ts +23 -0
  340. package/dist/domain/domain-runtime/spec/module-checks/merge-expression-modules.js +1 -0
  341. package/dist/domain/domain-runtime/spec/module-checks/resolve-expression-modules.d.ts +3 -4
  342. package/dist/domain/domain-runtime/spec/module-checks/resolve-expression-modules.js +1 -1
  343. package/dist/domain/domain-runtime/spec/selectors.d.ts +3 -1
  344. package/dist/domain/domain-runtime/spec/selectors.js +1 -1
  345. package/dist/domain/domain-runtime/web-component/actions.d.ts +3 -0
  346. package/dist/domain/domain-runtime/web-component/actions.js +1 -1
  347. package/dist/domain/domain-runtime/web-component/initialize.d.ts +1 -0
  348. package/dist/domain/domain-runtime/web-component/initialize.js +1 -1
  349. package/dist/domain/domain-runtime/web-component/selectors.d.ts +2 -0
  350. package/dist/domain/domain-runtime/web-component/selectors.js +1 -1
  351. package/dist/domain/domain-runtime/web-component/types.d.ts +6 -3
  352. package/dist/domain/extensions/export-images/agent-screenshot.d.ts +24 -0
  353. package/dist/domain/extensions/export-images/agent-screenshot.js +1 -0
  354. package/dist/domain/extensions/{client-side-image.d.ts → export-images/client-side-image.d.ts} +2 -2
  355. package/dist/domain/extensions/export-images/client-side-image.js +1 -0
  356. package/dist/domain/extensions/export-images/index.d.ts +3 -0
  357. package/dist/domain/extensions/export-images/index.js +1 -0
  358. package/dist/domain/extensions/{save-template-thumbnail.d.ts → export-images/save-template-thumbnail.d.ts} +2 -2
  359. package/dist/domain/extensions/export-images/save-template-thumbnail.js +1 -0
  360. package/dist/domain/extensions/index.d.ts +1 -2
  361. package/dist/domain/extensions/index.js +1 -1
  362. package/dist/domain/mocks/create-mock-domain-runtime.d.ts +9 -0
  363. package/dist/editor/domain-editors/DomainSpecContext.js +1 -1
  364. package/dist/editor/domain-editors/apply-spec-update.d.ts +10 -2
  365. package/dist/editor/domain-editors/apply-spec-update.js +1 -1
  366. package/dist/editor/domain-editors/use-implementation-files.d.ts +6 -14
  367. package/dist/editor/domain-editors/use-implementation-files.js +1 -1
  368. package/dist/editor/screenshot/ScreenshotHandler.js +1 -1
  369. package/dist/i18n/locales/cs/messages.js +1 -1
  370. package/dist/i18n/locales/da/messages.js +1 -1
  371. package/dist/i18n/locales/de/messages.js +1 -1
  372. package/dist/i18n/locales/de-AT/messages.js +1 -1
  373. package/dist/i18n/locales/en/messages.js +1 -1
  374. package/dist/i18n/locales/en-AU/messages.js +1 -1
  375. package/dist/i18n/locales/en-GB/messages.js +1 -1
  376. package/dist/i18n/locales/es/messages.js +1 -1
  377. package/dist/i18n/locales/fr/messages.js +1 -1
  378. package/dist/i18n/locales/hu/messages.js +1 -1
  379. package/dist/i18n/locales/it/messages.js +1 -1
  380. package/dist/i18n/locales/nl/messages.js +1 -1
  381. package/dist/i18n/locales/pl/messages.js +1 -1
  382. package/dist/i18n/locales/pt/messages.js +1 -1
  383. package/dist/i18n/locales/ro/messages.js +1 -1
  384. package/dist/i18n/locales/sk/messages.js +1 -1
  385. package/dist/i18n/locales/tr/messages.js +1 -1
  386. package/dist/legacy/domain-cli/utils/cache.d.ts +1 -1
  387. package/dist/legacy/domain-cli/utils/cache.js +1 -1
  388. package/dist/legacy/domain-spec/__generated/domain-schema.d.ts +16 -0
  389. package/dist/legacy/domain-spec/spec/domain-spec.js +1 -1
  390. package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/claude-md.d.ts +1 -1
  391. package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/cursor-rules.d.ts +1 -1
  392. package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/shared-sdk-content.d.ts +1 -1
  393. package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/shared-sdk-content.js +11 -5
  394. package/dist/react-sdk/application-runtime/common-services/analytics/analytics-service.d.ts +1 -1
  395. package/dist/react-sdk/application-runtime/common-services/analytics/frontend-analytics.d.ts +4 -0
  396. package/dist/react-sdk/application-runtime/common-services/initialize.d.ts +2 -2
  397. package/dist/react-sdk/application-runtime/hooks/index.d.ts +2 -0
  398. package/dist/react-sdk/application-runtime/hooks/index.js +1 -1
  399. package/dist/react-sdk/application-runtime/hooks/loading/index.d.ts +5 -0
  400. package/dist/react-sdk/application-runtime/hooks/loading/index.js +1 -0
  401. package/dist/react-sdk/application-runtime/hooks/loading/loader-host-context.d.ts +11 -0
  402. package/dist/react-sdk/application-runtime/hooks/loading/loader-host-context.js +1 -0
  403. package/dist/react-sdk/application-runtime/hooks/loading/use-is-awaiting-loading-phase.d.ts +8 -0
  404. package/dist/react-sdk/application-runtime/hooks/loading/use-is-awaiting-loading-phase.js +1 -0
  405. package/dist/react-sdk/application-runtime/hooks/loading/use-is-loader-hosted.d.ts +7 -0
  406. package/dist/react-sdk/application-runtime/hooks/loading/use-is-loader-hosted.js +1 -0
  407. package/dist/react-sdk/application-runtime/hooks/loading/use-loading-actions.d.ts +9 -0
  408. package/dist/react-sdk/application-runtime/hooks/loading/use-loading-actions.js +1 -0
  409. package/dist/react-sdk/application-runtime/hooks/loading/use-loading-application.d.ts +24 -0
  410. package/dist/react-sdk/application-runtime/hooks/loading/use-loading-application.js +1 -0
  411. package/dist/react-sdk/application-runtime/hooks/responsive/index.d.ts +3 -0
  412. package/dist/react-sdk/application-runtime/hooks/responsive/index.js +1 -0
  413. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-actions.d.ts +9 -0
  414. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-actions.js +1 -0
  415. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-application.d.ts +26 -0
  416. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-application.js +1 -0
  417. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-state.d.ts +10 -0
  418. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-state.js +1 -0
  419. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-store.d.ts +24 -0
  420. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-store.js +1 -0
  421. package/dist/react-sdk/application-runtime/hooks/use-access-service.d.ts +5 -0
  422. package/dist/react-sdk/application-runtime/hooks/use-access-service.js +1 -1
  423. package/dist/react-sdk/application-runtime/hooks/use-phoenix-element-slots.d.ts +2 -1
  424. package/dist/react-sdk/application-runtime/hooks/use-web-component-readonly-sync.d.ts +4 -11
  425. package/dist/react-sdk/application-runtime/hooks/use-web-component-readonly-sync.js +1 -1
  426. package/dist/react-sdk/application-runtime/hooks/use-web-component.d.ts +5 -0
  427. package/dist/react-sdk/application-runtime/hooks/use-web-component.js +1 -1
  428. package/dist/react-sdk/application-runtime/host-properties/actions.d.ts +15 -0
  429. package/dist/react-sdk/application-runtime/host-properties/actions.js +1 -0
  430. package/dist/react-sdk/application-runtime/host-properties/connect-host-properties.d.ts +30 -0
  431. package/dist/react-sdk/application-runtime/host-properties/connect-host-properties.js +1 -0
  432. package/dist/react-sdk/application-runtime/host-properties/domain-host-properties.d.ts +7 -0
  433. package/dist/react-sdk/application-runtime/host-properties/domain-host-properties.js +1 -0
  434. package/dist/react-sdk/application-runtime/host-properties/host-property-descriptors.d.ts +21 -0
  435. package/dist/react-sdk/application-runtime/host-properties/host-property-descriptors.js +1 -0
  436. package/dist/react-sdk/application-runtime/host-properties/host-property-scopes.d.ts +42 -0
  437. package/dist/react-sdk/application-runtime/host-properties/host-property-scopes.js +1 -0
  438. package/dist/react-sdk/application-runtime/host-properties/index.d.ts +8 -0
  439. package/dist/react-sdk/application-runtime/host-properties/index.js +1 -0
  440. package/dist/react-sdk/application-runtime/host-properties/initialize.d.ts +7 -0
  441. package/dist/react-sdk/application-runtime/host-properties/initialize.js +1 -0
  442. package/dist/react-sdk/application-runtime/host-properties/selectors.d.ts +54 -0
  443. package/dist/react-sdk/application-runtime/host-properties/selectors.js +1 -0
  444. package/dist/react-sdk/application-runtime/host-properties/types.d.ts +18 -0
  445. package/dist/react-sdk/application-runtime/index.d.ts +3 -0
  446. package/dist/react-sdk/application-runtime/integration/initialize.d.ts +7 -6
  447. package/dist/react-sdk/application-runtime/integration/initialize.js +1 -1
  448. package/dist/react-sdk/application-runtime/integration/selectors.d.ts +11 -2
  449. package/dist/react-sdk/application-runtime/integration/selectors.js +1 -1
  450. package/dist/react-sdk/application-runtime/integration/types.d.ts +5 -2
  451. package/dist/react-sdk/application-runtime/loading/actions.d.ts +45 -0
  452. package/dist/react-sdk/application-runtime/loading/actions.js +1 -0
  453. package/dist/react-sdk/application-runtime/loading/index.d.ts +4 -0
  454. package/dist/react-sdk/application-runtime/loading/index.js +1 -0
  455. package/dist/react-sdk/application-runtime/loading/initialize.d.ts +3 -0
  456. package/dist/react-sdk/application-runtime/loading/initialize.js +1 -0
  457. package/dist/react-sdk/application-runtime/loading/selectors.d.ts +18 -0
  458. package/dist/react-sdk/application-runtime/loading/selectors.js +1 -0
  459. package/dist/react-sdk/application-runtime/loading/types.d.ts +59 -0
  460. package/dist/react-sdk/application-runtime/loading/types.js +1 -0
  461. package/dist/react-sdk/application-runtime/mocks/create-mock-application-runtime.js +1 -1
  462. package/dist/react-sdk/application-runtime/provider/ApplicationRuntime.d.ts +6 -0
  463. package/dist/react-sdk/application-runtime/provider/ApplicationRuntime.js +1 -1
  464. package/dist/react-sdk/application-runtime/provider/ApplicationType.d.ts +7 -0
  465. package/dist/react-sdk/application-runtime/provider/ApplicationType.js +1 -1
  466. package/dist/react-sdk/application-runtime/responsive/actions.d.ts +47 -0
  467. package/dist/react-sdk/application-runtime/responsive/actions.js +1 -0
  468. package/dist/react-sdk/application-runtime/responsive/index.d.ts +4 -0
  469. package/dist/react-sdk/application-runtime/responsive/index.js +1 -0
  470. package/dist/react-sdk/application-runtime/responsive/initialize.d.ts +3 -0
  471. package/dist/react-sdk/application-runtime/responsive/initialize.js +1 -0
  472. package/dist/react-sdk/application-runtime/responsive/selectors.d.ts +20 -0
  473. package/dist/react-sdk/application-runtime/responsive/selectors.js +1 -0
  474. package/dist/react-sdk/application-runtime/responsive/types.d.ts +104 -0
  475. package/dist/react-sdk/application-runtime/responsive/types.js +1 -0
  476. package/dist/react-sdk/application-runtime/routing/routing-service.d.ts +34 -2
  477. package/dist/react-sdk/application-runtime/routing/routing-service.js +1 -1
  478. package/dist/react-sdk/application-runtime/runtime/create-application-runtime.d.ts +313 -10
  479. package/dist/react-sdk/application-runtime/runtime/create-application-runtime.js +1 -1
  480. package/dist/react-sdk/domain-application-context/BackendDataProvider.js +1 -1
  481. package/dist/react-sdk/domain-application-context/FrontendDataProvider.js +1 -1
  482. package/dist/react-sdk/domain-application-context/PublishedDataProvider.js +1 -1
  483. package/dist/react-sdk/domain-context/common-hooks/index.d.ts +9 -0
  484. package/dist/react-sdk/domain-context/common-hooks/index.js +1 -0
  485. package/dist/react-sdk/domain-context/common-hooks/use-configuration-save-state.d.ts +23 -0
  486. package/dist/react-sdk/domain-context/common-hooks/use-configuration-save-state.js +1 -0
  487. package/dist/react-sdk/domain-context/common-hooks/use-domain-context.d.ts +1 -0
  488. package/dist/react-sdk/domain-context/component-hooks/index.d.ts +1 -0
  489. package/dist/react-sdk/domain-context/component-hooks/index.js +1 -0
  490. package/dist/react-sdk/domain-context/component-hooks/use-component.js +1 -1
  491. package/dist/react-sdk/domain-context/entity-hooks/use-domain-entity-state.js +1 -1
  492. package/dist/react-sdk/domain-context/entity-hooks/use-entity-price.js +1 -1
  493. package/dist/react-sdk/domain-context/entity-hooks/use-root-entity-id.js +1 -1
  494. package/dist/react-sdk/domain-context/entity-hooks/use-root-entity-state.js +1 -1
  495. package/dist/react-sdk/domain-context/index.d.ts +5 -0
  496. package/dist/react-sdk/domain-context/index.js +1 -1
  497. package/dist/react-sdk/domain-context/system-hooks/composition-hooks.js +1 -1
  498. package/dist/react-sdk/domain-context/system-hooks/dimension-hooks.js +1 -1
  499. package/dist/react-sdk/domain-context/system-hooks/generate-share-link-hooks.d.ts +8 -0
  500. package/dist/react-sdk/domain-context/system-hooks/generate-share-link-hooks.js +1 -0
  501. package/dist/react-sdk/domain-context/system-hooks/hover-hooks.js +1 -1
  502. package/dist/react-sdk/domain-context/system-hooks/locale-switch-hooks.d.ts +24 -0
  503. package/dist/react-sdk/domain-context/system-hooks/locale-switch-hooks.js +1 -0
  504. package/dist/react-sdk/domain-context/system-hooks/navigation-hooks.js +1 -1
  505. package/dist/react-sdk/domain-context/system-hooks/selection-hooks.js +1 -1
  506. package/dist/react-sdk/domain-context/system-hooks/share-links-hooks.d.ts +32 -0
  507. package/dist/react-sdk/domain-context/system-hooks/share-links-hooks.js +1 -0
  508. package/dist/react-sdk/domain-context/system-hooks/step-navigation-hooks.d.ts +57 -0
  509. package/dist/react-sdk/domain-context/system-hooks/step-navigation-hooks.js +1 -0
  510. package/dist/react-sdk/domain-context/system-hooks/tab-navigation-hooks.js +1 -1
  511. package/dist/react-sdk/domain-context/system-hooks/undo-redo-hooks.js +1 -1
  512. package/dist/react-sdk/handlers/FinalizeActionRunner.d.ts +13 -0
  513. package/dist/react-sdk/handlers/FinalizeActionRunner.js +1 -0
  514. package/dist/react-sdk/handlers/FinalizeActionsHost.d.ts +10 -0
  515. package/dist/react-sdk/handlers/FinalizeActionsHost.js +1 -0
  516. package/dist/react-sdk/handlers/FinalizeActionsProvider.d.ts +11 -0
  517. package/dist/react-sdk/handlers/FinalizeActionsProvider.js +1 -0
  518. package/dist/react-sdk/handlers/finalize-actions-context.d.ts +6 -0
  519. package/dist/react-sdk/handlers/finalize-actions-context.js +1 -0
  520. package/dist/react-sdk/handlers/finalize-actions-store.d.ts +23 -0
  521. package/dist/react-sdk/handlers/finalize-actions-store.js +1 -0
  522. package/dist/react-sdk/handlers/index.d.ts +5 -0
  523. package/dist/react-sdk/handlers/index.js +1 -1
  524. package/dist/react-sdk/handlers/use-finalize-action.d.ts +3 -2
  525. package/dist/react-sdk/handlers/use-finalize-action.js +1 -1
  526. package/dist/react-sdk/handlers/use-finalize-actions.d.ts +53 -0
  527. package/dist/react-sdk/handlers/use-finalize-actions.js +1 -0
  528. package/dist/react-sdk/index.d.ts +2 -0
  529. package/dist/react-sdk/index.js +1 -1
  530. package/dist/react-sdk/loading-state/LoaderHost.d.ts +20 -0
  531. package/dist/react-sdk/loading-state/LoaderHost.js +1 -0
  532. package/dist/react-sdk/loading-state/built-in-loading-phase.d.ts +9 -0
  533. package/dist/react-sdk/loading-state/built-in-loading-phase.js +1 -0
  534. package/dist/react-sdk/loading-state/create-app-loader.d.ts +18 -0
  535. package/dist/react-sdk/loading-state/create-app-loader.js +1 -0
  536. package/dist/react-sdk/loading-state/index.d.ts +4 -2
  537. package/dist/react-sdk/loading-state/index.js +1 -1
  538. package/dist/react-sdk/loading-state/{LoadingStateProvider.d.ts → loading-state-hooks.d.ts} +3 -12
  539. package/dist/react-sdk/loading-state/loading-state-hooks.js +1 -0
  540. package/dist/react-sdk/page-elements/ConfigurationNotFoundPagePropsProvider.d.ts +17 -0
  541. package/dist/react-sdk/page-elements/ConfigurationNotFoundPagePropsProvider.js +1 -0
  542. package/dist/react-sdk/page-elements/ConfiguratorErrorPagePropsProvider.d.ts +17 -0
  543. package/dist/react-sdk/page-elements/ConfiguratorErrorPagePropsProvider.js +1 -0
  544. package/dist/react-sdk/page-elements/configurator-error-page-props.d.ts +20 -0
  545. package/dist/react-sdk/page-elements/configurator-error-page-props.js +1 -0
  546. package/dist/react-sdk/page-elements/create-configuration-not-found-page.d.ts +15 -0
  547. package/dist/react-sdk/page-elements/create-configuration-not-found-page.js +1 -0
  548. package/dist/react-sdk/page-elements/create-configurator-error-page.d.ts +14 -0
  549. package/dist/react-sdk/page-elements/create-configurator-error-page.js +1 -0
  550. package/dist/react-sdk/page-elements/create-preset-ui.d.ts +9 -0
  551. package/dist/react-sdk/page-elements/create-preset-ui.js +1 -0
  552. package/dist/react-sdk/page-elements/index.d.ts +6 -0
  553. package/dist/react-sdk/page-elements/index.js +1 -0
  554. package/dist/react-sdk/scene-control-elements/AddEntityFabElement.d.ts +36 -0
  555. package/dist/react-sdk/scene-control-elements/AddEntityFabElement.js +1 -0
  556. package/dist/react-sdk/scene-control-elements/DeleteEntityFabElement.d.ts +44 -0
  557. package/dist/react-sdk/scene-control-elements/DeleteEntityFabElement.js +1 -0
  558. package/dist/react-sdk/scene-control-elements/NavigationHotspotElement.d.ts +38 -0
  559. package/dist/react-sdk/scene-control-elements/NavigationHotspotElement.js +1 -0
  560. package/dist/react-sdk/scene-control-elements/SelectionHotspotElement.d.ts +30 -0
  561. package/dist/react-sdk/scene-control-elements/SelectionHotspotElement.js +1 -0
  562. package/dist/react-sdk/scene-control-elements/ToolMenuFabElement.d.ts +30 -0
  563. package/dist/react-sdk/scene-control-elements/ToolMenuFabElement.js +1 -0
  564. package/dist/react-sdk/scene-control-elements/create-add-entity-fab.d.ts +9 -0
  565. package/dist/react-sdk/scene-control-elements/create-add-entity-fab.js +1 -0
  566. package/dist/react-sdk/scene-control-elements/create-delete-entity-fab.d.ts +9 -0
  567. package/dist/react-sdk/scene-control-elements/create-delete-entity-fab.js +1 -0
  568. package/dist/react-sdk/scene-control-elements/create-navigation-hotspot.d.ts +10 -0
  569. package/dist/react-sdk/scene-control-elements/create-navigation-hotspot.js +1 -0
  570. package/dist/react-sdk/scene-control-elements/create-selection-hotspot.d.ts +9 -0
  571. package/dist/react-sdk/scene-control-elements/create-selection-hotspot.js +1 -0
  572. package/dist/react-sdk/scene-control-elements/create-tool-menu-fab.d.ts +9 -0
  573. package/dist/react-sdk/scene-control-elements/create-tool-menu-fab.js +1 -0
  574. package/dist/react-sdk/scene-control-elements/index.d.ts +11 -0
  575. package/dist/react-sdk/scene-control-elements/index.js +1 -0
  576. package/dist/react-sdk/scene-control-elements/scene-control-tooltip-visibility.d.ts +5 -0
  577. package/dist/react-sdk/scene-control-elements/scene-control-tooltip-visibility.js +1 -0
  578. package/dist/react-sdk/session/SessionServiceProvider.js +1 -1
  579. package/dist/react-sdk/web-component/create-web-component.js +1 -1
  580. package/dist/react-sdk/web-component/god-mode/god-mode-registry.d.ts +30 -0
  581. package/dist/react-sdk/web-component/god-mode/god-mode-registry.js +1 -0
  582. package/dist/react-sdk/web-component/god-mode/index.d.ts +1 -0
  583. package/dist/react-sdk/web-component/god-mode/index.js +1 -0
  584. package/dist/ui-sdk/icons/__generated/Lightbulb.d.ts +3 -0
  585. package/dist/ui-sdk/icons/__generated/Lightbulb.js +1 -0
  586. package/dist/ui-sdk/icons/__generated/XCircle.d.ts +3 -0
  587. package/dist/ui-sdk/icons/__generated/XCircle.js +1 -0
  588. package/dist/ui-sdk/icons/__generated/index.d.ts +4 -0
  589. package/dist/ui-sdk/icons/__generated/index.js +1 -1
  590. package/dist/ui-sdk/icons/icons-registry.js +1 -1
  591. package/dist/ui-sdk/responsive-application/ResponsiveApplicationProvider.d.ts +9 -0
  592. package/dist/ui-sdk/responsive-application/ResponsiveApplicationProvider.js +1 -1
  593. package/dist/ui-sdk/responsive-application/index.d.ts +1 -0
  594. package/dist/ui-sdk/responsive-application/responsive-state-store.d.ts +59 -0
  595. package/dist/ui-sdk/responsive-application/responsive-state-store.js +1 -0
  596. package/dist/ui-sdk/responsive-application/use-store-responsive-info.d.ts +7 -0
  597. package/dist/ui-sdk/responsive-application/use-store-responsive-info.js +1 -0
  598. package/dist/ui-sdk/theme/types.d.ts +5 -1
  599. package/package.json +13 -3
  600. package/schema/domain-spec.json +140 -0
  601. package/dist/configurator/ui/assistant/form/built-in-form-registry.d.ts +0 -10
  602. package/dist/configurator/ui/assistant/form/built-in-form-registry.js +0 -1
  603. package/dist/configurator/ui/assistant/form/form-registry.js +0 -8
  604. package/dist/configurator/ui/phoenix-layout-elements/language/use-locale-options.d.ts +0 -4
  605. package/dist/configurator/ui/phoenix-layout-elements/language/use-locale-options.js +0 -1
  606. package/dist/configurator/ui/phoenix-layout-elements/measurements/use-measurements-visibility.d.ts +0 -4
  607. package/dist/configurator/ui/phoenix-layout-elements/measurements/use-measurements-visibility.js +0 -1
  608. package/dist/domain/application/assistant/assistant-form-elements.d.ts +0 -28
  609. package/dist/domain/application/assistant/assistant-form-elements.js +0 -1
  610. package/dist/domain/extensions/client-side-image.js +0 -1
  611. package/dist/domain/extensions/save-template-thumbnail.js +0 -1
  612. package/dist/react-sdk/application-runtime/runtime/god-mode.d.ts +0 -19
  613. package/dist/react-sdk/application-runtime/runtime/god-mode.js +0 -1
  614. package/dist/react-sdk/loading-state/LoadingStateProvider.js +0 -1
  615. package/dist/react-sdk/loading-state/loading-state-manager.d.ts +0 -32
  616. package/dist/react-sdk/loading-state/loading-state-manager.js +0 -1
  617. /package/dist/{configurator/ui/assistant/form/form-element-definition.js → react-sdk/application-runtime/host-properties/types.js} +0 -0
@@ -0,0 +1,2683 @@
1
+ # Sunfold Solar
2
+
3
+ Help a household decide on a system by showing what it would do for them. A solar installer, Sunfold, lets a homeowner plan panels and a battery for their roof: the roof's area and direction, the number of panels, the battery, the tariff and the home's yearly use. The page answers with a dashboard: four figures, the solar year month by month, the year the system pays for itself, and how much of the power the home uses. Every figure is an expression of the spec, so the dashboard changes the moment an input does, and **Book a home survey** is a finalize action. There is no 3D model; the tiles and charts are React and hand-written SVG.
4
+
5
+ - [Try it](#try-it)
6
+ - [What the example demonstrates](#what-the-example-demonstrates)
7
+ - [A dashboard instead of a scene](#a-dashboard-instead-of-a-scene)
8
+ - [Every figure is an expression](#every-figure-is-an-expression)
9
+ - [Figures on tiles](#figures-on-tiles)
10
+ - [Charts in SVG](#charts-in-svg)
11
+ - [A warning and a notice](#a-warning-and-a-notice)
12
+ - [Book a home survey](#book-a-home-survey)
13
+ - [On a phone](#on-a-phone)
14
+ - [Test cases](#test-cases)
15
+ - [What this layout leaves out](#what-this-layout-leaves-out)
16
+ - [Related pages](#related-pages)
17
+ - [Complete Implementation](#complete-implementation)
18
+
19
+ ## Try it
20
+
21
+ The phone version is under [On a phone](#on-a-phone).
22
+
23
+ ## What the example demonstrates
24
+
25
+ | Piece | Where |
26
+ | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
27
+ | A `ConfiguratorLayout` that mounts no `LayoutSlots.SharedCanvas` | [A dashboard instead of a scene](#a-dashboard-instead-of-a-scene) |
28
+ | Numbers and series declared in `expressions:` and read with `phx.frontend.useRootEntityState()` | [Every figure is an expression](#every-figure-is-an-expression) |
29
+ | KPI tiles | `KpiTiles`, [Figures on tiles](#figures-on-tiles) |
30
+ | A stacked column chart with a line, a payback curve and a split bar, with tooltips | `MonthlyEnergyChart`, `PaybackChart`, `EnergySplitBar`, [Charts in SVG](#charts-in-svg) |
31
+ | A `warning` that blocks finalizing beside a `notice` that does not | `PlannerInputs`, [A warning and a notice](#a-warning-and-a-notice) |
32
+ | `phx.frontend.useFinalizeActions()` for the spec's `survey` action | `SurveyPanel`, [Book a home survey](#book-a-home-survey) |
33
+
34
+ ## A dashboard instead of a scene
35
+
36
+ `src/elements/ConfiguratorLayout.tsx` mounts the services and a page, never `LayoutSlots.SharedCanvas`, so no scene loads and the app loader does not wait for one. On a desktop the inputs are a sidebar with the cost and the booking at its foot, and the dashboard fills the rest; on a phone everything is one scrolling column with the cost in a bar at the bottom.
37
+
38
+ ```tsx
39
+ import { Box } from '@mui/material'
40
+
41
+ import { LayoutSlots } from '@salsita-ai/sdk/configurator'
42
+
43
+ import { phx } from '../../.phoenix/phx.js'
44
+ import { DesktopPage } from '../_shared/DesktopPage.js'
45
+ import { PhonePage } from '../_shared/PhonePage.js'
46
+ import { SUNFOLD } from '../_shared/tokens.js'
47
+
48
+ // A planning dashboard with no scene: the layout never mounts `LayoutSlots.SharedCanvas`, and every tile and chart is
49
+ // React and SVG drawn from the spec's expressions
50
+ const ConfiguratorLayout = () => {
51
+ // `null` until the container has been measured
52
+ const viewports = phx.frontend.useActiveViewports()
53
+ const isDesktop = viewports?.includes('desktop') ?? false
54
+
55
+ return (
56
+ <Box component="div" sx={{ position: 'absolute', inset: 0, overflow: 'clip', bgcolor: SUNFOLD.linen }}>
57
+ <LayoutSlots.Services />
58
+ {viewports !== null && (isDesktop ? <DesktopPage /> : <PhonePage />)}
59
+ </Box>
60
+ )
61
+ }
62
+
63
+ export default phx.frontend.createCustomPhoenixLayoutElement('ConfiguratorLayout', ConfiguratorLayout)
64
+ ```
65
+
66
+ ## Every figure is an expression
67
+
68
+ The dashboard computes nothing itself. The system's size, its production month by month, how the production splits between the home and the grid, the yearly saving, the cost and the money returned year by year are expressions of the root entity. Declaring them in `expressions:` gives them their types, so the layout reads `$monthly` as an array of months and `$cumulativeSavings` as an array of numbers with `phx.frontend.useRootEntityState()`.
69
+
70
+ ```yaml
71
+ expressions:
72
+ SolarPlan:
73
+ $systemKwp:
74
+ type: number
75
+ $roofPanelLimit:
76
+ type: number
77
+ $panelAreaM2:
78
+ type: number
79
+ $annualProductionKwh:
80
+ type: number
81
+ $monthly:
82
+ type: array
83
+ items:
84
+ type: object
85
+ properties:
86
+ month:
87
+ type: string
88
+ productionKwh:
89
+ type: number
90
+ consumptionKwh:
91
+ type: number
92
+ selfUseKwh:
93
+ type: number
94
+ exportKwh:
95
+ type: number
96
+ required:
97
+ - month
98
+ - productionKwh
99
+ - consumptionKwh
100
+ - selfUseKwh
101
+ - exportKwh
102
+ additionalProperties: false
103
+ $selfConsumption:
104
+ type: number
105
+ $selfSufficiency:
106
+ type: number
107
+ $annualSavings:
108
+ type: number
109
+ $systemCost:
110
+ type: number
111
+ $cumulativeSavings:
112
+ type: array
113
+ items:
114
+ type: number
115
+ $paybackYears:
116
+ type: number
117
+ $co2Tonnes:
118
+ type: number
119
+ ```
120
+
121
+ The model in `src/expressions/solar.ts` is deliberately simple: a south-facing kWp yields 1,000 kWh a year, spread over the months by a typical central European sun curve. Without a battery the home uses solar power only while the sun shines; a battery carries the day's surplus into the evening. The saving counts the power the home no longer buys and the power it sells, and the running total grows with prices rising 2 % a year while the panels lose half a percent.
122
+
123
+ ```ts
124
+ import { phx } from '../../.phoenix/phx.js'
125
+
126
+ const PANEL_KWP = 0.43
127
+ const PANEL_AREA_M2 = 1.95
128
+ // Vents, chimneys and the edge clearance leave about 85 % of a roof for panels
129
+ const USABLE_ROOF = 0.85
130
+ // A south-facing kWp in central Europe yields about 1,000 kWh a year
131
+ const YIELD_KWH_PER_KWP = 1000
132
+ const ORIENTATION_FACTOR = { South: 1, SouthEastWest: 0.95, EastWest: 0.82, North: 0.6 }
133
+
134
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
135
+ // The share of the year's sun and of the household's use that falls in each month
136
+ const SUN_SHARE = [0.04, 0.045, 0.08, 0.105, 0.125, 0.13, 0.13, 0.115, 0.09, 0.065, 0.04, 0.035]
137
+ const USE_SHARE = [0.1, 0.09, 0.088, 0.08, 0.075, 0.07, 0.07, 0.072, 0.076, 0.085, 0.094, 0.1]
138
+ const DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
139
+
140
+ const BATTERY_KWH = { None: 0, Kwh5: 5, Kwh10: 10, Kwh15: 15 }
141
+ const BATTERY_PRICE = { None: 0, Kwh5: 3600, Kwh10: 6400, Kwh15: 8900 }
142
+ const TARIFF = {
143
+ Flat: { buy: 0.32, sell: 0.08, shiftBonus: 0 },
144
+ TimeOfUse: { buy: 0.29, sell: 0.1, shiftBonus: 0.09 },
145
+ ExportPlus: { buy: 0.32, sell: 0.15, shiftBonus: 0 },
146
+ }
147
+ // Panels lose half a percent a year; electricity gets 2 % dearer a year
148
+ const DEGRADATION = 0.995
149
+ const PRICE_GROWTH = 1.02
150
+ const YEARS = 25
151
+
152
+ export default phx.domain.implementCustomExpressions({
153
+ SolarPlan: {
154
+ $systemKwp: s => {
155
+ return s.panels * PANEL_KWP
156
+ },
157
+ $roofPanelLimit: s => {
158
+ return Math.floor((s.roofArea * USABLE_ROOF) / PANEL_AREA_M2)
159
+ },
160
+ // The roof the panels cover, rounded up to whole square metres, as the tiles and the roof warning quote it
161
+ $panelAreaM2: s => {
162
+ return Math.ceil(s.panels * PANEL_AREA_M2)
163
+ },
164
+ $annualProductionKwh: s => {
165
+ return s.$systemKwp * YIELD_KWH_PER_KWP * ORIENTATION_FACTOR[s.orientation]
166
+ },
167
+ // Each month's production and use, and how the production splits between the home and the grid. Without a
168
+ // battery the home uses solar power only while the sun shines; a battery carries the surplus into the evening,
169
+ // one charge a day, and covers the evening but not the early morning.
170
+ $monthly: s => {
171
+ const batteryKwh = BATTERY_KWH[s.battery] * 0.9
172
+
173
+ return MONTHS.map((month, index) => {
174
+ const productionKwh = s.$annualProductionKwh * (SUN_SHARE[index] ?? 0)
175
+ const consumptionKwh = s.annualUse * (USE_SHARE[index] ?? 0)
176
+ const direct = Math.min(productionKwh * 0.55, consumptionKwh * 0.45)
177
+ const stored = Math.min(
178
+ productionKwh - direct,
179
+ batteryKwh * (DAYS[index] ?? 30),
180
+ (consumptionKwh - direct) * 0.6
181
+ )
182
+ const selfUseKwh = direct + stored
183
+
184
+ return { month, productionKwh, consumptionKwh, selfUseKwh, exportKwh: productionKwh - selfUseKwh }
185
+ })
186
+ },
187
+ $selfConsumption: s => {
188
+ const selfUse = s.$monthly.reduce((sum, month) => sum + month.selfUseKwh, 0)
189
+
190
+ return s.$annualProductionKwh > 0 ? selfUse / s.$annualProductionKwh : 0
191
+ },
192
+ $selfSufficiency: s => {
193
+ const selfUse = s.$monthly.reduce((sum, month) => sum + month.selfUseKwh, 0)
194
+
195
+ return selfUse / s.annualUse
196
+ },
197
+ // A year's saving: power the home no longer buys, power sold to the grid, and on a time-of-use tariff the
198
+ // evening power a battery shifts away from the dear hours
199
+ $annualSavings: s => {
200
+ const tariff = TARIFF[s.tariff]
201
+ const selfUse = s.$monthly.reduce((sum, month) => sum + month.selfUseKwh, 0)
202
+ const exported = s.$monthly.reduce((sum, month) => sum + month.exportKwh, 0)
203
+ const shifted = s.battery === 'None' ? 0 : selfUse * 0.4
204
+
205
+ return selfUse * tariff.buy + exported * tariff.sell + shifted * tariff.shiftBonus
206
+ },
207
+ $systemCost: s => {
208
+ return Math.round(1500 + s.$systemKwp * 1050 + BATTERY_PRICE[s.battery])
209
+ },
210
+ // The money the system has returned at the end of each year, starting at minus its cost in year 0
211
+ $cumulativeSavings: s => {
212
+ return Array.from({ length: YEARS + 1 }, (_, year) => {
213
+ const earned = Array.from(
214
+ { length: year },
215
+ (__, past) => s.$annualSavings * (PRICE_GROWTH * DEGRADATION) ** past
216
+ ).reduce((sum, value) => sum + value, 0)
217
+
218
+ return earned - s.$systemCost
219
+ })
220
+ },
221
+ // The year the running total crosses zero, between whole years; beyond the panels' 25 years it is `Infinity`
222
+ $paybackYears: s => {
223
+ const totals = s.$cumulativeSavings
224
+ const year = totals.findIndex(total => total >= 0)
225
+
226
+ if (year <= 0) {
227
+ return year === 0 ? 0 : Infinity
228
+ }
229
+
230
+ const before = totals[year - 1] ?? 0
231
+ const after = totals[year] ?? 0
232
+
233
+ return year - 1 + -before / (after - before)
234
+ },
235
+ $co2Tonnes: s => {
236
+ return (s.$annualProductionKwh * 0.35) / 1000
237
+ },
238
+ },
239
+ })
240
+ ```
241
+
242
+ The cost is the `price` component's value, so `phx.frontend.useRootEntityPrice()` formats it in euros:
243
+
244
+ ```yaml
245
+ components:
246
+ SolarPlan:
247
+ price:
248
+ main:
249
+ price:
250
+ $read: $systemCost
251
+ currency: EUR
252
+ ```
253
+
254
+ ## Figures on tiles
255
+
256
+ `KpiTiles` shows the four figures a household asks first, each with the context that makes it mean something: the panels and roof behind the system size, the CO₂ behind the production, what the self-consumption share means, and the first year's saving behind the payback.
257
+
258
+ ```tsx
259
+ import { Box, Typography } from '@mui/material'
260
+
261
+ import { phx } from '../../.phoenix/phx.js'
262
+ import { CARD_SX, SUNFOLD } from './tokens.js'
263
+
264
+ const euro = (value: number) => {
265
+ return value.toLocaleString('en-IE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })
266
+ }
267
+
268
+ interface KpiTilesProps {
269
+ readonly columns: number
270
+ }
271
+
272
+ // The four figures a household asks first, each with the context that makes it mean something. Every value is an
273
+ // expression of the spec, read from the root entity.
274
+ export const KpiTiles = ({ columns }: KpiTilesProps) => {
275
+ const plan = phx.frontend.useRootEntityState()
276
+ const payback = plan.$paybackYears
277
+ const tiles = [
278
+ {
279
+ label: 'System size',
280
+ value: `${plan.$systemKwp.toFixed(1)} kWp`,
281
+ context: `${plan.panels} panels, ${plan.$panelAreaM2} m² of roof`,
282
+ },
283
+ {
284
+ label: 'Annual production',
285
+ value: `${Math.round(plan.$annualProductionKwh).toLocaleString('en')} kWh`,
286
+ context: `About ${plan.$co2Tonnes.toFixed(1)} t of CO₂ avoided a year`,
287
+ },
288
+ {
289
+ label: 'Self-consumption',
290
+ value: `${Math.round(plan.$selfConsumption * 100)} %`,
291
+ context: 'of the solar power is used at home',
292
+ },
293
+ {
294
+ label: 'Payback',
295
+ value: Number.isFinite(payback) ? `${payback.toFixed(1)} years` : 'Over 25 years',
296
+ context: `${euro(plan.$annualSavings)} saved in the first year`,
297
+ },
298
+ ]
299
+
300
+ return (
301
+ <Box component="div" sx={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gap: 2 }}>
302
+ {tiles.map(tile => (
303
+ <Box
304
+ component="section"
305
+ key={tile.label}
306
+ aria-label={tile.label}
307
+ sx={{ ...CARD_SX, display: 'grid', gap: 0.5, padding: 2 }}
308
+ >
309
+ <Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 13, color: SUNFOLD.muted }}>{tile.label}</Typography>
310
+ <Typography
311
+ sx={{
312
+ fontFamily: SUNFOLD.font,
313
+ fontSize: columns > 2 ? 30 : 24,
314
+ fontWeight: 600,
315
+ lineHeight: 1.15,
316
+ color: SUNFOLD.ink,
317
+ }}
318
+ >
319
+ {tile.value}
320
+ </Typography>
321
+ <Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.muted }}>{tile.context}</Typography>
322
+ </Box>
323
+ ))}
324
+ </Box>
325
+ )
326
+ }
327
+ ```
328
+
329
+ ## Charts in SVG
330
+
331
+ The charts are hand-written SVG; the example adds no chart library. Each chart measures its width with a `ResizeObserver` and draws its text at its real size.
332
+
333
+ - **Your solar year.** `MonthlyEnergyChart` draws each month's production as a column split into what the home uses and what goes to the grid, with a 2-pixel gap between the parts and a rounded top, and the home's use as a line across it. A legend names the three series, hovering a month shows its figures, and **Show figures** swaps the chart for a table of the same numbers.
334
+ - **When it pays back.** `PaybackChart` draws `$cumulativeSavings`, which starts at minus the system's cost, marks the year it crosses zero, and reads off any year under the pointer.
335
+ - **Where the power goes.** `EnergySplitBar` splits a year of production between the home and the grid, and says how much of the home's use solar covers.
336
+
337
+ The series colours are a fixed set checked together for colour-vision deficiencies; two are lighter than 3:1 against white, which is why every chart also has a legend, a tooltip or the figures written out.
338
+
339
+ ```tsx
340
+ import { Box, ButtonBase, Typography } from '@mui/material'
341
+ import { useState } from 'react'
342
+
343
+ import { phx } from '../../.phoenix/phx.js'
344
+ import { CHART, SERIES } from './chart-style.js'
345
+ import { niceScale } from './nice-scale.js'
346
+ import { useWidth } from './use-width.js'
347
+
348
+ const MARGIN = { top: 12, right: 8, bottom: 26, left: 48 }
349
+ const GAP = 2
350
+ const RADIUS = 4
351
+
352
+ const kwh = (value: number) => {
353
+ return `${Math.round(value).toLocaleString('en')} kWh`
354
+ }
355
+
356
+ // A column segment whose top corners are rounded, so the stack ends in a 4px data-end and stays square at the
357
+ // baseline
358
+ const roundedTop = (x: number, y: number, width: number, height: number) => {
359
+ const r = Math.min(RADIUS, height, width / 2)
360
+
361
+ return `M${x},${y + height} V${y + r} Q${x},${y} ${x + r},${y} H${x + width - r} Q${x + width},${y} ${x + width},${y + r} V${y + height} Z`
362
+ }
363
+
364
+ const LEGEND = [
365
+ { key: 'selfUse', label: 'Solar used at home', color: SERIES.selfUse, line: false },
366
+ { key: 'exported', label: 'Solar sent to the grid', color: SERIES.exported, line: false },
367
+ { key: 'consumption', label: 'Home use', color: SERIES.consumption, line: true },
368
+ ]
369
+
370
+ interface MonthlyEnergyChartProps {
371
+ readonly height: number
372
+ }
373
+
374
+ // Each month's solar production as a column split into what the home uses and what goes to the grid, with the
375
+ // home's use as a line across it. All figures come from the spec's `$monthly` expression. Hovering a month shows its
376
+ // figures; "Show figures" swaps the chart for a table of the same numbers.
377
+ export const MonthlyEnergyChart = ({ height }: MonthlyEnergyChartProps) => {
378
+ const plan = phx.frontend.useRootEntityState()
379
+ const { ref, width } = useWidth()
380
+ const [hovered, setHovered] = useState<number | null>(null)
381
+ const [asTable, setAsTable] = useState(false)
382
+ const months = plan.$monthly
383
+ const peak = Math.max(...months.map(month => Math.max(month.productionKwh, month.consumptionKwh)))
384
+ const { high, ticks } = niceScale(0, peak, 4)
385
+ const plotWidth = Math.max(width - MARGIN.left - MARGIN.right, 0)
386
+ const plotHeight = height - MARGIN.top - MARGIN.bottom
387
+ const band = plotWidth / months.length
388
+ const barWidth = Math.min(24, band * 0.62)
389
+ const y = (value: number) => MARGIN.top + plotHeight - (value / high) * plotHeight
390
+ const x = (index: number) => MARGIN.left + band * index + band / 2
391
+ const active = hovered === null ? undefined : months[hovered]
392
+
393
+ return (
394
+ <Box component="div" sx={{ display: 'grid', gap: 1.5 }}>
395
+ <Box component="div" sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', columnGap: 2, rowGap: 0.5 }}>
396
+ {LEGEND.map(item => (
397
+ <Box component="span" key={item.key} sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75 }}>
398
+ <Box
399
+ component="span"
400
+ aria-hidden
401
+ sx={
402
+ item.line
403
+ ? { inlineSize: 14, blockSize: 2, borderRadius: 1, bgcolor: item.color }
404
+ : { inlineSize: 10, blockSize: 10, borderRadius: 0.5, bgcolor: item.color }
405
+ }
406
+ />
407
+ <Typography component="span" sx={{ fontFamily: CHART.font, fontSize: 12, color: CHART.inkSoft }}>
408
+ {item.label}
409
+ </Typography>
410
+ </Box>
411
+ ))}
412
+ <ButtonBase
413
+ onClick={() => setAsTable(shown => !shown)}
414
+ sx={{
415
+ marginInlineStart: 'auto',
416
+ fontFamily: CHART.font,
417
+ fontSize: 12,
418
+ color: CHART.ink,
419
+ textDecoration: 'underline',
420
+ }}
421
+ >
422
+ {asTable ? 'Show chart' : 'Show figures'}
423
+ </ButtonBase>
424
+ </Box>
425
+
426
+ {/* Measured whether the chart or the table shows, so the chart comes back at the current width */}
427
+ <Box component="div" ref={ref}>
428
+ {asTable ? (
429
+ <Box
430
+ component="table"
431
+ sx={{
432
+ inlineSize: '100%',
433
+ borderCollapse: 'collapse',
434
+ fontFamily: CHART.font,
435
+ fontSize: 12,
436
+ color: CHART.ink,
437
+ }}
438
+ >
439
+ <thead>
440
+ <tr>
441
+ {['Month', 'Produced', 'Used at home', 'Sent to grid', 'Home use'].map(heading => (
442
+ <Box
443
+ component="th"
444
+ key={heading}
445
+ sx={{
446
+ paddingBlock: 0.5,
447
+ textAlign: 'end',
448
+ fontWeight: 500,
449
+ color: CHART.inkSoft,
450
+ '&:first-of-type': { textAlign: 'start' },
451
+ }}
452
+ >
453
+ {heading}
454
+ </Box>
455
+ ))}
456
+ </tr>
457
+ </thead>
458
+ <tbody>
459
+ {months.map(month => (
460
+ <Box component="tr" key={month.month} sx={{ borderBlockStart: `1px solid ${CHART.grid}` }}>
461
+ <Box component="td" sx={{ paddingBlock: 0.5 }}>
462
+ {month.month}
463
+ </Box>
464
+ {[month.productionKwh, month.selfUseKwh, month.exportKwh, month.consumptionKwh].map(
465
+ (value, index) => (
466
+ <Box component="td" key={index} sx={{ textAlign: 'end', fontVariantNumeric: 'tabular-nums' }}>
467
+ {Math.round(value).toLocaleString('en')}
468
+ </Box>
469
+ )
470
+ )}
471
+ </Box>
472
+ ))}
473
+ </tbody>
474
+ </Box>
475
+ ) : (
476
+ <Box component="div" sx={{ position: 'relative', blockSize: height }}>
477
+ {width > 0 && (
478
+ <svg
479
+ width={width}
480
+ height={height}
481
+ role="img"
482
+ aria-label="Solar production and home use by month"
483
+ onMouseLeave={() => setHovered(null)}
484
+ >
485
+ {ticks.map(tick => (
486
+ <g key={tick}>
487
+ <line
488
+ x1={MARGIN.left}
489
+ x2={width - MARGIN.right}
490
+ y1={y(tick)}
491
+ y2={y(tick)}
492
+ stroke={tick === 0 ? CHART.axis : CHART.grid}
493
+ strokeWidth={1}
494
+ />
495
+ <text
496
+ x={MARGIN.left - 8}
497
+ y={y(tick)}
498
+ textAnchor="end"
499
+ dominantBaseline="middle"
500
+ fill={CHART.inkSoft}
501
+ fontFamily={CHART.font}
502
+ fontSize={11}
503
+ >
504
+ {tick.toLocaleString('en')}
505
+ </text>
506
+ </g>
507
+ ))}
508
+
509
+ {hovered !== null && (
510
+ <rect
511
+ x={MARGIN.left + band * hovered}
512
+ y={MARGIN.top}
513
+ width={band}
514
+ height={plotHeight}
515
+ fill={CHART.grid}
516
+ opacity={0.6}
517
+ />
518
+ )}
519
+
520
+ {months.map((month, index) => {
521
+ const left = x(index) - barWidth / 2
522
+ const selfTop = y(month.selfUseKwh)
523
+ const exportTop = y(month.productionKwh)
524
+ const exportHeight = Math.max(selfTop - exportTop - GAP, 0)
525
+
526
+ return (
527
+ <g key={month.month}>
528
+ {exportHeight > 0.5 ? (
529
+ <>
530
+ <rect x={left} y={selfTop} width={barWidth} height={y(0) - selfTop} fill={SERIES.selfUse} />
531
+ <path d={roundedTop(left, exportTop, barWidth, exportHeight)} fill={SERIES.exported} />
532
+ </>
533
+ ) : (
534
+ <path d={roundedTop(left, selfTop, barWidth, y(0) - selfTop)} fill={SERIES.selfUse} />
535
+ )}
536
+ <text
537
+ x={x(index)}
538
+ y={height - 8}
539
+ textAnchor="middle"
540
+ fill={CHART.inkSoft}
541
+ fontFamily={CHART.font}
542
+ fontSize={11}
543
+ >
544
+ {/* A narrow chart keeps only the initial */}
545
+ {band < 34 ? month.month.slice(0, 1) : month.month}
546
+ </text>
547
+ </g>
548
+ )
549
+ })}
550
+
551
+ <polyline
552
+ points={months.map((month, index) => `${x(index)},${y(month.consumptionKwh)}`).join(' ')}
553
+ fill="none"
554
+ stroke={SERIES.consumption}
555
+ strokeWidth={2}
556
+ strokeLinejoin="round"
557
+ strokeLinecap="round"
558
+ />
559
+ {months.map((month, index) => (
560
+ <circle
561
+ key={month.month}
562
+ cx={x(index)}
563
+ cy={y(month.consumptionKwh)}
564
+ r={4}
565
+ fill={SERIES.consumption}
566
+ stroke={CHART.surface}
567
+ strokeWidth={2}
568
+ />
569
+ ))}
570
+
571
+ {/* Hit areas a full band wide and the plot's height, larger than the marks */}
572
+ {months.map((month, index) => (
573
+ <rect
574
+ key={month.month}
575
+ x={MARGIN.left + band * index}
576
+ y={MARGIN.top}
577
+ width={band}
578
+ height={plotHeight}
579
+ fill="transparent"
580
+ onMouseEnter={() => setHovered(index)}
581
+ />
582
+ ))}
583
+ </svg>
584
+ )}
585
+
586
+ {active !== undefined && hovered !== null && (
587
+ <Box
588
+ component="div"
589
+ role="tooltip"
590
+ sx={{
591
+ position: 'absolute',
592
+ insetBlockStart: 0,
593
+ insetInlineStart: Math.min(Math.max(x(hovered) - 90, 0), Math.max(width - 180, 0)),
594
+ inlineSize: 180,
595
+ padding: 1.25,
596
+ borderRadius: 1.5,
597
+ bgcolor: CHART.surface,
598
+ boxShadow: '0 6px 24px rgba(28, 40, 38, 0.16)',
599
+ pointerEvents: 'none',
600
+ fontFamily: CHART.font,
601
+ }}
602
+ >
603
+ <Typography sx={{ fontFamily: CHART.font, fontSize: 12, fontWeight: 600, color: CHART.ink }}>
604
+ {active.month}
605
+ </Typography>
606
+ {[
607
+ { label: 'Used at home', value: active.selfUseKwh, color: SERIES.selfUse },
608
+ { label: 'Sent to grid', value: active.exportKwh, color: SERIES.exported },
609
+ { label: 'Home use', value: active.consumptionKwh, color: SERIES.consumption },
610
+ ].map(row => (
611
+ <Box component="div" key={row.label} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
612
+ <Box
613
+ component="span"
614
+ aria-hidden
615
+ sx={{ inlineSize: 8, blockSize: 8, borderRadius: '50%', bgcolor: row.color }}
616
+ />
617
+ <Typography
618
+ component="span"
619
+ sx={{ flex: 1, fontFamily: CHART.font, fontSize: 12, color: CHART.inkSoft }}
620
+ >
621
+ {row.label}
622
+ </Typography>
623
+ <Typography
624
+ component="span"
625
+ sx={{
626
+ fontFamily: CHART.font,
627
+ fontSize: 12,
628
+ color: CHART.ink,
629
+ fontVariantNumeric: 'tabular-nums',
630
+ }}
631
+ >
632
+ {kwh(row.value)}
633
+ </Typography>
634
+ </Box>
635
+ ))}
636
+ </Box>
637
+ )}
638
+ </Box>
639
+ )}
640
+ </Box>
641
+ </Box>
642
+ )
643
+ }
644
+ ```
645
+
646
+ ## A warning and a notice
647
+
648
+ The plan has two rules. More panels than the roof holds is a `warning`: the dashboard keeps showing the figures, so the household sees what the larger system would do, but a survey cannot be booked until the panels fit. A north-facing roof is a `notice`: it informs and never blocks.
649
+
650
+ ```yaml
651
+ invariants:
652
+ SolarPlan:
653
+ panelsFitRoof:
654
+ types:
655
+ - panelsExceedRoof
656
+ northFacing:
657
+ type: northFacing
658
+ severity: notice
659
+ message: A north-facing roof yields about 40 % less. An east-west layout on another roof usually pays back sooner.
660
+ ```
661
+
662
+ ```ts
663
+ import { phx } from '../../.phoenix/phx.js'
664
+
665
+ // More panels than the roof holds is a warning: the dashboard keeps showing the figures, and a survey cannot be
666
+ // booked until the panel count fits. A north-facing roof is only a notice, so it never blocks the booking.
667
+ export default phx.domain.implementCustomInvariants({
668
+ SolarPlan: {
669
+ panelsFitRoof: s => {
670
+ if (s.panels <= s.$roofPanelLimit) {
671
+ return []
672
+ }
673
+
674
+ return [
675
+ {
676
+ type: 'panelsExceedRoof',
677
+ severity: 'warning',
678
+ message: `${s.panels} panels need about ${s.$panelAreaM2} m² of roof; ${s.roofArea} m² holds at most ${s.$roofPanelLimit}. Lower the panel count or measure the roof again.`,
679
+ },
680
+ ]
681
+ },
682
+ northFacing: s => {
683
+ return s.orientation !== 'North'
684
+ },
685
+ },
686
+ })
687
+ ```
688
+
689
+ `PlannerInputs` shows both under the roof's inputs, from `phx.frontend.useFinalizeViolations()`, which lists the violations of the committed plan with their severity: the warning in amber, the notice in blue. `phx.frontend.useFinalizeActions()` reports the survey action `disabled` only for the warning.
690
+
691
+ ```tsx
692
+ import { Box, Typography } from '@mui/material'
693
+ import type { ReactNode } from 'react'
694
+
695
+ import { phx } from '../../.phoenix/phx.js'
696
+ import { PillField } from './PillField.js'
697
+ import { SliderField } from './SliderField.js'
698
+ import { SUNFOLD } from './tokens.js'
699
+
700
+ interface GroupProps {
701
+ readonly title: string
702
+ // The invariant types whose message belongs under this group
703
+ readonly messageTypes?: readonly string[]
704
+ readonly children: ReactNode
705
+ }
706
+
707
+ // A group of inputs with the message of an invariant the group can resolve: a warning, which blocks the booking, in
708
+ // amber, a notice, which only informs, in blue. `useFinalizeViolations` lists both.
709
+ const Group = ({ title, messageTypes = [], children }: GroupProps) => {
710
+ const { violations } = phx.frontend.useFinalizeViolations()
711
+ const resolve = phx.frontend.useResolveLocalizable()
712
+ const messages = (violations ?? []).filter(({ violation }) => messageTypes.includes(violation.type))
713
+
714
+ return (
715
+ <Box component="section" sx={{ display: 'grid', gap: 2 }}>
716
+ <Typography component="h3" sx={{ fontFamily: SUNFOLD.font, fontSize: 13, fontWeight: 600, color: SUNFOLD.muted }}>
717
+ {title}
718
+ </Typography>
719
+ {children}
720
+ {messages.map(({ violation }) => {
721
+ const isNotice = violation.severity === 'notice'
722
+
723
+ return (
724
+ <Typography
725
+ key={violation.type}
726
+ role={isNotice ? 'status' : 'alert'}
727
+ sx={{
728
+ paddingInline: 1.5,
729
+ paddingBlock: 1,
730
+ borderRadius: 2,
731
+ bgcolor: isNotice ? SUNFOLD.infoTint : SUNFOLD.warningTint,
732
+ color: isNotice ? SUNFOLD.info : SUNFOLD.warning,
733
+ fontFamily: SUNFOLD.font,
734
+ fontSize: 13,
735
+ lineHeight: 1.45,
736
+ }}
737
+ >
738
+ {resolve(violation.message)}
739
+ </Typography>
740
+ )
741
+ })}
742
+ </Box>
743
+ )
744
+ }
745
+
746
+ interface PlannerInputsProps {
747
+ readonly readOnly: boolean
748
+ }
749
+
750
+ // The household's inputs: the roof, the panels on it, the home's use and tariff, and the battery
751
+ export const PlannerInputs = ({ readOnly }: PlannerInputsProps) => {
752
+ const plan = phx.frontend.useRootEntityState()
753
+
754
+ return (
755
+ <Box component="div" sx={{ display: 'grid', gap: 3.5 }}>
756
+ <Group title="Your roof" messageTypes={['panelsExceedRoof', 'northFacing']}>
757
+ <SliderField instanceName="roofArea" label="Usable roof area" step={1} readOnly={readOnly} />
758
+ <PillField instanceName="orientation" label="Facing" readOnly={readOnly} />
759
+ <SliderField instanceName="panels" label="Panels" step={1} readOnly={readOnly} />
760
+ <Typography sx={{ marginBlockStart: -1.5, fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.muted }}>
761
+ 430 W panels, 1.95 m² each. This roof holds up to {plan.$roofPanelLimit}.
762
+ </Typography>
763
+ </Group>
764
+ <Group title="Your home">
765
+ <SliderField instanceName="annualUse" label="Electricity used a year" step={100} readOnly={readOnly} />
766
+ <PillField instanceName="tariff" label="Tariff" readOnly={readOnly} />
767
+ </Group>
768
+ <Group title="Storage">
769
+ <PillField instanceName="battery" label="Home battery" readOnly={readOnly} />
770
+ </Group>
771
+ </Box>
772
+ )
773
+ }
774
+ ```
775
+
776
+ ## Book a home survey
777
+
778
+ The spec declares one finalize action. Its handler, `src/handlers/book-survey.ts`, stands in for the installer's booking service: it waits, then enters the `booked` navigation state. In that state the panel confirms the request and the inputs are locked until **Back to the plan**. A viewer of a shared configuration sees the cost and no button.
779
+
780
+ ```yaml
781
+ components:
782
+ SolarPlan:
783
+ finalizeAction:
784
+ survey:
785
+ variant: primary
786
+ label: Book a home survey
787
+ title: Ask an installer to survey the roof and confirm the plan
788
+ icon: CalendarCheck
789
+ handler: bookSurvey
790
+ ```
791
+
792
+ ```tsx
793
+ import { Alert, Box, Button, CircularProgress, Typography } from '@mui/material'
794
+
795
+ import { phx } from '../../.phoenix/phx.js'
796
+ import { SUNFOLD } from './tokens.js'
797
+
798
+ interface SurveyPanelProps {
799
+ // On a phone the panel is a bar: the cost beside the button, without the hints
800
+ readonly compact: boolean
801
+ }
802
+
803
+ // The system's cost, the `price` component's value, and the spec's `survey` finalize action. A warning blocks the
804
+ // booking and its `hints` say why; a notice does not. In the `booked` navigation state the panel confirms the
805
+ // request instead.
806
+ export const SurveyPanel = ({ compact }: SurveyPanelProps) => {
807
+ const cost = phx.frontend.useRootEntityPrice()
808
+ const { primary, hints } = phx.frontend.useFinalizeActions()
809
+ const navigationState = phx.frontend.useActiveNavigationState()
810
+ const navigate = phx.frontend.useNavigate()
811
+ const { accessInfo } = phx.frontend.useAccessService()
812
+ const isPending = primary?.isPending ?? false
813
+
814
+ if (navigationState === 'booked') {
815
+ return (
816
+ <Box component="div" role="status" sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
817
+ <Box component="div" sx={{ display: 'grid', flex: 1 }}>
818
+ <Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 16, fontWeight: 600, color: SUNFOLD.green }}>
819
+ Survey requested
820
+ </Typography>
821
+ <Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 13, color: SUNFOLD.muted }}>
822
+ An installer calls within two working days.
823
+ </Typography>
824
+ </Box>
825
+ <Button
826
+ onClick={() => navigate('plan')}
827
+ sx={{
828
+ fontFamily: SUNFOLD.font,
829
+ fontSize: 13,
830
+ color: SUNFOLD.ink,
831
+ textTransform: 'none',
832
+ textDecoration: 'underline',
833
+ }}
834
+ >
835
+ Back to the plan
836
+ </Button>
837
+ </Box>
838
+ )
839
+ }
840
+
841
+ const showButton = primary !== null && !accessInfo.viewMode
842
+
843
+ return (
844
+ <Box component="div" sx={{ display: 'grid', gap: 1.25 }}>
845
+ <Box
846
+ component="div"
847
+ sx={{
848
+ display: 'flex',
849
+ flexDirection: compact ? 'row' : 'column',
850
+ alignItems: compact ? 'center' : 'stretch',
851
+ justifyContent: 'space-between',
852
+ gap: compact ? 2 : 1.5,
853
+ }}
854
+ >
855
+ <Box component="div" sx={{ display: 'grid' }}>
856
+ <Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.muted }}>Installed cost</Typography>
857
+ <Typography
858
+ sx={{ fontFamily: SUNFOLD.font, fontSize: compact ? 20 : 26, fontWeight: 600, color: SUNFOLD.ink }}
859
+ >
860
+ {cost}
861
+ </Typography>
862
+ </Box>
863
+ {showButton && (
864
+ <Button
865
+ variant="contained"
866
+ disableElevation
867
+ disabled={primary.disabled || isPending}
868
+ onClick={() => void primary.run()}
869
+ title={primary.title}
870
+ startIcon={isPending ? <CircularProgress size={16} color="inherit" /> : undefined}
871
+ sx={{
872
+ minBlockSize: 46,
873
+ paddingInline: 2.5,
874
+ borderRadius: 999,
875
+ bgcolor: SUNFOLD.green,
876
+ fontFamily: SUNFOLD.font,
877
+ fontSize: 14,
878
+ fontWeight: 500,
879
+ textTransform: 'none',
880
+ '&:hover': { bgcolor: SUNFOLD.greenDark },
881
+ '&.Mui-disabled': { bgcolor: SUNFOLD.line, color: SUNFOLD.muted },
882
+ }}
883
+ >
884
+ {isPending ? 'Booking…' : primary.label}
885
+ </Button>
886
+ )}
887
+ </Box>
888
+ {!compact && showButton && primary.disabled && hints && (
889
+ <Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.warning }}>{hints}</Typography>
890
+ )}
891
+ {primary?.error && <Alert severity="error">The survey could not be booked: {primary.error.message}</Alert>}
892
+ </Box>
893
+ )
894
+ }
895
+ ```
896
+
897
+ ## On a phone
898
+
899
+ The same layout in a frame 390 pixels wide: the four figures in two rows, the inputs in a card below them, the charts stacked under the inputs, and the cost with **Book a home survey** in a bar at the bottom. Opened on its own from the sidebar, the **Phone** story sets Storybook's viewport to 390 × 844.
900
+
901
+ ## Test cases
902
+
903
+ - The plan opens with 14 panels on a 42 m² south-facing roof, no battery, a flat rate and 4,500 kWh a year. The tiles read **6.0 kWp**, **6,020 kWh**, **30 %** and **8.2 years**, the installed cost is **€7,821.00**, and the split bar shows 30 % used at home.
904
+ - In **Your solar year**, the summer columns are mostly sent to the grid and the winter ones mostly used at home, and the home's use is a line that is higher in winter. Hovering June shows 142 kWh used at home, 641 kWh sent to the grid and 315 kWh of home use.
905
+ - Moving the pointer across **When it pays back** reads off the year; the curve crosses zero at the marked payback year.
906
+ - A 10 kWh battery, the time-of-use tariff and 18 panels change every tile and chart: 7.7 kWp, 7,740 kWh, 44 % used at home and 9.8 years, at €16,027.00. The columns' home part grows.
907
+ - 22 panels on the 42 m² roof show "22 panels need about 43 m² of roof; 42 m² holds at most 18. …" and disable **Book a home survey**; a roof of 52 m² clears it.
908
+ - **North** shows the notice "A north-facing roof yields about 40 % less. …" in blue, lowers the production and lengthens the payback, and leaves **Book a home survey** enabled.
909
+ - **Show figures** shows a table of the twelve months; **Show chart** brings the chart back.
910
+ - **Book a home survey** shows a spinner and "Booking…", then "Survey requested"; the inputs are locked until **Back to the plan**.
911
+ - In the **Phone** story the tiles sit in two rows, the inputs and charts scroll below, the same changes update every figure, and the bottom bar confirms the booking.
912
+ - The browser console shows no errors, the page has no `canvas` element, and no model or texture is requested.
913
+
914
+ ## What this layout leaves out
915
+
916
+ - **Read mode.** The inputs are inert and **Book a home survey** is hidden while `phx.frontend.useAccessService().accessInfo.viewMode` is set, but the example ships no read template; [Custom Layout → Serve read mode](?path=/docs/layouts-custom-layout--docs#serve-read-mode) shows one.
917
+ - **Real yields and prices.** The model is an illustration. A deployed planner would read the site's irradiance and the household's tariff from data the project publishes, see [Data Sources](?path=/docs/phoenix-data-sources--docs).
918
+ - **A dark theme.** The page is the installer's own light design; the configurator's theme does not switch it.
919
+ - **The built-in arrangement.** Header, sidebar, footer, tabs and the preview start screen are not mounted; [Layout Slots](?path=/docs/phoenix-layout-slots--docs) lists every slot a project layout can mount.
920
+
921
+ ## Related pages
922
+
923
+ - [Ardent Compressor](?path=/docs/layouts-gallery-ardent-compressor--docs) is a quote form with steps, and [Fenwick Window](?path=/docs/layouts-gallery-fenwick-window--docs) a live shop drawing; neither mounts a canvas either.
924
+ - [Custom Layout](?path=/docs/layouts-custom-layout--docs) explains a project layout step by step.
925
+ - [Configurator Interface → Hooks for a Project Layout](?path=/docs/phoenix-configurator-interface--docs#hooks-for-a-project-layout) lists the hooks used here.
926
+ - [Domain Model → Expressions](?path=/docs/phoenix-domain-model--docs#expressions) and [Domain Model → Invariants](?path=/docs/phoenix-domain-model--docs#invariants) describe declared expressions and severities.
927
+
928
+ ## Complete Implementation
929
+
930
+ {/* AUTO-GENERATED SECTION: This section is automatically updated by 'phoenix story build' */}
931
+
932
+ <details>
933
+ <summary>Click to view the complete implementation files</summary>
934
+
935
+ ### ./spec/domain.yaml
936
+
937
+ ```yaml
938
+ model:
939
+ latest:
940
+ entityTypes:
941
+ - SolarPlan
942
+ aliases:
943
+ RootEntity:
944
+ - SolarPlan
945
+ commonTypes:
946
+ Orientation:
947
+ enum:
948
+ - South
949
+ - SouthEastWest
950
+ - EastWest
951
+ - North
952
+ Battery:
953
+ enum:
954
+ - None
955
+ - Kwh5
956
+ - Kwh10
957
+ - Kwh15
958
+ Tariff:
959
+ enum:
960
+ - Flat
961
+ - TimeOfUse
962
+ - ExportPlus
963
+ shapes:
964
+ SolarPlan:
965
+ roofArea:
966
+ type: number
967
+ orientation:
968
+ $ref: '#/model/latest/commonTypes/Orientation'
969
+ panels:
970
+ type: number
971
+ battery:
972
+ $ref: '#/model/latest/commonTypes/Battery'
973
+ tariff:
974
+ $ref: '#/model/latest/commonTypes/Tariff'
975
+ annualUse:
976
+ type: number
977
+
978
+ presets:
979
+ - rootEntityType: SolarPlan
980
+ initialDomainEntities:
981
+ - id: SolarPlan-1
982
+ type: SolarPlan
983
+ roofArea: 42
984
+ orientation: South
985
+ panels: 14
986
+ battery: None
987
+ tariff: Flat
988
+ annualUse: 4500
989
+
990
+ navigationStates:
991
+ - plan
992
+ - booked
993
+
994
+ # Every figure on the dashboard: declared here, computed in src/expressions/solar.ts from the roof, the panels, the
995
+ # battery, the tariff and the household's use
996
+ expressions:
997
+ SolarPlan:
998
+ $systemKwp:
999
+ type: number
1000
+ $roofPanelLimit:
1001
+ type: number
1002
+ $panelAreaM2:
1003
+ type: number
1004
+ $annualProductionKwh:
1005
+ type: number
1006
+ $monthly:
1007
+ type: array
1008
+ items:
1009
+ type: object
1010
+ properties:
1011
+ month:
1012
+ type: string
1013
+ productionKwh:
1014
+ type: number
1015
+ consumptionKwh:
1016
+ type: number
1017
+ selfUseKwh:
1018
+ type: number
1019
+ exportKwh:
1020
+ type: number
1021
+ required:
1022
+ - month
1023
+ - productionKwh
1024
+ - consumptionKwh
1025
+ - selfUseKwh
1026
+ - exportKwh
1027
+ additionalProperties: false
1028
+ $selfConsumption:
1029
+ type: number
1030
+ $selfSufficiency:
1031
+ type: number
1032
+ $annualSavings:
1033
+ type: number
1034
+ $systemCost:
1035
+ type: number
1036
+ $cumulativeSavings:
1037
+ type: array
1038
+ items:
1039
+ type: number
1040
+ $paybackYears:
1041
+ type: number
1042
+ $co2Tonnes:
1043
+ type: number
1044
+
1045
+ invariants:
1046
+ SolarPlan:
1047
+ panelsFitRoof:
1048
+ types:
1049
+ - panelsExceedRoof
1050
+ northFacing:
1051
+ type: northFacing
1052
+ severity: notice
1053
+ message: A north-facing roof yields about 40 % less. An east-west layout on another roof usually pays back sooner.
1054
+
1055
+ components:
1056
+ SolarPlan:
1057
+ price:
1058
+ main:
1059
+ price:
1060
+ $read: $systemCost
1061
+ currency: EUR
1062
+ finalizeAction:
1063
+ survey:
1064
+ variant: primary
1065
+ label: Book a home survey
1066
+ title: Ask an installer to survey the roof and confirm the plan
1067
+ icon: CalendarCheck
1068
+ handler: bookSurvey
1069
+ genericDimension:
1070
+ roofArea:
1071
+ value:
1072
+ $bind: roofArea
1073
+ min: 10
1074
+ max: 120
1075
+ unit: m²
1076
+ decimalPlaces: 0
1077
+ panels:
1078
+ value:
1079
+ $bind: panels
1080
+ min: 4
1081
+ max: 40
1082
+ unit: panels
1083
+ decimalPlaces: 0
1084
+ annualUse:
1085
+ value:
1086
+ $bind: annualUse
1087
+ min: 1500
1088
+ max: 12000
1089
+ unit: kWh
1090
+ decimalPlaces: 0
1091
+ attributeSelection:
1092
+ orientation:
1093
+ value:
1094
+ $bind: orientation
1095
+ options:
1096
+ - label: South
1097
+ value: South
1098
+ - label: South-east or south-west
1099
+ value: SouthEastWest
1100
+ - label: East and west
1101
+ value: EastWest
1102
+ - label: North
1103
+ value: North
1104
+ battery:
1105
+ value:
1106
+ $bind: battery
1107
+ options:
1108
+ - label: None
1109
+ value: None
1110
+ - label: 5 kWh
1111
+ value: Kwh5
1112
+ - label: 10 kWh
1113
+ value: Kwh10
1114
+ - label: 15 kWh
1115
+ value: Kwh15
1116
+ tariff:
1117
+ value:
1118
+ $bind: tariff
1119
+ options:
1120
+ - label: Flat rate
1121
+ description: €0.32 per kWh bought, €0.08 per kWh sold
1122
+ value: Flat
1123
+ - label: Time of use
1124
+ description: Cheap nights, dear evenings; a battery shifts the evening
1125
+ value: TimeOfUse
1126
+ - label: Export plus
1127
+ description: €0.15 per kWh sold to the grid
1128
+ value: ExportPlus
1129
+ ```
1130
+
1131
+ ### ./src/\_charts/EnergySplitBar.tsx
1132
+
1133
+ ```tsx
1134
+ import { Box, Typography } from '@mui/material'
1135
+
1136
+ import { phx } from '../../.phoenix/phx.js'
1137
+ import { CHART, SERIES } from './chart-style.js'
1138
+
1139
+ // Where a year of solar power goes, as one bar split between the home and the grid, from the spec's `$monthly` and
1140
+ // `$selfConsumption` expressions. A battery moves the split towards the home.
1141
+ export const EnergySplitBar = () => {
1142
+ const plan = phx.frontend.useRootEntityState()
1143
+ const selfUse = plan.$monthly.reduce((sum, month) => sum + month.selfUseKwh, 0)
1144
+ const exported = plan.$monthly.reduce((sum, month) => sum + month.exportKwh, 0)
1145
+ const share = plan.$selfConsumption
1146
+ const parts = [
1147
+ { key: 'home', label: 'Used at home', kwh: selfUse, share, color: SERIES.selfUse },
1148
+ { key: 'grid', label: 'Sent to the grid', kwh: exported, share: 1 - share, color: SERIES.exported },
1149
+ ]
1150
+
1151
+ return (
1152
+ <Box component="div" sx={{ display: 'grid', gap: 1.5 }}>
1153
+ <Box
1154
+ component="div"
1155
+ role="img"
1156
+ aria-label={`${Math.round(share * 100)} % of the solar power is used at home`}
1157
+ sx={{ display: 'flex', gap: '2px', blockSize: 20 }}
1158
+ >
1159
+ {parts.map((part, index) => (
1160
+ <Box
1161
+ component="span"
1162
+ key={part.key}
1163
+ sx={{
1164
+ flexBasis: `${part.share * 100}%`,
1165
+ minInlineSize: part.share > 0 ? 4 : 0,
1166
+ bgcolor: part.color,
1167
+ borderStartStartRadius: index === 0 ? 4 : 0,
1168
+ borderEndStartRadius: index === 0 ? 4 : 0,
1169
+ borderStartEndRadius: index === parts.length - 1 ? 4 : 0,
1170
+ borderEndEndRadius: index === parts.length - 1 ? 4 : 0,
1171
+ transition: 'flex-basis 240ms ease-out',
1172
+ }}
1173
+ />
1174
+ ))}
1175
+ </Box>
1176
+ <Box component="div" sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
1177
+ {parts.map(part => (
1178
+ <Box component="div" key={part.key} sx={{ display: 'grid', gap: 0.25 }}>
1179
+ <Box component="div" sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
1180
+ <Box
1181
+ component="span"
1182
+ aria-hidden
1183
+ sx={{ inlineSize: 10, blockSize: 10, borderRadius: 0.5, bgcolor: part.color }}
1184
+ />
1185
+ <Typography sx={{ fontFamily: CHART.font, fontSize: 12, color: CHART.inkSoft }}>{part.label}</Typography>
1186
+ </Box>
1187
+ <Typography sx={{ fontFamily: CHART.font, fontSize: 20, fontWeight: 600, color: CHART.ink }}>
1188
+ {Math.round(part.share * 100)} %
1189
+ </Typography>
1190
+ <Typography sx={{ fontFamily: CHART.font, fontSize: 12, color: CHART.inkSoft }}>
1191
+ {Math.round(part.kwh).toLocaleString('en')} kWh a year
1192
+ </Typography>
1193
+ </Box>
1194
+ ))}
1195
+ </Box>
1196
+ <Typography sx={{ fontFamily: CHART.font, fontSize: 13, color: CHART.ink }}>
1197
+ Solar covers {Math.round(plan.$selfSufficiency * 100)} % of the home's {plan.annualUse.toLocaleString('en')}{' '}
1198
+ kWh.
1199
+ </Typography>
1200
+ </Box>
1201
+ )
1202
+ }
1203
+ ```
1204
+
1205
+ ### ./src/\_charts/MonthlyEnergyChart.tsx
1206
+
1207
+ ```tsx
1208
+ import { Box, ButtonBase, Typography } from '@mui/material'
1209
+ import { useState } from 'react'
1210
+
1211
+ import { phx } from '../../.phoenix/phx.js'
1212
+ import { CHART, SERIES } from './chart-style.js'
1213
+ import { niceScale } from './nice-scale.js'
1214
+ import { useWidth } from './use-width.js'
1215
+
1216
+ const MARGIN = { top: 12, right: 8, bottom: 26, left: 48 }
1217
+ const GAP = 2
1218
+ const RADIUS = 4
1219
+
1220
+ const kwh = (value: number) => {
1221
+ return `${Math.round(value).toLocaleString('en')} kWh`
1222
+ }
1223
+
1224
+ // A column segment whose top corners are rounded, so the stack ends in a 4px data-end and stays square at the
1225
+ // baseline
1226
+ const roundedTop = (x: number, y: number, width: number, height: number) => {
1227
+ const r = Math.min(RADIUS, height, width / 2)
1228
+
1229
+ return `M${x},${y + height} V${y + r} Q${x},${y} ${x + r},${y} H${x + width - r} Q${x + width},${y} ${x + width},${y + r} V${y + height} Z`
1230
+ }
1231
+
1232
+ const LEGEND = [
1233
+ { key: 'selfUse', label: 'Solar used at home', color: SERIES.selfUse, line: false },
1234
+ { key: 'exported', label: 'Solar sent to the grid', color: SERIES.exported, line: false },
1235
+ { key: 'consumption', label: 'Home use', color: SERIES.consumption, line: true },
1236
+ ]
1237
+
1238
+ interface MonthlyEnergyChartProps {
1239
+ readonly height: number
1240
+ }
1241
+
1242
+ // Each month's solar production as a column split into what the home uses and what goes to the grid, with the
1243
+ // home's use as a line across it. All figures come from the spec's `$monthly` expression. Hovering a month shows its
1244
+ // figures; "Show figures" swaps the chart for a table of the same numbers.
1245
+ export const MonthlyEnergyChart = ({ height }: MonthlyEnergyChartProps) => {
1246
+ const plan = phx.frontend.useRootEntityState()
1247
+ const { ref, width } = useWidth()
1248
+ const [hovered, setHovered] = useState<number | null>(null)
1249
+ const [asTable, setAsTable] = useState(false)
1250
+ const months = plan.$monthly
1251
+ const peak = Math.max(...months.map(month => Math.max(month.productionKwh, month.consumptionKwh)))
1252
+ const { high, ticks } = niceScale(0, peak, 4)
1253
+ const plotWidth = Math.max(width - MARGIN.left - MARGIN.right, 0)
1254
+ const plotHeight = height - MARGIN.top - MARGIN.bottom
1255
+ const band = plotWidth / months.length
1256
+ const barWidth = Math.min(24, band * 0.62)
1257
+ const y = (value: number) => MARGIN.top + plotHeight - (value / high) * plotHeight
1258
+ const x = (index: number) => MARGIN.left + band * index + band / 2
1259
+ const active = hovered === null ? undefined : months[hovered]
1260
+
1261
+ return (
1262
+ <Box component="div" sx={{ display: 'grid', gap: 1.5 }}>
1263
+ <Box component="div" sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', columnGap: 2, rowGap: 0.5 }}>
1264
+ {LEGEND.map(item => (
1265
+ <Box component="span" key={item.key} sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75 }}>
1266
+ <Box
1267
+ component="span"
1268
+ aria-hidden
1269
+ sx={
1270
+ item.line
1271
+ ? { inlineSize: 14, blockSize: 2, borderRadius: 1, bgcolor: item.color }
1272
+ : { inlineSize: 10, blockSize: 10, borderRadius: 0.5, bgcolor: item.color }
1273
+ }
1274
+ />
1275
+ <Typography component="span" sx={{ fontFamily: CHART.font, fontSize: 12, color: CHART.inkSoft }}>
1276
+ {item.label}
1277
+ </Typography>
1278
+ </Box>
1279
+ ))}
1280
+ <ButtonBase
1281
+ onClick={() => setAsTable(shown => !shown)}
1282
+ sx={{
1283
+ marginInlineStart: 'auto',
1284
+ fontFamily: CHART.font,
1285
+ fontSize: 12,
1286
+ color: CHART.ink,
1287
+ textDecoration: 'underline',
1288
+ }}
1289
+ >
1290
+ {asTable ? 'Show chart' : 'Show figures'}
1291
+ </ButtonBase>
1292
+ </Box>
1293
+
1294
+ {/* Measured whether the chart or the table shows, so the chart comes back at the current width */}
1295
+ <Box component="div" ref={ref}>
1296
+ {asTable ? (
1297
+ <Box
1298
+ component="table"
1299
+ sx={{
1300
+ inlineSize: '100%',
1301
+ borderCollapse: 'collapse',
1302
+ fontFamily: CHART.font,
1303
+ fontSize: 12,
1304
+ color: CHART.ink,
1305
+ }}
1306
+ >
1307
+ <thead>
1308
+ <tr>
1309
+ {['Month', 'Produced', 'Used at home', 'Sent to grid', 'Home use'].map(heading => (
1310
+ <Box
1311
+ component="th"
1312
+ key={heading}
1313
+ sx={{
1314
+ paddingBlock: 0.5,
1315
+ textAlign: 'end',
1316
+ fontWeight: 500,
1317
+ color: CHART.inkSoft,
1318
+ '&:first-of-type': { textAlign: 'start' },
1319
+ }}
1320
+ >
1321
+ {heading}
1322
+ </Box>
1323
+ ))}
1324
+ </tr>
1325
+ </thead>
1326
+ <tbody>
1327
+ {months.map(month => (
1328
+ <Box component="tr" key={month.month} sx={{ borderBlockStart: `1px solid ${CHART.grid}` }}>
1329
+ <Box component="td" sx={{ paddingBlock: 0.5 }}>
1330
+ {month.month}
1331
+ </Box>
1332
+ {[month.productionKwh, month.selfUseKwh, month.exportKwh, month.consumptionKwh].map(
1333
+ (value, index) => (
1334
+ <Box component="td" key={index} sx={{ textAlign: 'end', fontVariantNumeric: 'tabular-nums' }}>
1335
+ {Math.round(value).toLocaleString('en')}
1336
+ </Box>
1337
+ )
1338
+ )}
1339
+ </Box>
1340
+ ))}
1341
+ </tbody>
1342
+ </Box>
1343
+ ) : (
1344
+ <Box component="div" sx={{ position: 'relative', blockSize: height }}>
1345
+ {width > 0 && (
1346
+ <svg
1347
+ width={width}
1348
+ height={height}
1349
+ role="img"
1350
+ aria-label="Solar production and home use by month"
1351
+ onMouseLeave={() => setHovered(null)}
1352
+ >
1353
+ {ticks.map(tick => (
1354
+ <g key={tick}>
1355
+ <line
1356
+ x1={MARGIN.left}
1357
+ x2={width - MARGIN.right}
1358
+ y1={y(tick)}
1359
+ y2={y(tick)}
1360
+ stroke={tick === 0 ? CHART.axis : CHART.grid}
1361
+ strokeWidth={1}
1362
+ />
1363
+ <text
1364
+ x={MARGIN.left - 8}
1365
+ y={y(tick)}
1366
+ textAnchor="end"
1367
+ dominantBaseline="middle"
1368
+ fill={CHART.inkSoft}
1369
+ fontFamily={CHART.font}
1370
+ fontSize={11}
1371
+ >
1372
+ {tick.toLocaleString('en')}
1373
+ </text>
1374
+ </g>
1375
+ ))}
1376
+
1377
+ {hovered !== null && (
1378
+ <rect
1379
+ x={MARGIN.left + band * hovered}
1380
+ y={MARGIN.top}
1381
+ width={band}
1382
+ height={plotHeight}
1383
+ fill={CHART.grid}
1384
+ opacity={0.6}
1385
+ />
1386
+ )}
1387
+
1388
+ {months.map((month, index) => {
1389
+ const left = x(index) - barWidth / 2
1390
+ const selfTop = y(month.selfUseKwh)
1391
+ const exportTop = y(month.productionKwh)
1392
+ const exportHeight = Math.max(selfTop - exportTop - GAP, 0)
1393
+
1394
+ return (
1395
+ <g key={month.month}>
1396
+ {exportHeight > 0.5 ? (
1397
+ <>
1398
+ <rect x={left} y={selfTop} width={barWidth} height={y(0) - selfTop} fill={SERIES.selfUse} />
1399
+ <path d={roundedTop(left, exportTop, barWidth, exportHeight)} fill={SERIES.exported} />
1400
+ </>
1401
+ ) : (
1402
+ <path d={roundedTop(left, selfTop, barWidth, y(0) - selfTop)} fill={SERIES.selfUse} />
1403
+ )}
1404
+ <text
1405
+ x={x(index)}
1406
+ y={height - 8}
1407
+ textAnchor="middle"
1408
+ fill={CHART.inkSoft}
1409
+ fontFamily={CHART.font}
1410
+ fontSize={11}
1411
+ >
1412
+ {/* A narrow chart keeps only the initial */}
1413
+ {band < 34 ? month.month.slice(0, 1) : month.month}
1414
+ </text>
1415
+ </g>
1416
+ )
1417
+ })}
1418
+
1419
+ <polyline
1420
+ points={months.map((month, index) => `${x(index)},${y(month.consumptionKwh)}`).join(' ')}
1421
+ fill="none"
1422
+ stroke={SERIES.consumption}
1423
+ strokeWidth={2}
1424
+ strokeLinejoin="round"
1425
+ strokeLinecap="round"
1426
+ />
1427
+ {months.map((month, index) => (
1428
+ <circle
1429
+ key={month.month}
1430
+ cx={x(index)}
1431
+ cy={y(month.consumptionKwh)}
1432
+ r={4}
1433
+ fill={SERIES.consumption}
1434
+ stroke={CHART.surface}
1435
+ strokeWidth={2}
1436
+ />
1437
+ ))}
1438
+
1439
+ {/* Hit areas a full band wide and the plot's height, larger than the marks */}
1440
+ {months.map((month, index) => (
1441
+ <rect
1442
+ key={month.month}
1443
+ x={MARGIN.left + band * index}
1444
+ y={MARGIN.top}
1445
+ width={band}
1446
+ height={plotHeight}
1447
+ fill="transparent"
1448
+ onMouseEnter={() => setHovered(index)}
1449
+ />
1450
+ ))}
1451
+ </svg>
1452
+ )}
1453
+
1454
+ {active !== undefined && hovered !== null && (
1455
+ <Box
1456
+ component="div"
1457
+ role="tooltip"
1458
+ sx={{
1459
+ position: 'absolute',
1460
+ insetBlockStart: 0,
1461
+ insetInlineStart: Math.min(Math.max(x(hovered) - 90, 0), Math.max(width - 180, 0)),
1462
+ inlineSize: 180,
1463
+ padding: 1.25,
1464
+ borderRadius: 1.5,
1465
+ bgcolor: CHART.surface,
1466
+ boxShadow: '0 6px 24px rgba(28, 40, 38, 0.16)',
1467
+ pointerEvents: 'none',
1468
+ fontFamily: CHART.font,
1469
+ }}
1470
+ >
1471
+ <Typography sx={{ fontFamily: CHART.font, fontSize: 12, fontWeight: 600, color: CHART.ink }}>
1472
+ {active.month}
1473
+ </Typography>
1474
+ {[
1475
+ { label: 'Used at home', value: active.selfUseKwh, color: SERIES.selfUse },
1476
+ { label: 'Sent to grid', value: active.exportKwh, color: SERIES.exported },
1477
+ { label: 'Home use', value: active.consumptionKwh, color: SERIES.consumption },
1478
+ ].map(row => (
1479
+ <Box component="div" key={row.label} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
1480
+ <Box
1481
+ component="span"
1482
+ aria-hidden
1483
+ sx={{ inlineSize: 8, blockSize: 8, borderRadius: '50%', bgcolor: row.color }}
1484
+ />
1485
+ <Typography
1486
+ component="span"
1487
+ sx={{ flex: 1, fontFamily: CHART.font, fontSize: 12, color: CHART.inkSoft }}
1488
+ >
1489
+ {row.label}
1490
+ </Typography>
1491
+ <Typography
1492
+ component="span"
1493
+ sx={{
1494
+ fontFamily: CHART.font,
1495
+ fontSize: 12,
1496
+ color: CHART.ink,
1497
+ fontVariantNumeric: 'tabular-nums',
1498
+ }}
1499
+ >
1500
+ {kwh(row.value)}
1501
+ </Typography>
1502
+ </Box>
1503
+ ))}
1504
+ </Box>
1505
+ )}
1506
+ </Box>
1507
+ )}
1508
+ </Box>
1509
+ </Box>
1510
+ )
1511
+ }
1512
+ ```
1513
+
1514
+ ### ./src/\_charts/PaybackChart.tsx
1515
+
1516
+ ```tsx
1517
+ import { Box, Typography } from '@mui/material'
1518
+ import type { MouseEvent } from 'react'
1519
+ import { useState } from 'react'
1520
+
1521
+ import { phx } from '../../.phoenix/phx.js'
1522
+ import { CHART } from './chart-style.js'
1523
+ import { niceScale } from './nice-scale.js'
1524
+ import { useWidth } from './use-width.js'
1525
+
1526
+ const MARGIN = { top: 16, right: 16, bottom: 26, left: 56 }
1527
+ const LINE = '#1f6f54'
1528
+ const YEAR_TICKS = [0, 5, 10, 15, 20, 25]
1529
+
1530
+ const euro = (value: number) => {
1531
+ return value.toLocaleString('en-IE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })
1532
+ }
1533
+
1534
+ interface PaybackChartProps {
1535
+ readonly height: number
1536
+ }
1537
+
1538
+ // The money the system has returned, year by year over the panels' 25 years, from the spec's `$cumulativeSavings`
1539
+ // expression: it starts at minus the system's cost and crosses zero in the payback year, which is marked. Moving the
1540
+ // pointer across the chart reads off any year.
1541
+ export const PaybackChart = ({ height }: PaybackChartProps) => {
1542
+ const plan = phx.frontend.useRootEntityState()
1543
+ const { ref, width } = useWidth()
1544
+ const [year, setYear] = useState<number | null>(null)
1545
+ const totals = plan.$cumulativeSavings
1546
+ const lastYear = totals.length - 1
1547
+ const { low, high, ticks } = niceScale(Math.min(...totals), Math.max(...totals, 0), 4)
1548
+ const plotWidth = Math.max(width - MARGIN.left - MARGIN.right, 0)
1549
+ const plotHeight = height - MARGIN.top - MARGIN.bottom
1550
+ const x = (value: number) => MARGIN.left + (value / lastYear) * plotWidth
1551
+ const y = (value: number) => MARGIN.top + ((high - value) / (high - low)) * plotHeight
1552
+ const points = totals.map((total, index) => `${x(index)},${y(total)}`)
1553
+ const payback = plan.$paybackYears
1554
+ const paysBack = Number.isFinite(payback) && payback <= lastYear
1555
+ const hoveredTotal = year === null ? null : totals[year]
1556
+
1557
+ const handleMove = (event: MouseEvent<SVGRectElement>) => {
1558
+ const bounds = event.currentTarget.getBoundingClientRect()
1559
+ const fraction = (event.clientX - bounds.left) / bounds.width
1560
+
1561
+ setYear(Math.min(lastYear, Math.max(0, Math.round(fraction * lastYear))))
1562
+ }
1563
+
1564
+ return (
1565
+ <Box component="div" ref={ref} sx={{ position: 'relative', blockSize: height }}>
1566
+ {width > 0 && (
1567
+ <svg
1568
+ width={width}
1569
+ height={height}
1570
+ role="img"
1571
+ aria-label={
1572
+ paysBack
1573
+ ? `The system pays back after ${payback.toFixed(1)} years`
1574
+ : 'The system does not pay back within 25 years'
1575
+ }
1576
+ >
1577
+ {ticks.map(tick => (
1578
+ <g key={tick}>
1579
+ <line
1580
+ x1={MARGIN.left}
1581
+ x2={width - MARGIN.right}
1582
+ y1={y(tick)}
1583
+ y2={y(tick)}
1584
+ stroke={tick === 0 ? CHART.axis : CHART.grid}
1585
+ strokeWidth={1}
1586
+ />
1587
+ <text
1588
+ x={MARGIN.left - 8}
1589
+ y={y(tick)}
1590
+ textAnchor="end"
1591
+ dominantBaseline="middle"
1592
+ fill={CHART.inkSoft}
1593
+ fontFamily={CHART.font}
1594
+ fontSize={11}
1595
+ >
1596
+ {tick === 0 ? '€0' : `${tick < 0 ? '−' : ''}€${Math.abs(tick / 1000)}k`}
1597
+ </text>
1598
+ </g>
1599
+ ))}
1600
+ {YEAR_TICKS.map(tick => (
1601
+ <text
1602
+ key={tick}
1603
+ x={x(tick)}
1604
+ y={height - 8}
1605
+ textAnchor="middle"
1606
+ fill={CHART.inkSoft}
1607
+ fontFamily={CHART.font}
1608
+ fontSize={11}
1609
+ >
1610
+ {tick === 0 ? 'Year 0' : tick}
1611
+ </text>
1612
+ ))}
1613
+
1614
+ {/* A wash between the line and zero: what is still owed below, what is earned above */}
1615
+ <polygon points={`${x(0)},${y(0)} ${points.join(' ')} ${x(lastYear)},${y(0)}`} fill={LINE} opacity={0.1} />
1616
+ <polyline
1617
+ points={points.join(' ')}
1618
+ fill="none"
1619
+ stroke={LINE}
1620
+ strokeWidth={2}
1621
+ strokeLinejoin="round"
1622
+ strokeLinecap="round"
1623
+ />
1624
+
1625
+ {paysBack && (
1626
+ <g>
1627
+ <line
1628
+ x1={x(payback)}
1629
+ x2={x(payback)}
1630
+ y1={y(0)}
1631
+ y2={MARGIN.top + plotHeight}
1632
+ stroke={CHART.axis}
1633
+ strokeWidth={1}
1634
+ />
1635
+ <circle cx={x(payback)} cy={y(0)} r={5} fill={LINE} stroke={CHART.surface} strokeWidth={2} />
1636
+ {/* Right of the point the line runs above zero and left of it below, so the label takes the empty side */}
1637
+ <text
1638
+ x={x(payback) + (x(payback) > width * 0.7 ? -10 : 10)}
1639
+ y={x(payback) > width * 0.7 ? y(0) - 10 : y(0) + 20}
1640
+ textAnchor={x(payback) > width * 0.7 ? 'end' : 'start'}
1641
+ fill={CHART.ink}
1642
+ fontFamily={CHART.font}
1643
+ fontSize={12}
1644
+ fontWeight={600}
1645
+ >
1646
+ Pays back in year {Math.ceil(payback)}
1647
+ </text>
1648
+ </g>
1649
+ )}
1650
+
1651
+ {year !== null && hoveredTotal !== undefined && hoveredTotal !== null && (
1652
+ <g>
1653
+ <line
1654
+ x1={x(year)}
1655
+ x2={x(year)}
1656
+ y1={MARGIN.top}
1657
+ y2={MARGIN.top + plotHeight}
1658
+ stroke={CHART.inkSoft}
1659
+ strokeWidth={1}
1660
+ />
1661
+ <circle cx={x(year)} cy={y(hoveredTotal)} r={4} fill={LINE} stroke={CHART.surface} strokeWidth={2} />
1662
+ </g>
1663
+ )}
1664
+ <rect
1665
+ x={MARGIN.left}
1666
+ y={MARGIN.top}
1667
+ width={plotWidth}
1668
+ height={plotHeight}
1669
+ fill="transparent"
1670
+ onMouseMove={handleMove}
1671
+ onMouseLeave={() => setYear(null)}
1672
+ />
1673
+ </svg>
1674
+ )}
1675
+
1676
+ {year !== null && hoveredTotal !== undefined && hoveredTotal !== null && (
1677
+ <Box
1678
+ component="div"
1679
+ role="tooltip"
1680
+ sx={{
1681
+ position: 'absolute',
1682
+ insetBlockStart: 0,
1683
+ insetInlineStart: Math.min(Math.max(x(year) - 80, 0), Math.max(width - 160, 0)),
1684
+ inlineSize: 160,
1685
+ padding: 1.25,
1686
+ borderRadius: 1.5,
1687
+ bgcolor: CHART.surface,
1688
+ boxShadow: '0 6px 24px rgba(28, 40, 38, 0.16)',
1689
+ pointerEvents: 'none',
1690
+ }}
1691
+ >
1692
+ <Typography sx={{ fontFamily: CHART.font, fontSize: 12, fontWeight: 600, color: CHART.ink }}>
1693
+ After year {year}
1694
+ </Typography>
1695
+ <Typography sx={{ fontFamily: CHART.font, fontSize: 12, color: CHART.inkSoft }}>
1696
+ {hoveredTotal >= 0 ? `${euro(hoveredTotal)} ahead` : `${euro(-hoveredTotal)} still to recover`}
1697
+ </Typography>
1698
+ </Box>
1699
+ )}
1700
+ </Box>
1701
+ )
1702
+ }
1703
+ ```
1704
+
1705
+ ### ./src/\_charts/chart-style.ts
1706
+
1707
+ ```ts
1708
+ // The charts' series colours, in a fixed order and checked as a set for colour-vision deficiencies on the white
1709
+ // card. Two of them sit below 3:1 against white, so every chart keeps a legend, a tooltip and a table of its figures.
1710
+ export const SERIES = {
1711
+ selfUse: '#e8770e',
1712
+ exported: '#1baf7a',
1713
+ consumption: '#2a78d6',
1714
+ } as const
1715
+
1716
+ export const CHART = {
1717
+ surface: '#ffffff',
1718
+ ink: '#1c2826',
1719
+ inkSoft: '#5b6664',
1720
+ grid: '#e9e6df',
1721
+ axis: '#c9c4b8',
1722
+ font: "'Poppins', 'Helvetica Neue', Arial, sans-serif",
1723
+ } as const
1724
+ ```
1725
+
1726
+ ### ./src/\_charts/nice-scale.ts
1727
+
1728
+ ```ts
1729
+ // Round axis bounds and ticks for a value range: steps of 1, 2 or 5 times a power of ten, about `count` of them
1730
+ export const niceScale = (min: number, max: number, count: number) => {
1731
+ const span = Math.max(max - min, 1)
1732
+ const raw = span / count
1733
+ const magnitude = 10 ** Math.floor(Math.log10(raw))
1734
+ const step = [1, 2, 5, 10].map(factor => factor * magnitude).find(candidate => candidate >= raw) ?? 10 * magnitude
1735
+ const low = Math.floor(min / step) * step
1736
+ const high = Math.ceil(max / step) * step
1737
+ const ticks = Array.from({ length: Math.round((high - low) / step) + 1 }, (_, index) => low + index * step)
1738
+
1739
+ return { low, high, ticks }
1740
+ }
1741
+ ```
1742
+
1743
+ ### ./src/\_charts/use-width.ts
1744
+
1745
+ ```ts
1746
+ import { useLayoutEffect, useRef, useState } from 'react'
1747
+
1748
+ // The element's width in pixels, measured again whenever it changes, so a chart draws its text at its real size
1749
+ // instead of scaling a fixed drawing
1750
+ export const useWidth = () => {
1751
+ const ref = useRef<HTMLDivElement>(null)
1752
+ const [width, setWidth] = useState(0)
1753
+
1754
+ useLayoutEffect(() => {
1755
+ const element = ref.current
1756
+
1757
+ if (element === null) {
1758
+ return
1759
+ }
1760
+
1761
+ const observer = new ResizeObserver(([entry]) => {
1762
+ if (entry) {
1763
+ setWidth(entry.contentRect.width)
1764
+ }
1765
+ })
1766
+ observer.observe(element)
1767
+
1768
+ return () => observer.disconnect()
1769
+ }, [])
1770
+
1771
+ return { ref, width }
1772
+ }
1773
+ ```
1774
+
1775
+ ### ./src/\_shared/ChartCards.tsx
1776
+
1777
+ ```tsx
1778
+ import { Box, Typography } from '@mui/material'
1779
+ import type { ReactNode } from 'react'
1780
+
1781
+ import { EnergySplitBar, MonthlyEnergyChart, PaybackChart } from '../_charts/index.js'
1782
+ import { CARD_SX, SUNFOLD } from './tokens.js'
1783
+
1784
+ interface ChartCardProps {
1785
+ readonly title: string
1786
+ readonly subtitle: string
1787
+ readonly area?: string
1788
+ readonly children: ReactNode
1789
+ }
1790
+
1791
+ const ChartCard = ({ title, subtitle, area, children }: ChartCardProps) => {
1792
+ return (
1793
+ <Box
1794
+ component="section"
1795
+ aria-label={title}
1796
+ sx={{ ...CARD_SX, display: 'grid', alignContent: 'start', gap: 2, gridArea: area }}
1797
+ >
1798
+ <Box component="header" sx={{ display: 'grid', gap: 0.25 }}>
1799
+ <Typography component="h2" sx={{ fontFamily: SUNFOLD.font, fontSize: 16, fontWeight: 600, color: SUNFOLD.ink }}>
1800
+ {title}
1801
+ </Typography>
1802
+ <Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.muted }}>{subtitle}</Typography>
1803
+ </Box>
1804
+ {children}
1805
+ </Box>
1806
+ )
1807
+ }
1808
+
1809
+ interface ChartCardsProps {
1810
+ readonly compact: boolean
1811
+ }
1812
+
1813
+ // The dashboard's charts: the year month by month across the full width, then the payback curve beside where the
1814
+ // power goes. On a phone they stack.
1815
+ export const ChartCards = ({ compact }: ChartCardsProps) => {
1816
+ return (
1817
+ <Box
1818
+ component="div"
1819
+ sx={{
1820
+ display: 'grid',
1821
+ gap: 2,
1822
+ gridTemplateColumns: compact ? 'minmax(0, 1fr)' : 'minmax(0, 3fr) minmax(0, 2fr)',
1823
+ gridTemplateAreas: compact ? '"monthly" "payback" "split"' : '"monthly monthly" "payback split"',
1824
+ }}
1825
+ >
1826
+ <ChartCard
1827
+ area="monthly"
1828
+ title="Your solar year"
1829
+ subtitle="Production by month, how much the home uses, and the home's own use"
1830
+ >
1831
+ <MonthlyEnergyChart height={compact ? 200 : 240} />
1832
+ </ChartCard>
1833
+ <ChartCard
1834
+ area="payback"
1835
+ title="When it pays back"
1836
+ subtitle="Money returned after the installed cost, with prices rising 2 % a year"
1837
+ >
1838
+ <PaybackChart height={compact ? 190 : 210} />
1839
+ </ChartCard>
1840
+ <ChartCard
1841
+ area="split"
1842
+ title="Where the power goes"
1843
+ subtitle="A year of production, split between the home and the grid"
1844
+ >
1845
+ <EnergySplitBar />
1846
+ </ChartCard>
1847
+ </Box>
1848
+ )
1849
+ }
1850
+ ```
1851
+
1852
+ ### ./src/\_shared/DesktopPage.tsx
1853
+
1854
+ ```tsx
1855
+ import { Box, Typography } from '@mui/material'
1856
+
1857
+ import { LayoutSlots } from '@salsita-ai/sdk/configurator'
1858
+
1859
+ import { phx } from '../../.phoenix/phx.js'
1860
+ import { ChartCards } from './ChartCards.js'
1861
+ import { KpiTiles } from './KpiTiles.js'
1862
+ import { PlannerInputs } from './PlannerInputs.js'
1863
+ import { SurveyPanel } from './SurveyPanel.js'
1864
+ import { SUNFOLD } from './tokens.js'
1865
+
1866
+ const SIDEBAR_WIDTH = 340
1867
+
1868
+ // The desktop page: the inputs in a sidebar with the cost and the booking at its foot, and the dashboard beside it,
1869
+ // four figures above three charts
1870
+ export const DesktopPage = () => {
1871
+ const { accessInfo } = phx.frontend.useAccessService()
1872
+ const navigationState = phx.frontend.useActiveNavigationState()
1873
+ // A booked plan keeps its inputs until the household returns to the plan
1874
+ const readOnly = accessInfo.viewMode || navigationState === 'booked'
1875
+
1876
+ return (
1877
+ <Box
1878
+ component="div"
1879
+ sx={{
1880
+ position: 'absolute',
1881
+ inset: 0,
1882
+ display: 'grid',
1883
+ gridTemplateColumns: `${SIDEBAR_WIDTH}px minmax(0, 1fr)`,
1884
+ bgcolor: SUNFOLD.linen,
1885
+ fontFamily: SUNFOLD.font,
1886
+ }}
1887
+ >
1888
+ <Box
1889
+ component="aside"
1890
+ aria-label="Your plan"
1891
+ sx={{
1892
+ display: 'flex',
1893
+ flexDirection: 'column',
1894
+ minBlockSize: 0,
1895
+ bgcolor: SUNFOLD.card,
1896
+ borderInlineEnd: `1px solid ${SUNFOLD.line}`,
1897
+ }}
1898
+ >
1899
+ <Box
1900
+ component="div"
1901
+ sx={{ display: 'flex', alignItems: 'center', gap: 1.25, paddingInline: 3, paddingBlock: 2.5 }}
1902
+ >
1903
+ <Box
1904
+ component="span"
1905
+ aria-hidden
1906
+ sx={{
1907
+ inlineSize: 26,
1908
+ blockSize: 26,
1909
+ borderRadius: '50%',
1910
+ bgcolor: SUNFOLD.amber,
1911
+ boxShadow: `0 0 0 5px ${SUNFOLD.amberTint}`,
1912
+ }}
1913
+ />
1914
+ <Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 20, fontWeight: 600, color: SUNFOLD.ink }}>
1915
+ sunfold
1916
+ </Typography>
1917
+ </Box>
1918
+ <Box component="div" sx={{ flex: 1, minBlockSize: 0, overflowY: 'auto', paddingInline: 3, paddingBlockEnd: 3 }}>
1919
+ <PlannerInputs readOnly={readOnly} />
1920
+ </Box>
1921
+ <Box component="div" sx={{ padding: 3, borderBlockStart: `1px solid ${SUNFOLD.line}` }}>
1922
+ <SurveyPanel compact={false} />
1923
+ </Box>
1924
+ </Box>
1925
+
1926
+ <Box component="main" sx={{ minBlockSize: 0, overflowY: 'auto' }}>
1927
+ <Box component="div" sx={{ display: 'grid', gap: 2.5, maxInlineSize: 1180, marginInline: 'auto', padding: 4 }}>
1928
+ <Box component="header" sx={{ display: 'grid', gap: 0.5 }}>
1929
+ <Typography
1930
+ component="h1"
1931
+ sx={{ fontFamily: SUNFOLD.font, fontSize: 28, fontWeight: 600, color: SUNFOLD.ink }}
1932
+ >
1933
+ Your solar plan
1934
+ </Typography>
1935
+ <Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 14, color: SUNFOLD.muted }}>
1936
+ Estimates for a home in central Europe. Every figure follows the plan on the left.
1937
+ </Typography>
1938
+ </Box>
1939
+ <KpiTiles columns={4} />
1940
+ <ChartCards compact={false} />
1941
+ </Box>
1942
+ </Box>
1943
+
1944
+ {/* Snackbars, such as a failed save, stack upwards from the dashboard's bottom right corner */}
1945
+ <Box component="div" sx={{ position: 'absolute', insetBlockEnd: 24, insetInlineEnd: 24, inlineSize: 320 }}>
1946
+ <LayoutSlots.DesktopSnackbarsRoot />
1947
+ </Box>
1948
+ </Box>
1949
+ )
1950
+ }
1951
+ ```
1952
+
1953
+ ### ./src/\_shared/KpiTiles.tsx
1954
+
1955
+ ```tsx
1956
+ import { Box, Typography } from '@mui/material'
1957
+
1958
+ import { phx } from '../../.phoenix/phx.js'
1959
+ import { CARD_SX, SUNFOLD } from './tokens.js'
1960
+
1961
+ const euro = (value: number) => {
1962
+ return value.toLocaleString('en-IE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })
1963
+ }
1964
+
1965
+ interface KpiTilesProps {
1966
+ readonly columns: number
1967
+ }
1968
+
1969
+ // The four figures a household asks first, each with the context that makes it mean something. Every value is an
1970
+ // expression of the spec, read from the root entity.
1971
+ export const KpiTiles = ({ columns }: KpiTilesProps) => {
1972
+ const plan = phx.frontend.useRootEntityState()
1973
+ const payback = plan.$paybackYears
1974
+ const tiles = [
1975
+ {
1976
+ label: 'System size',
1977
+ value: `${plan.$systemKwp.toFixed(1)} kWp`,
1978
+ context: `${plan.panels} panels, ${plan.$panelAreaM2} m² of roof`,
1979
+ },
1980
+ {
1981
+ label: 'Annual production',
1982
+ value: `${Math.round(plan.$annualProductionKwh).toLocaleString('en')} kWh`,
1983
+ context: `About ${plan.$co2Tonnes.toFixed(1)} t of CO₂ avoided a year`,
1984
+ },
1985
+ {
1986
+ label: 'Self-consumption',
1987
+ value: `${Math.round(plan.$selfConsumption * 100)} %`,
1988
+ context: 'of the solar power is used at home',
1989
+ },
1990
+ {
1991
+ label: 'Payback',
1992
+ value: Number.isFinite(payback) ? `${payback.toFixed(1)} years` : 'Over 25 years',
1993
+ context: `${euro(plan.$annualSavings)} saved in the first year`,
1994
+ },
1995
+ ]
1996
+
1997
+ return (
1998
+ <Box component="div" sx={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gap: 2 }}>
1999
+ {tiles.map(tile => (
2000
+ <Box
2001
+ component="section"
2002
+ key={tile.label}
2003
+ aria-label={tile.label}
2004
+ sx={{ ...CARD_SX, display: 'grid', gap: 0.5, padding: 2 }}
2005
+ >
2006
+ <Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 13, color: SUNFOLD.muted }}>{tile.label}</Typography>
2007
+ <Typography
2008
+ sx={{
2009
+ fontFamily: SUNFOLD.font,
2010
+ fontSize: columns > 2 ? 30 : 24,
2011
+ fontWeight: 600,
2012
+ lineHeight: 1.15,
2013
+ color: SUNFOLD.ink,
2014
+ }}
2015
+ >
2016
+ {tile.value}
2017
+ </Typography>
2018
+ <Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.muted }}>{tile.context}</Typography>
2019
+ </Box>
2020
+ ))}
2021
+ </Box>
2022
+ )
2023
+ }
2024
+ ```
2025
+
2026
+ ### ./src/\_shared/PhonePage.tsx
2027
+
2028
+ ```tsx
2029
+ import { Box, Typography } from '@mui/material'
2030
+
2031
+ import { LayoutSlots } from '@salsita-ai/sdk/configurator'
2032
+
2033
+ import { phx } from '../../.phoenix/phx.js'
2034
+ import { ChartCards } from './ChartCards.js'
2035
+ import { KpiTiles } from './KpiTiles.js'
2036
+ import { PlannerInputs } from './PlannerInputs.js'
2037
+ import { SurveyPanel } from './SurveyPanel.js'
2038
+ import { CARD_SX, SUNFOLD } from './tokens.js'
2039
+
2040
+ // The phone page: one scrolling column with the figures first, the inputs below them and the charts last, and the
2041
+ // cost with the booking in a bar at the bottom
2042
+ export const PhonePage = () => {
2043
+ const { accessInfo } = phx.frontend.useAccessService()
2044
+ const navigationState = phx.frontend.useActiveNavigationState()
2045
+ const readOnly = accessInfo.viewMode || navigationState === 'booked'
2046
+
2047
+ return (
2048
+ <Box
2049
+ component="div"
2050
+ sx={{
2051
+ position: 'absolute',
2052
+ inset: 0,
2053
+ display: 'grid',
2054
+ gridTemplateRows: 'minmax(0, 1fr) auto',
2055
+ bgcolor: SUNFOLD.linen,
2056
+ fontFamily: SUNFOLD.font,
2057
+ }}
2058
+ >
2059
+ <Box component="main" sx={{ minBlockSize: 0, overflowY: 'auto' }}>
2060
+ <Box component="div" sx={{ display: 'grid', gap: 2, padding: 2 }}>
2061
+ <Box component="header" sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
2062
+ <Box
2063
+ component="span"
2064
+ aria-hidden
2065
+ sx={{
2066
+ inlineSize: 22,
2067
+ blockSize: 22,
2068
+ borderRadius: '50%',
2069
+ bgcolor: SUNFOLD.amber,
2070
+ boxShadow: `0 0 0 4px ${SUNFOLD.amberTint}`,
2071
+ }}
2072
+ />
2073
+ <Typography
2074
+ component="h1"
2075
+ sx={{ fontFamily: SUNFOLD.font, fontSize: 20, fontWeight: 600, color: SUNFOLD.ink }}
2076
+ >
2077
+ Your solar plan
2078
+ </Typography>
2079
+ </Box>
2080
+ <KpiTiles columns={2} />
2081
+ <Box component="div" sx={CARD_SX}>
2082
+ <PlannerInputs readOnly={readOnly} />
2083
+ </Box>
2084
+ <ChartCards compact />
2085
+ </Box>
2086
+ </Box>
2087
+ <Box
2088
+ component="footer"
2089
+ sx={{
2090
+ padding: 2,
2091
+ bgcolor: SUNFOLD.card,
2092
+ borderBlockStart: `1px solid ${SUNFOLD.line}`,
2093
+ boxShadow: '0 -8px 24px rgba(28, 40, 38, 0.08)',
2094
+ }}
2095
+ >
2096
+ <SurveyPanel compact />
2097
+ </Box>
2098
+ <LayoutSlots.MobileSnackbarsRoot />
2099
+ </Box>
2100
+ )
2101
+ }
2102
+ ```
2103
+
2104
+ ### ./src/\_shared/PillField.tsx
2105
+
2106
+ ```tsx
2107
+ import { Box, ButtonBase, Typography } from '@mui/material'
2108
+ import type { KeyboardEvent } from 'react'
2109
+
2110
+ import { phx } from '../../.phoenix/phx.js'
2111
+ import { SUNFOLD } from './tokens.js'
2112
+
2113
+ // Arrow keys move the selection between the pills, as in a native radio group
2114
+ const ARROW_STEPS: Readonly<Record<string, number | undefined>> = {
2115
+ ArrowRight: 1,
2116
+ ArrowDown: 1,
2117
+ ArrowLeft: -1,
2118
+ ArrowUp: -1,
2119
+ }
2120
+
2121
+ interface PillFieldProps {
2122
+ readonly instanceName: string
2123
+ readonly label: string
2124
+ readonly readOnly: boolean
2125
+ }
2126
+
2127
+ // One `attributeSelection` of the spec as a row of pills, with the chosen option's description below it
2128
+ export const PillField = ({ instanceName, label, readOnly }: PillFieldProps) => {
2129
+ const resolve = phx.frontend.useResolveLocalizable()
2130
+ const planId = phx.frontend.useRootEntityId()
2131
+ const { value, options } = phx.frontend.useAttributeSelection(planId, instanceName)
2132
+ const selected = options.find(option => option.value === value)
2133
+ const labelId = `sunfold-${instanceName}-label`
2134
+
2135
+ const handleKeyDown = (event: KeyboardEvent<HTMLElement>, index: number) => {
2136
+ const step = ARROW_STEPS[event.key]
2137
+
2138
+ if (step === undefined) {
2139
+ return
2140
+ }
2141
+
2142
+ event.preventDefault()
2143
+ const next = (index + step + options.length) % options.length
2144
+ options[next]?.onSelect()
2145
+ event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="radio"]')[next]?.focus()
2146
+ }
2147
+
2148
+ return (
2149
+ <Box component="div" sx={{ display: 'grid', gap: 0.75 }}>
2150
+ <Typography id={labelId} sx={{ fontFamily: SUNFOLD.font, fontSize: 14, color: SUNFOLD.ink }}>
2151
+ {label}
2152
+ </Typography>
2153
+ <Box
2154
+ component="div"
2155
+ role="radiogroup"
2156
+ aria-labelledby={labelId}
2157
+ sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}
2158
+ >
2159
+ {options.map((option, index) => {
2160
+ const isSelected = option.value === value
2161
+
2162
+ return (
2163
+ <ButtonBase
2164
+ key={String(option.value)}
2165
+ role="radio"
2166
+ aria-checked={isSelected}
2167
+ tabIndex={isSelected ? 0 : -1}
2168
+ disabled={readOnly && !isSelected}
2169
+ onClick={readOnly ? undefined : option.onSelect}
2170
+ onKeyDown={readOnly ? undefined : event => handleKeyDown(event, index)}
2171
+ sx={{
2172
+ paddingInline: 1.5,
2173
+ paddingBlock: 0.75,
2174
+ borderRadius: 999,
2175
+ border: `1px solid ${isSelected ? SUNFOLD.green : SUNFOLD.line}`,
2176
+ bgcolor: isSelected ? SUNFOLD.green : SUNFOLD.card,
2177
+ color: isSelected ? SUNFOLD.card : SUNFOLD.ink,
2178
+ fontFamily: SUNFOLD.font,
2179
+ fontSize: 13,
2180
+ fontWeight: isSelected ? 500 : 400,
2181
+ transition: 'background-color 120ms',
2182
+ '&:hover': { borderColor: SUNFOLD.green },
2183
+ '&.Mui-focusVisible': { outline: `2px solid ${SUNFOLD.amber}`, outlineOffset: 2 },
2184
+ }}
2185
+ >
2186
+ {resolve(option.title)}
2187
+ </ButtonBase>
2188
+ )
2189
+ })}
2190
+ </Box>
2191
+ {selected?.description && (
2192
+ <Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.muted }}>
2193
+ {resolve(selected.description)}
2194
+ </Typography>
2195
+ )}
2196
+ </Box>
2197
+ )
2198
+ }
2199
+ ```
2200
+
2201
+ ### ./src/\_shared/PlannerInputs.tsx
2202
+
2203
+ ```tsx
2204
+ import { Box, Typography } from '@mui/material'
2205
+ import type { ReactNode } from 'react'
2206
+
2207
+ import { phx } from '../../.phoenix/phx.js'
2208
+ import { PillField } from './PillField.js'
2209
+ import { SliderField } from './SliderField.js'
2210
+ import { SUNFOLD } from './tokens.js'
2211
+
2212
+ interface GroupProps {
2213
+ readonly title: string
2214
+ // The invariant types whose message belongs under this group
2215
+ readonly messageTypes?: readonly string[]
2216
+ readonly children: ReactNode
2217
+ }
2218
+
2219
+ // A group of inputs with the message of an invariant the group can resolve: a warning, which blocks the booking, in
2220
+ // amber, a notice, which only informs, in blue. `useFinalizeViolations` lists both.
2221
+ const Group = ({ title, messageTypes = [], children }: GroupProps) => {
2222
+ const { violations } = phx.frontend.useFinalizeViolations()
2223
+ const resolve = phx.frontend.useResolveLocalizable()
2224
+ const messages = (violations ?? []).filter(({ violation }) => messageTypes.includes(violation.type))
2225
+
2226
+ return (
2227
+ <Box component="section" sx={{ display: 'grid', gap: 2 }}>
2228
+ <Typography component="h3" sx={{ fontFamily: SUNFOLD.font, fontSize: 13, fontWeight: 600, color: SUNFOLD.muted }}>
2229
+ {title}
2230
+ </Typography>
2231
+ {children}
2232
+ {messages.map(({ violation }) => {
2233
+ const isNotice = violation.severity === 'notice'
2234
+
2235
+ return (
2236
+ <Typography
2237
+ key={violation.type}
2238
+ role={isNotice ? 'status' : 'alert'}
2239
+ sx={{
2240
+ paddingInline: 1.5,
2241
+ paddingBlock: 1,
2242
+ borderRadius: 2,
2243
+ bgcolor: isNotice ? SUNFOLD.infoTint : SUNFOLD.warningTint,
2244
+ color: isNotice ? SUNFOLD.info : SUNFOLD.warning,
2245
+ fontFamily: SUNFOLD.font,
2246
+ fontSize: 13,
2247
+ lineHeight: 1.45,
2248
+ }}
2249
+ >
2250
+ {resolve(violation.message)}
2251
+ </Typography>
2252
+ )
2253
+ })}
2254
+ </Box>
2255
+ )
2256
+ }
2257
+
2258
+ interface PlannerInputsProps {
2259
+ readonly readOnly: boolean
2260
+ }
2261
+
2262
+ // The household's inputs: the roof, the panels on it, the home's use and tariff, and the battery
2263
+ export const PlannerInputs = ({ readOnly }: PlannerInputsProps) => {
2264
+ const plan = phx.frontend.useRootEntityState()
2265
+
2266
+ return (
2267
+ <Box component="div" sx={{ display: 'grid', gap: 3.5 }}>
2268
+ <Group title="Your roof" messageTypes={['panelsExceedRoof', 'northFacing']}>
2269
+ <SliderField instanceName="roofArea" label="Usable roof area" step={1} readOnly={readOnly} />
2270
+ <PillField instanceName="orientation" label="Facing" readOnly={readOnly} />
2271
+ <SliderField instanceName="panels" label="Panels" step={1} readOnly={readOnly} />
2272
+ <Typography sx={{ marginBlockStart: -1.5, fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.muted }}>
2273
+ 430 W panels, 1.95 m² each. This roof holds up to {plan.$roofPanelLimit}.
2274
+ </Typography>
2275
+ </Group>
2276
+ <Group title="Your home">
2277
+ <SliderField instanceName="annualUse" label="Electricity used a year" step={100} readOnly={readOnly} />
2278
+ <PillField instanceName="tariff" label="Tariff" readOnly={readOnly} />
2279
+ </Group>
2280
+ <Group title="Storage">
2281
+ <PillField instanceName="battery" label="Home battery" readOnly={readOnly} />
2282
+ </Group>
2283
+ </Box>
2284
+ )
2285
+ }
2286
+ ```
2287
+
2288
+ ### ./src/\_shared/SliderField.tsx
2289
+
2290
+ ```tsx
2291
+ import { Box, Slider, Typography } from '@mui/material'
2292
+
2293
+ import { phx } from '../../.phoenix/phx.js'
2294
+ import { SUNFOLD } from './tokens.js'
2295
+
2296
+ interface SliderFieldProps {
2297
+ readonly instanceName: string
2298
+ readonly label: string
2299
+ readonly step: number
2300
+ readonly readOnly: boolean
2301
+ }
2302
+
2303
+ // One `genericDimension` of the spec as a slider with its value and unit above it. Dragging stages each value, so
2304
+ // every tile and chart follows the thumb, and letting go commits it.
2305
+ export const SliderField = ({ instanceName, label, step, readOnly }: SliderFieldProps) => {
2306
+ const planId = phx.frontend.useRootEntityId()
2307
+ const dimension = phx.frontend.useGenericDimension(planId, instanceName)
2308
+ const labelId = `sunfold-${instanceName}-label`
2309
+
2310
+ return (
2311
+ <Box component="div" sx={{ display: 'grid', gap: 0.25 }}>
2312
+ <Box component="div" sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 2 }}>
2313
+ <Typography id={labelId} sx={{ fontFamily: SUNFOLD.font, fontSize: 14, color: SUNFOLD.ink }}>
2314
+ {label}
2315
+ </Typography>
2316
+ <Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 15, fontWeight: 600, color: SUNFOLD.ink }}>
2317
+ {dimension.value.toLocaleString('en')} {dimension.unit}
2318
+ </Typography>
2319
+ </Box>
2320
+ <Slider
2321
+ value={dimension.value}
2322
+ min={dimension.min ?? 0}
2323
+ max={dimension.max ?? 100}
2324
+ step={step}
2325
+ disabled={readOnly}
2326
+ onChange={(_, value) => dimension.update(Array.isArray(value) ? (value[0] ?? 0) : value)}
2327
+ onChangeCommitted={() => dimension.resolve()}
2328
+ slotProps={{ input: { 'aria-labelledby': labelId } }}
2329
+ sx={{
2330
+ color: SUNFOLD.green,
2331
+ '& .MuiSlider-rail': { bgcolor: SUNFOLD.line, opacity: 1 },
2332
+ '& .MuiSlider-thumb': {
2333
+ inlineSize: 18,
2334
+ blockSize: 18,
2335
+ bgcolor: SUNFOLD.card,
2336
+ border: `3px solid ${SUNFOLD.green}`,
2337
+ },
2338
+ }}
2339
+ />
2340
+ </Box>
2341
+ )
2342
+ }
2343
+ ```
2344
+
2345
+ ### ./src/\_shared/SurveyPanel.tsx
2346
+
2347
+ ```tsx
2348
+ import { Alert, Box, Button, CircularProgress, Typography } from '@mui/material'
2349
+
2350
+ import { phx } from '../../.phoenix/phx.js'
2351
+ import { SUNFOLD } from './tokens.js'
2352
+
2353
+ interface SurveyPanelProps {
2354
+ // On a phone the panel is a bar: the cost beside the button, without the hints
2355
+ readonly compact: boolean
2356
+ }
2357
+
2358
+ // The system's cost, the `price` component's value, and the spec's `survey` finalize action. A warning blocks the
2359
+ // booking and its `hints` say why; a notice does not. In the `booked` navigation state the panel confirms the
2360
+ // request instead.
2361
+ export const SurveyPanel = ({ compact }: SurveyPanelProps) => {
2362
+ const cost = phx.frontend.useRootEntityPrice()
2363
+ const { primary, hints } = phx.frontend.useFinalizeActions()
2364
+ const navigationState = phx.frontend.useActiveNavigationState()
2365
+ const navigate = phx.frontend.useNavigate()
2366
+ const { accessInfo } = phx.frontend.useAccessService()
2367
+ const isPending = primary?.isPending ?? false
2368
+
2369
+ if (navigationState === 'booked') {
2370
+ return (
2371
+ <Box component="div" role="status" sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
2372
+ <Box component="div" sx={{ display: 'grid', flex: 1 }}>
2373
+ <Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 16, fontWeight: 600, color: SUNFOLD.green }}>
2374
+ Survey requested
2375
+ </Typography>
2376
+ <Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 13, color: SUNFOLD.muted }}>
2377
+ An installer calls within two working days.
2378
+ </Typography>
2379
+ </Box>
2380
+ <Button
2381
+ onClick={() => navigate('plan')}
2382
+ sx={{
2383
+ fontFamily: SUNFOLD.font,
2384
+ fontSize: 13,
2385
+ color: SUNFOLD.ink,
2386
+ textTransform: 'none',
2387
+ textDecoration: 'underline',
2388
+ }}
2389
+ >
2390
+ Back to the plan
2391
+ </Button>
2392
+ </Box>
2393
+ )
2394
+ }
2395
+
2396
+ const showButton = primary !== null && !accessInfo.viewMode
2397
+
2398
+ return (
2399
+ <Box component="div" sx={{ display: 'grid', gap: 1.25 }}>
2400
+ <Box
2401
+ component="div"
2402
+ sx={{
2403
+ display: 'flex',
2404
+ flexDirection: compact ? 'row' : 'column',
2405
+ alignItems: compact ? 'center' : 'stretch',
2406
+ justifyContent: 'space-between',
2407
+ gap: compact ? 2 : 1.5,
2408
+ }}
2409
+ >
2410
+ <Box component="div" sx={{ display: 'grid' }}>
2411
+ <Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.muted }}>Installed cost</Typography>
2412
+ <Typography
2413
+ sx={{ fontFamily: SUNFOLD.font, fontSize: compact ? 20 : 26, fontWeight: 600, color: SUNFOLD.ink }}
2414
+ >
2415
+ {cost}
2416
+ </Typography>
2417
+ </Box>
2418
+ {showButton && (
2419
+ <Button
2420
+ variant="contained"
2421
+ disableElevation
2422
+ disabled={primary.disabled || isPending}
2423
+ onClick={() => void primary.run()}
2424
+ title={primary.title}
2425
+ startIcon={isPending ? <CircularProgress size={16} color="inherit" /> : undefined}
2426
+ sx={{
2427
+ minBlockSize: 46,
2428
+ paddingInline: 2.5,
2429
+ borderRadius: 999,
2430
+ bgcolor: SUNFOLD.green,
2431
+ fontFamily: SUNFOLD.font,
2432
+ fontSize: 14,
2433
+ fontWeight: 500,
2434
+ textTransform: 'none',
2435
+ '&:hover': { bgcolor: SUNFOLD.greenDark },
2436
+ '&.Mui-disabled': { bgcolor: SUNFOLD.line, color: SUNFOLD.muted },
2437
+ }}
2438
+ >
2439
+ {isPending ? 'Booking…' : primary.label}
2440
+ </Button>
2441
+ )}
2442
+ </Box>
2443
+ {!compact && showButton && primary.disabled && hints && (
2444
+ <Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.warning }}>{hints}</Typography>
2445
+ )}
2446
+ {primary?.error && <Alert severity="error">The survey could not be booked: {primary.error.message}</Alert>}
2447
+ </Box>
2448
+ )
2449
+ }
2450
+ ```
2451
+
2452
+ ### ./src/\_shared/tokens.ts
2453
+
2454
+ ```ts
2455
+ // Sunfold's palette and type: warm linen, a deep green ink and the amber of the logo. Poppins is the brand's typeface;
2456
+ // the stack falls back to system fonts where it is not loaded.
2457
+ export const SUNFOLD = {
2458
+ linen: '#f6f3ec',
2459
+ card: '#ffffff',
2460
+ ink: '#1c2826',
2461
+ muted: '#5b6664',
2462
+ line: '#e7e2d8',
2463
+ green: '#1f6f54',
2464
+ greenDark: '#17583f',
2465
+ amber: '#f2a516',
2466
+ amberTint: '#fff4dc',
2467
+ warning: '#9a4b00',
2468
+ warningTint: '#fff3e0',
2469
+ info: '#2a5c8a',
2470
+ infoTint: '#e9f1f9',
2471
+ font: "'Poppins', 'Helvetica Neue', Arial, sans-serif",
2472
+ } as const
2473
+
2474
+ export const CARD_SX = {
2475
+ padding: 2.5,
2476
+ borderRadius: 3,
2477
+ bgcolor: SUNFOLD.card,
2478
+ boxShadow: '0 1px 2px rgba(28, 40, 38, 0.06)',
2479
+ border: `1px solid ${SUNFOLD.line}`,
2480
+ } as const
2481
+ ```
2482
+
2483
+ ### ./src/elements/ConfiguratorLayout.tsx
2484
+
2485
+ ```tsx
2486
+ import { Box } from '@mui/material'
2487
+
2488
+ import { LayoutSlots } from '@salsita-ai/sdk/configurator'
2489
+
2490
+ import { phx } from '../../.phoenix/phx.js'
2491
+ import { DesktopPage } from '../_shared/DesktopPage.js'
2492
+ import { PhonePage } from '../_shared/PhonePage.js'
2493
+ import { SUNFOLD } from '../_shared/tokens.js'
2494
+
2495
+ // A planning dashboard with no scene: the layout never mounts `LayoutSlots.SharedCanvas`, and every tile and chart is
2496
+ // React and SVG drawn from the spec's expressions
2497
+ const ConfiguratorLayout = () => {
2498
+ // `null` until the container has been measured
2499
+ const viewports = phx.frontend.useActiveViewports()
2500
+ const isDesktop = viewports?.includes('desktop') ?? false
2501
+
2502
+ return (
2503
+ <Box component="div" sx={{ position: 'absolute', inset: 0, overflow: 'clip', bgcolor: SUNFOLD.linen }}>
2504
+ <LayoutSlots.Services />
2505
+ {viewports !== null && (isDesktop ? <DesktopPage /> : <PhonePage />)}
2506
+ </Box>
2507
+ )
2508
+ }
2509
+
2510
+ export default phx.frontend.createCustomPhoenixLayoutElement('ConfiguratorLayout', ConfiguratorLayout)
2511
+ ```
2512
+
2513
+ ### ./src/expressions/solar.ts
2514
+
2515
+ ```ts
2516
+ import { phx } from '../../.phoenix/phx.js'
2517
+
2518
+ const PANEL_KWP = 0.43
2519
+ const PANEL_AREA_M2 = 1.95
2520
+ // Vents, chimneys and the edge clearance leave about 85 % of a roof for panels
2521
+ const USABLE_ROOF = 0.85
2522
+ // A south-facing kWp in central Europe yields about 1,000 kWh a year
2523
+ const YIELD_KWH_PER_KWP = 1000
2524
+ const ORIENTATION_FACTOR = { South: 1, SouthEastWest: 0.95, EastWest: 0.82, North: 0.6 }
2525
+
2526
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
2527
+ // The share of the year's sun and of the household's use that falls in each month
2528
+ const SUN_SHARE = [0.04, 0.045, 0.08, 0.105, 0.125, 0.13, 0.13, 0.115, 0.09, 0.065, 0.04, 0.035]
2529
+ const USE_SHARE = [0.1, 0.09, 0.088, 0.08, 0.075, 0.07, 0.07, 0.072, 0.076, 0.085, 0.094, 0.1]
2530
+ const DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
2531
+
2532
+ const BATTERY_KWH = { None: 0, Kwh5: 5, Kwh10: 10, Kwh15: 15 }
2533
+ const BATTERY_PRICE = { None: 0, Kwh5: 3600, Kwh10: 6400, Kwh15: 8900 }
2534
+ const TARIFF = {
2535
+ Flat: { buy: 0.32, sell: 0.08, shiftBonus: 0 },
2536
+ TimeOfUse: { buy: 0.29, sell: 0.1, shiftBonus: 0.09 },
2537
+ ExportPlus: { buy: 0.32, sell: 0.15, shiftBonus: 0 },
2538
+ }
2539
+ // Panels lose half a percent a year; electricity gets 2 % dearer a year
2540
+ const DEGRADATION = 0.995
2541
+ const PRICE_GROWTH = 1.02
2542
+ const YEARS = 25
2543
+
2544
+ export default phx.domain.implementCustomExpressions({
2545
+ SolarPlan: {
2546
+ $systemKwp: s => {
2547
+ return s.panels * PANEL_KWP
2548
+ },
2549
+ $roofPanelLimit: s => {
2550
+ return Math.floor((s.roofArea * USABLE_ROOF) / PANEL_AREA_M2)
2551
+ },
2552
+ // The roof the panels cover, rounded up to whole square metres, as the tiles and the roof warning quote it
2553
+ $panelAreaM2: s => {
2554
+ return Math.ceil(s.panels * PANEL_AREA_M2)
2555
+ },
2556
+ $annualProductionKwh: s => {
2557
+ return s.$systemKwp * YIELD_KWH_PER_KWP * ORIENTATION_FACTOR[s.orientation]
2558
+ },
2559
+ // Each month's production and use, and how the production splits between the home and the grid. Without a
2560
+ // battery the home uses solar power only while the sun shines; a battery carries the surplus into the evening,
2561
+ // one charge a day, and covers the evening but not the early morning.
2562
+ $monthly: s => {
2563
+ const batteryKwh = BATTERY_KWH[s.battery] * 0.9
2564
+
2565
+ return MONTHS.map((month, index) => {
2566
+ const productionKwh = s.$annualProductionKwh * (SUN_SHARE[index] ?? 0)
2567
+ const consumptionKwh = s.annualUse * (USE_SHARE[index] ?? 0)
2568
+ const direct = Math.min(productionKwh * 0.55, consumptionKwh * 0.45)
2569
+ const stored = Math.min(
2570
+ productionKwh - direct,
2571
+ batteryKwh * (DAYS[index] ?? 30),
2572
+ (consumptionKwh - direct) * 0.6
2573
+ )
2574
+ const selfUseKwh = direct + stored
2575
+
2576
+ return { month, productionKwh, consumptionKwh, selfUseKwh, exportKwh: productionKwh - selfUseKwh }
2577
+ })
2578
+ },
2579
+ $selfConsumption: s => {
2580
+ const selfUse = s.$monthly.reduce((sum, month) => sum + month.selfUseKwh, 0)
2581
+
2582
+ return s.$annualProductionKwh > 0 ? selfUse / s.$annualProductionKwh : 0
2583
+ },
2584
+ $selfSufficiency: s => {
2585
+ const selfUse = s.$monthly.reduce((sum, month) => sum + month.selfUseKwh, 0)
2586
+
2587
+ return selfUse / s.annualUse
2588
+ },
2589
+ // A year's saving: power the home no longer buys, power sold to the grid, and on a time-of-use tariff the
2590
+ // evening power a battery shifts away from the dear hours
2591
+ $annualSavings: s => {
2592
+ const tariff = TARIFF[s.tariff]
2593
+ const selfUse = s.$monthly.reduce((sum, month) => sum + month.selfUseKwh, 0)
2594
+ const exported = s.$monthly.reduce((sum, month) => sum + month.exportKwh, 0)
2595
+ const shifted = s.battery === 'None' ? 0 : selfUse * 0.4
2596
+
2597
+ return selfUse * tariff.buy + exported * tariff.sell + shifted * tariff.shiftBonus
2598
+ },
2599
+ $systemCost: s => {
2600
+ return Math.round(1500 + s.$systemKwp * 1050 + BATTERY_PRICE[s.battery])
2601
+ },
2602
+ // The money the system has returned at the end of each year, starting at minus its cost in year 0
2603
+ $cumulativeSavings: s => {
2604
+ return Array.from({ length: YEARS + 1 }, (_, year) => {
2605
+ const earned = Array.from(
2606
+ { length: year },
2607
+ (__, past) => s.$annualSavings * (PRICE_GROWTH * DEGRADATION) ** past
2608
+ ).reduce((sum, value) => sum + value, 0)
2609
+
2610
+ return earned - s.$systemCost
2611
+ })
2612
+ },
2613
+ // The year the running total crosses zero, between whole years; beyond the panels' 25 years it is `Infinity`
2614
+ $paybackYears: s => {
2615
+ const totals = s.$cumulativeSavings
2616
+ const year = totals.findIndex(total => total >= 0)
2617
+
2618
+ if (year <= 0) {
2619
+ return year === 0 ? 0 : Infinity
2620
+ }
2621
+
2622
+ const before = totals[year - 1] ?? 0
2623
+ const after = totals[year] ?? 0
2624
+
2625
+ return year - 1 + -before / (after - before)
2626
+ },
2627
+ $co2Tonnes: s => {
2628
+ return (s.$annualProductionKwh * 0.35) / 1000
2629
+ },
2630
+ },
2631
+ })
2632
+ ```
2633
+
2634
+ ### ./src/handlers/book-survey.ts
2635
+
2636
+ ```ts
2637
+ import { useCallback } from 'react'
2638
+
2639
+ import { phx } from '../../.phoenix/phx.js'
2640
+
2641
+ // Stands in for the installer's booking service: the request takes a moment, then the page shows the confirmation
2642
+ export default phx.frontend.createPhoenixHandler('bookSurvey', () => {
2643
+ const navigate = phx.frontend.useNavigate()
2644
+
2645
+ return {
2646
+ handler: useCallback(async () => {
2647
+ await new Promise(resolve => setTimeout(resolve, 1200))
2648
+ navigate('booked')
2649
+ }, [navigate]),
2650
+ }
2651
+ })
2652
+ ```
2653
+
2654
+ ### ./src/invariants/solar.ts
2655
+
2656
+ ```ts
2657
+ import { phx } from '../../.phoenix/phx.js'
2658
+
2659
+ // More panels than the roof holds is a warning: the dashboard keeps showing the figures, and a survey cannot be
2660
+ // booked until the panel count fits. A north-facing roof is only a notice, so it never blocks the booking.
2661
+ export default phx.domain.implementCustomInvariants({
2662
+ SolarPlan: {
2663
+ panelsFitRoof: s => {
2664
+ if (s.panels <= s.$roofPanelLimit) {
2665
+ return []
2666
+ }
2667
+
2668
+ return [
2669
+ {
2670
+ type: 'panelsExceedRoof',
2671
+ severity: 'warning',
2672
+ message: `${s.panels} panels need about ${s.$panelAreaM2} m² of roof; ${s.roofArea} m² holds at most ${s.$roofPanelLimit}. Lower the panel count or measure the roof again.`,
2673
+ },
2674
+ ]
2675
+ },
2676
+ northFacing: s => {
2677
+ return s.orientation !== 'North'
2678
+ },
2679
+ },
2680
+ })
2681
+ ```
2682
+
2683
+ </details>