@salsita-ai/sdk 0.50.0-develop.f6390cb584 → 0.50.0-develop.fbe43b9a2d

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