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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (547) hide show
  1. package/dist/application/index.d.ts +1 -0
  2. package/dist/application/index.js +1 -1
  3. package/dist/application/mount-application.d.ts +1 -1
  4. package/dist/application/mount-application.js +1 -1
  5. package/dist/application/mount-configurator.d.ts +19 -0
  6. package/dist/application/mount-configurator.js +1 -0
  7. package/dist/application/mounted-application-runtime.d.ts +4 -0
  8. package/dist/application/mounted-application-runtime.js +1 -0
  9. package/dist/application/register-web-component.d.ts +2 -0
  10. package/dist/application/register-web-component.js +1 -0
  11. package/dist/application-spec/errors/diagnostic-codes.d.ts +9 -0
  12. package/dist/application-spec/errors/diagnostic-codes.js +1 -1
  13. package/dist/application-spec/errors/diagnostic-messages.d.ts +10 -1
  14. package/dist/application-spec/errors/diagnostic-messages.js +1 -1
  15. package/dist/application-spec/generate-application/code/domain-runtime-files.js +1 -1
  16. package/dist/application-spec/generate-application/code/templates/application-types.js +6 -6
  17. package/dist/application-spec/generate-application/code/templates/common/doc-comment.d.ts +2 -0
  18. package/dist/application-spec/generate-application/code/templates/common/doc-comment.js +1 -0
  19. package/dist/application-spec/generate-application/code/templates/configurator-globals.d.ts +2 -1
  20. package/dist/application-spec/generate-application/code/templates/configurator-globals.js +19 -6
  21. package/dist/application-spec/generate-application/code/templates/global-definition/generate-action-types.js +7 -7
  22. package/dist/application-spec/generate-application/code/templates/global-definition/generate-assistant-form-element-types.d.ts +12 -0
  23. package/dist/application-spec/generate-application/code/templates/global-definition/generate-assistant-form-element-types.js +7 -0
  24. package/dist/application-spec/generate-application/code/templates/global-definition/generate-phx-types.js +24 -22
  25. package/dist/application-spec/generate-application/code/templates/global-definition/index.d.ts +1 -0
  26. package/dist/application-spec/generate-application/code/templates/global-definition/index.js +1 -1
  27. package/dist/application-spec/generate-application/code/templates/skills/sdk-content.d.ts +13 -6
  28. package/dist/application-spec/generate-application/code/templates/skills/sdk-content.js +2 -2
  29. package/dist/application-spec/generate-application/implementations/phx-application-types.d.ts +25 -0
  30. package/dist/application-spec/generate-application/implementations/phx-application-types.js +1 -0
  31. package/dist/application-spec/generate-application/implementations/phx-domain.d.ts +36 -0
  32. package/dist/application-spec/generate-application/implementations/phx-domain.js +1 -0
  33. package/dist/application-spec/generate-application/implementations/phx-model.d.ts +24 -0
  34. package/dist/application-spec/generate-application/implementations/phx-model.js +1 -0
  35. package/dist/application-spec/generate-application/implementations/phx.d.ts +22 -0
  36. package/dist/application-spec/generate-application/implementations/phx.js +1 -0
  37. package/dist/application-spec/generate-application/index.d.ts +2 -0
  38. package/dist/application-spec/generate-application/index.js +1 -1
  39. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-model-runtime.d.ts +3 -3
  40. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-runtime.d.ts +7 -4
  41. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-runtime.js +1 -1
  42. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-spec-runtime.d.ts +16 -0
  43. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-spec-runtime.js +1 -0
  44. package/dist/application-spec/generate-application/runtime/application-runtime.d.ts +17 -4
  45. package/dist/application-spec/generate-application/runtime/application-runtime.js +1 -1
  46. package/dist/application-spec/generate-application/runtime/application-spec-slice.d.ts +71 -0
  47. package/dist/application-spec/generate-application/runtime/application-spec-slice.js +1 -0
  48. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-model-runtime.d.ts +3 -3
  49. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-runtime.d.ts +7 -5
  50. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-runtime.js +1 -1
  51. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-spec-runtime.d.ts +16 -0
  52. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-spec-runtime.js +1 -0
  53. package/dist/application-spec/generate-application/runtime/create-application.d.ts +57 -0
  54. package/dist/application-spec/generate-application/runtime/create-application.js +1 -0
  55. package/dist/application-spec/generate-application/runtime/index.d.ts +8 -0
  56. package/dist/application-spec/generate-application/runtime/index.js +1 -0
  57. package/dist/application-spec/generate-application/runtime/sections/expression-runtime.d.ts +9 -3
  58. package/dist/application-spec/generate-application/runtime/sections/expression-runtime.js +1 -1
  59. package/dist/application-spec/generate-application/runtime/spec-runtime.d.ts +21 -0
  60. package/dist/application-spec/generate-application/runtime/spec-runtime.js +1 -0
  61. package/dist/application-spec/generate-application/runtime-spec/encoded-runtime-spec.d.ts +3 -2
  62. package/dist/application-spec/generate-application/runtime-spec/runtime-spec.d.ts +44 -9
  63. package/dist/application-spec/generate-application/runtime-spec/runtime-spec.js +1 -1
  64. package/dist/application-spec/parse-application-files/raw-spec/raw-assistant.d.ts +33 -3
  65. package/dist/application-spec/parse-application-files/raw-spec/raw-assistant.js +1 -1
  66. package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-form-elements.d.ts +9 -0
  67. package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-form-elements.js +1 -0
  68. package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-schema.d.ts +4 -1
  69. package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-schema.js +1 -1
  70. package/dist/configurator/ConfiguratorApp.js +1 -1
  71. package/dist/configurator/app-loader-content/index.d.ts +0 -1
  72. package/dist/configurator/app-loader-content/index.js +1 -1
  73. package/dist/configurator/application-runtime/extensions/selectors.js +1 -1
  74. package/dist/configurator/application-runtime/extensions/systems/assistant-system/assistant-system.d.ts +4 -3
  75. package/dist/configurator/application-runtime/extensions/systems/assistant-system/assistant-system.js +1 -1
  76. package/dist/configurator/application-runtime/extensions/systems/assistant-system/graphql-tools.d.ts +33 -7
  77. package/dist/configurator/application-runtime/extensions/systems/assistant-system/graphql-tools.js +1 -1
  78. package/dist/configurator/application-runtime/extensions/systems/canvas-system.d.ts +8 -0
  79. package/dist/configurator/application-runtime/extensions/systems/canvas-system.js +1 -1
  80. package/dist/configurator/application-runtime/extensions/systems/export-system/export-busy-error.d.ts +8 -0
  81. package/dist/configurator/application-runtime/extensions/systems/export-system/export-busy-error.js +1 -0
  82. package/dist/configurator/application-runtime/extensions/systems/export-system/export-system.d.ts +23 -1
  83. package/dist/configurator/application-runtime/extensions/systems/export-system/export-system.js +1 -1
  84. package/dist/configurator/application-runtime/extensions/systems/graphql-system/entity-meta.d.ts +14 -1
  85. package/dist/configurator/application-runtime/extensions/systems/graphql-system/entity-meta.js +1 -1
  86. package/dist/configurator/application-runtime/extensions/systems/graphql-system/graphql-system.d.ts +45 -4
  87. package/dist/configurator/application-runtime/extensions/systems/graphql-system/graphql-system.js +1 -1
  88. package/dist/configurator/application-runtime/extensions/systems/image-rendering-system/image-rendering-system.d.ts +14 -5
  89. package/dist/configurator/application-runtime/extensions/systems/image-rendering-system/image-rendering-system.js +1 -1
  90. package/dist/configurator/application-runtime/extensions/systems/index.d.ts +1 -1
  91. package/dist/configurator/common/configurator-interface.d.ts +40 -6
  92. package/dist/configurator/common/configurator-interface.js +1 -1
  93. package/dist/configurator/configuration-not-found-page/BuiltInConfigurationNotFoundPage.d.ts +5 -0
  94. package/dist/configurator/configuration-not-found-page/BuiltInConfigurationNotFoundPage.js +1 -0
  95. package/dist/configurator/configuration-not-found-page/ConfigurationNotFoundPageElement.d.ts +0 -6
  96. package/dist/configurator/configuration-not-found-page/ConfigurationNotFoundPageElement.js +1 -1
  97. package/dist/configurator/configuration-not-found-page/index.d.ts +2 -2
  98. package/dist/configurator/configuration-not-found-page/index.js +1 -1
  99. package/dist/configurator/configurator-context/ConfiguratorResponsiveProvider.d.ts +3 -3
  100. package/dist/configurator/configurator-context/ConfiguratorResponsiveProvider.js +1 -1
  101. package/dist/configurator/configurator-context/PreviewContextProvider.js +1 -1
  102. package/dist/configurator/configurator-context/configurator-hooks/layout-hooks.d.ts +4 -0
  103. package/dist/configurator/configurator-context/configurator-routing/ConfiguratorRoutingProvider.js +1 -1
  104. package/dist/configurator/configurator-error-page/BuiltInConfiguratorErrorPage.d.ts +3 -3
  105. package/dist/configurator/configurator-error-page/BuiltInConfiguratorErrorPage.js +1 -1
  106. package/dist/configurator/configurator-error-page/ConfiguratorErrorPageElement.d.ts +0 -5
  107. package/dist/configurator/configurator-error-page/ConfiguratorErrorPageElement.js +1 -1
  108. package/dist/configurator/configurator-error-page/index.d.ts +1 -2
  109. package/dist/configurator/configurator-error-page/index.js +1 -1
  110. package/dist/configurator/index.d.ts +9 -2
  111. package/dist/configurator/index.js +1 -1
  112. package/dist/configurator/pages/default-configurator-elements.d.ts +3 -2
  113. package/dist/configurator/pages/default-configurator-elements.js +1 -1
  114. package/dist/configurator/pages/default-elements/index.d.ts +1 -0
  115. package/dist/configurator/pages/default-elements/index.js +1 -1
  116. package/dist/configurator/pages/default-elements/scene-controls/BuiltInAddEntityFab.d.ts +3 -0
  117. package/dist/configurator/pages/default-elements/scene-controls/BuiltInAddEntityFab.js +1 -0
  118. package/dist/configurator/pages/default-elements/scene-controls/BuiltInDeleteEntityFab.d.ts +3 -0
  119. package/dist/configurator/pages/default-elements/scene-controls/BuiltInDeleteEntityFab.js +1 -0
  120. package/dist/configurator/pages/default-elements/scene-controls/BuiltInNavigationHotspot.d.ts +3 -0
  121. package/dist/configurator/pages/default-elements/scene-controls/BuiltInNavigationHotspot.js +1 -0
  122. package/dist/configurator/pages/default-elements/scene-controls/BuiltInSelectionHotspot.d.ts +3 -0
  123. package/dist/configurator/pages/default-elements/scene-controls/BuiltInSelectionHotspot.js +1 -0
  124. package/dist/configurator/pages/default-elements/scene-controls/BuiltInToolMenuFab.d.ts +3 -0
  125. package/dist/configurator/pages/default-elements/scene-controls/BuiltInToolMenuFab.js +1 -0
  126. package/dist/configurator/pages/default-elements/scene-controls/index.d.ts +6 -0
  127. package/dist/configurator/pages/default-elements/scene-controls/index.js +1 -0
  128. package/dist/configurator/pages/default-elements/scene-controls/scene-control-elements.d.ts +6 -0
  129. package/dist/configurator/pages/default-elements/scene-controls/scene-control-elements.js +1 -0
  130. package/dist/configurator/pages/error-boundaries/LoadingErrorBoundary.js +1 -1
  131. package/dist/configurator/scene/index.d.ts +1 -0
  132. package/dist/configurator/scene/index.js +1 -1
  133. package/dist/configurator/scene/scene-controls/ToolFab.d.ts +4 -0
  134. package/dist/configurator/scene/scene-controls/ToolFab.js +1 -1
  135. package/dist/configurator/scene/scene-controls/use-scene-control-tooltip.d.ts +2 -1
  136. package/dist/configurator/scene/scene-entity/scene-controls/SceneEntityHtmlHotspots.js +1 -1
  137. package/dist/configurator/toolbar-actions/index.d.ts +1 -0
  138. package/dist/configurator/toolbar-actions/index.js +1 -1
  139. package/dist/configurator/toolbar-actions/use-toolbar-actions.d.ts +100 -0
  140. package/dist/configurator/toolbar-actions/use-toolbar-actions.js +1 -0
  141. package/dist/configurator/toolbar-actions/use-toolbar-help.d.ts +7 -0
  142. package/dist/configurator/toolbar-actions/use-toolbar-help.js +1 -0
  143. package/dist/configurator/ui/assistant/AssistantChips.d.ts +3 -2
  144. package/dist/configurator/ui/assistant/AssistantChips.js +1 -1
  145. package/dist/configurator/ui/assistant/AssistantMarkdown.d.ts +1 -1
  146. package/dist/configurator/ui/assistant/AssistantMarkdown.js +1 -1
  147. package/dist/configurator/ui/assistant/alert/AssistantAlert.d.ts +8 -0
  148. package/dist/configurator/ui/assistant/alert/AssistantAlert.js +1 -0
  149. package/dist/configurator/ui/assistant/alert/index.d.ts +2 -0
  150. package/dist/configurator/ui/assistant/alert/index.js +1 -0
  151. package/dist/configurator/ui/assistant/alert/remark-alerts.d.ts +10 -0
  152. package/dist/configurator/ui/assistant/alert/remark-alerts.js +1 -0
  153. package/dist/configurator/ui/assistant/form/AssistantForm.js +1 -1
  154. package/dist/configurator/ui/assistant/form/compose-answer.d.ts +1 -1
  155. package/dist/configurator/ui/assistant/form/compose-answer.js +1 -1
  156. package/dist/configurator/ui/assistant/form/form-model.d.ts +3 -4
  157. package/dist/configurator/ui/assistant/form/form-state.d.ts +1 -1
  158. package/dist/configurator/ui/assistant/form/index.d.ts +2 -0
  159. package/dist/configurator/ui/assistant/form/index.js +1 -1
  160. package/dist/configurator/ui/assistant/form/parse-form-block.d.ts +3 -2
  161. package/dist/configurator/ui/assistant/form/parse-form-block.js +1 -1
  162. package/dist/configurator/ui/assistant/form/read-form-attributes.d.ts +1 -1
  163. package/dist/configurator/ui/assistant/form/read-form-attributes.js +1 -1
  164. package/dist/configurator/ui/assistant/form/registry/built-in-form-elements.d.ts +3 -0
  165. package/dist/configurator/ui/assistant/form/registry/built-in-form-elements.js +1 -0
  166. package/dist/configurator/ui/assistant/form/registry/create-assistant-form-element.d.ts +31 -0
  167. package/dist/configurator/ui/assistant/form/registry/create-assistant-form-element.js +1 -0
  168. package/dist/configurator/ui/assistant/form/{form-element-definition.d.ts → registry/form-element-definition.d.ts} +3 -1
  169. package/dist/configurator/ui/assistant/form/registry/form-element-definition.js +1 -0
  170. package/dist/configurator/ui/assistant/form/{form-registry.d.ts → registry/form-registry.d.ts} +2 -2
  171. package/dist/configurator/ui/assistant/form/registry/form-registry.js +1 -0
  172. package/dist/configurator/ui/assistant/form/registry/index.d.ts +6 -0
  173. package/dist/configurator/ui/assistant/form/registry/index.js +1 -0
  174. package/dist/configurator/ui/assistant/form/registry/use-form-registry.d.ts +8 -0
  175. package/dist/configurator/ui/assistant/form/registry/use-form-registry.js +1 -0
  176. package/dist/configurator/ui/assistant/form-elements/FormCard.d.ts +9 -0
  177. package/dist/configurator/ui/assistant/form-elements/FormCard.js +1 -0
  178. package/dist/configurator/ui/assistant/form-elements/FormConfirm.d.ts +9 -0
  179. package/dist/configurator/ui/assistant/form-elements/FormConfirm.js +1 -0
  180. package/dist/configurator/ui/assistant/form-elements/FormGroup.d.ts +4 -1
  181. package/dist/configurator/ui/assistant/form-elements/FormNumber.d.ts +9 -0
  182. package/dist/configurator/ui/assistant/form-elements/FormNumber.js +1 -0
  183. package/dist/configurator/ui/assistant/form-elements/FormOptions.d.ts +4 -1
  184. package/dist/configurator/ui/assistant/form-elements/FormReply.d.ts +4 -1
  185. package/dist/configurator/ui/assistant/form-elements/FormReply.js +1 -1
  186. package/dist/configurator/ui/assistant/form-elements/FormSwatch.d.ts +10 -0
  187. package/dist/configurator/ui/assistant/form-elements/FormSwatch.js +1 -0
  188. package/dist/configurator/ui/assistant/form-elements/FormText.d.ts +4 -1
  189. package/dist/configurator/ui/assistant/form-elements/FormText.js +1 -1
  190. package/dist/configurator/ui/assistant/form-elements/clamp-form-number.d.ts +10 -0
  191. package/dist/configurator/ui/assistant/form-elements/clamp-form-number.js +1 -0
  192. package/dist/configurator/ui/assistant/form-elements/form-element-props.d.ts +20 -16
  193. package/dist/configurator/ui/assistant/form-elements/index.d.ts +4 -0
  194. package/dist/configurator/ui/assistant/form-elements/index.js +1 -1
  195. package/dist/configurator/ui/assistant/index.d.ts +3 -0
  196. package/dist/configurator/ui/assistant/index.js +1 -1
  197. package/dist/configurator/ui/assistant/resource-image/index.d.ts +1 -1
  198. package/dist/configurator/ui/assistant/resource-image/index.js +1 -1
  199. package/dist/configurator/ui/assistant/resource-image/resource-path.d.ts +0 -9
  200. package/dist/configurator/ui/assistant/resource-image/resource-path.js +1 -1
  201. package/dist/configurator/ui/assistant/screenshots/ScreenshotThumbnails.d.ts +9 -0
  202. package/dist/configurator/ui/assistant/screenshots/ScreenshotThumbnails.js +1 -0
  203. package/dist/configurator/ui/assistant/screenshots/index.d.ts +2 -0
  204. package/dist/configurator/ui/assistant/screenshots/index.js +1 -0
  205. package/dist/configurator/ui/assistant/screenshots/tool-screenshots.d.ts +14 -0
  206. package/dist/configurator/ui/assistant/screenshots/tool-screenshots.js +1 -0
  207. package/dist/configurator/ui/assistant/streamed-markdown.d.ts +2 -2
  208. package/dist/configurator/ui/assistant/streamed-markdown.js +2 -2
  209. package/dist/configurator/ui/assistant/tool-chip.js +1 -1
  210. package/dist/configurator/ui/configurator-layout/slots/canvas/LayoutCanvas.js +1 -1
  211. package/dist/configurator/ui/configurator-layout/slots/canvas/SharedCanvas.js +1 -1
  212. package/dist/configurator/ui/configurator-layout/universal/ExportingOverlay.d.ts +5 -0
  213. package/dist/configurator/ui/configurator-layout/universal/ExportingOverlay.js +1 -0
  214. package/dist/configurator/ui/configurator-layout/universal/Layout.js +1 -1
  215. package/dist/configurator/ui/configurator-layout/universal/use-layout-scene-safe-area.js +1 -1
  216. package/dist/configurator/ui/index.d.ts +1 -0
  217. package/dist/configurator/ui/index.js +1 -1
  218. package/dist/configurator/ui/legacy/layout/common/ReactiveInterfaceLayout.js +1 -1
  219. package/dist/configurator/ui/phoenix-layout-elements/AppLoader.d.ts +0 -7
  220. package/dist/configurator/ui/phoenix-layout-elements/AppLoader.js +2 -2
  221. package/dist/configurator/ui/phoenix-layout-elements/index.d.ts +8 -1
  222. package/dist/configurator/ui/phoenix-layout-elements/index.js +1 -1
  223. package/dist/configurator/ui/preset-layout/PresetUi.d.ts +0 -2
  224. package/dist/configurator/ui/preset-layout/PresetUi.js +1 -1
  225. package/dist/configurator/ui/preset-layout/index.d.ts +6 -1
  226. package/dist/configurator/ui/preset-layout/index.js +1 -1
  227. package/dist/configurator/ui/save-message-snackbars/SaveMessageSnackbars.js +1 -1
  228. package/dist/docs/complex-use-cases/focus-mode.mdx +2 -0
  229. package/dist/docs/fabs/basic.mdx +4 -0
  230. package/dist/docs/index.json +95 -1
  231. package/dist/docs/integrations/webcomponent-api/basics.mdx +2 -0
  232. package/dist/docs/layouts/custom-layout.mdx +320 -158
  233. package/dist/docs/layouts/gallery/ardent-compressor.mdx +3516 -0
  234. package/dist/docs/layouts/gallery/corvid-drone.mdx +3428 -0
  235. package/dist/docs/layouts/gallery/fenwick-window.mdx +2803 -0
  236. package/dist/docs/layouts/gallery/ferrule-bar-station.mdx +6567 -0
  237. package/dist/docs/layouts/gallery/kestrel-kettle.mdx +1858 -0
  238. package/dist/docs/layouts/gallery/nightjar-guitar.mdx +3698 -0
  239. package/dist/docs/layouts/gallery/sunfold-solar.mdx +2683 -0
  240. package/dist/docs/layouts/gallery/tidepool-aquascape.mdx +5203 -0
  241. package/dist/docs/other-features/client-side-rendering/declarative.mdx +1 -1
  242. package/dist/docs/other-features/navigation-hotspot.mdx +2 -0
  243. package/dist/docs/phoenix/configurator-interface.mdx +72 -14
  244. package/dist/docs/phoenix/sdk-builder-workflow.mdx +1 -1
  245. package/dist/docs/phoenix/sessions-and-access.mdx +2 -0
  246. package/dist/docs/selection-and-highlight/hotspots.mdx +2 -0
  247. package/dist/domain/application/assistant/assistant-manifest.d.ts +16 -1
  248. package/dist/domain/application/assistant/assistant-resolved-manifest.d.ts +8 -1
  249. package/dist/domain/application/assistant/build-assistant-manifest.js +1 -1
  250. package/dist/domain/application/assistant/form/assistant-form-element-name.d.ts +11 -0
  251. package/dist/domain/application/assistant/form/assistant-form-element-name.js +8 -0
  252. package/dist/domain/application/assistant/form/assistant-form-elements.d.ts +174 -0
  253. package/dist/domain/application/assistant/form/assistant-form-elements.js +1 -0
  254. package/dist/domain/application/assistant/form/assistant-resource-path.d.ts +10 -0
  255. package/dist/domain/application/assistant/form/assistant-resource-path.js +1 -0
  256. package/dist/domain/application/assistant/form/index.d.ts +4 -0
  257. package/dist/domain/application/assistant/form/index.js +1 -0
  258. package/dist/domain/application/assistant/generate-assistant-prompt.d.ts +3 -0
  259. package/dist/domain/application/assistant/generate-assistant-prompt.js +1 -1
  260. package/dist/domain/application/assistant/index.d.ts +2 -3
  261. package/dist/domain/application/assistant/index.js +1 -1
  262. package/dist/domain/application/assistant/session-setup/apply-assistant-setup.js +1 -1
  263. package/dist/domain/application/assistant/session-setup/assistant-setup-manifest.d.ts +2 -0
  264. package/dist/domain/application/assistant/session-setup/assistant-setup-manifest.js +1 -1
  265. package/dist/domain/application/configurator-builder.d.ts +7 -1
  266. package/dist/domain/application/configurator-builder.js +1 -1
  267. package/dist/domain/application/index.d.ts +1 -1
  268. package/dist/domain/application/index.js +1 -1
  269. package/dist/domain/application/standard-properties.js +1 -1
  270. package/dist/domain/application/web-component-property.d.ts +3 -1
  271. package/dist/domain/application/web-component-property.js +1 -1
  272. package/dist/domain/available-system-components.d.ts +1 -1
  273. package/dist/domain/available-system-components.js +1 -1
  274. package/dist/domain/domain-builder.d.ts +9 -3
  275. package/dist/domain/domain-builder.js +1 -1
  276. package/dist/domain/domain-context/store-system/store-system.d.ts +5 -0
  277. package/dist/domain/domain-context/store-system/store-system.js +1 -1
  278. package/dist/domain/domain-runtime/application.d.ts +39 -0
  279. package/dist/domain/domain-runtime/application.js +1 -0
  280. package/dist/domain/domain-runtime/domain-context/selectors.d.ts +1 -0
  281. package/dist/domain/domain-runtime/domain-context/selectors.js +1 -1
  282. package/dist/domain/domain-runtime/domain-runtime.d.ts +44 -4
  283. package/dist/domain/domain-runtime/domain-runtime.js +1 -1
  284. package/dist/domain/domain-runtime/environment/initialize.d.ts +1 -1
  285. package/dist/domain/domain-runtime/environment/types.d.ts +1 -1
  286. package/dist/domain/domain-runtime/errors/implementation-error-collector.d.ts +2 -0
  287. package/dist/domain/domain-runtime/errors/implementation-error-collector.js +1 -1
  288. package/dist/domain/domain-runtime/index.d.ts +2 -1
  289. package/dist/domain/domain-runtime/index.js +1 -1
  290. package/dist/domain/domain-runtime/spec/index.d.ts +1 -1
  291. package/dist/domain/domain-runtime/spec/index.js +1 -1
  292. package/dist/domain/domain-runtime/spec/initialize.js +1 -1
  293. package/dist/domain/domain-runtime/spec/module-checks/element-registration.d.ts +15 -1
  294. package/dist/domain/domain-runtime/spec/module-checks/element-registration.js +1 -1
  295. package/dist/domain/domain-runtime/spec/module-checks/find-assistant-form-element-errors.d.ts +12 -0
  296. package/dist/domain/domain-runtime/spec/module-checks/find-assistant-form-element-errors.js +1 -0
  297. package/dist/domain/domain-runtime/spec/module-checks/index.d.ts +3 -1
  298. package/dist/domain/domain-runtime/spec/module-checks/index.js +1 -1
  299. package/dist/domain/domain-runtime/spec/module-checks/merge-expression-modules.d.ts +23 -0
  300. package/dist/domain/domain-runtime/spec/module-checks/merge-expression-modules.js +1 -0
  301. package/dist/domain/domain-runtime/spec/module-checks/resolve-expression-modules.d.ts +3 -4
  302. package/dist/domain/domain-runtime/spec/module-checks/resolve-expression-modules.js +1 -1
  303. package/dist/domain/domain-runtime/spec/selectors.d.ts +3 -1
  304. package/dist/domain/domain-runtime/spec/selectors.js +1 -1
  305. package/dist/domain/domain-runtime/web-component/actions.d.ts +3 -0
  306. package/dist/domain/domain-runtime/web-component/actions.js +1 -1
  307. package/dist/domain/domain-runtime/web-component/initialize.d.ts +1 -0
  308. package/dist/domain/domain-runtime/web-component/initialize.js +1 -1
  309. package/dist/domain/domain-runtime/web-component/selectors.d.ts +2 -0
  310. package/dist/domain/domain-runtime/web-component/selectors.js +1 -1
  311. package/dist/domain/domain-runtime/web-component/types.d.ts +6 -3
  312. package/dist/domain/extensions/export-images/agent-screenshot.d.ts +24 -0
  313. package/dist/domain/extensions/export-images/agent-screenshot.js +1 -0
  314. package/dist/domain/extensions/{client-side-image.d.ts → export-images/client-side-image.d.ts} +2 -2
  315. package/dist/domain/extensions/export-images/client-side-image.js +1 -0
  316. package/dist/domain/extensions/export-images/index.d.ts +3 -0
  317. package/dist/domain/extensions/export-images/index.js +1 -0
  318. package/dist/domain/extensions/{save-template-thumbnail.d.ts → export-images/save-template-thumbnail.d.ts} +2 -2
  319. package/dist/domain/extensions/export-images/save-template-thumbnail.js +1 -0
  320. package/dist/domain/extensions/index.d.ts +1 -2
  321. package/dist/domain/extensions/index.js +1 -1
  322. package/dist/domain/mocks/create-mock-domain-runtime.d.ts +9 -0
  323. package/dist/editor/domain-editors/DomainSpecContext.js +1 -1
  324. package/dist/editor/domain-editors/apply-spec-update.d.ts +10 -2
  325. package/dist/editor/domain-editors/apply-spec-update.js +1 -1
  326. package/dist/editor/domain-editors/use-implementation-files.d.ts +6 -14
  327. package/dist/editor/domain-editors/use-implementation-files.js +1 -1
  328. package/dist/editor/screenshot/ScreenshotHandler.js +1 -1
  329. package/dist/i18n/locales/cs/messages.js +1 -1
  330. package/dist/i18n/locales/da/messages.js +1 -1
  331. package/dist/i18n/locales/de/messages.js +1 -1
  332. package/dist/i18n/locales/de-AT/messages.js +1 -1
  333. package/dist/i18n/locales/en/messages.js +1 -1
  334. package/dist/i18n/locales/en-AU/messages.js +1 -1
  335. package/dist/i18n/locales/en-GB/messages.js +1 -1
  336. package/dist/i18n/locales/es/messages.js +1 -1
  337. package/dist/i18n/locales/fr/messages.js +1 -1
  338. package/dist/i18n/locales/hu/messages.js +1 -1
  339. package/dist/i18n/locales/it/messages.js +1 -1
  340. package/dist/i18n/locales/nl/messages.js +1 -1
  341. package/dist/i18n/locales/pl/messages.js +1 -1
  342. package/dist/i18n/locales/pt/messages.js +1 -1
  343. package/dist/i18n/locales/ro/messages.js +1 -1
  344. package/dist/i18n/locales/sk/messages.js +1 -1
  345. package/dist/i18n/locales/tr/messages.js +1 -1
  346. package/dist/legacy/domain-cli/utils/cache.d.ts +1 -1
  347. package/dist/legacy/domain-cli/utils/cache.js +1 -1
  348. package/dist/legacy/domain-spec/__generated/domain-schema.d.ts +16 -0
  349. package/dist/legacy/domain-spec/spec/domain-spec.js +1 -1
  350. package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/claude-md.d.ts +1 -1
  351. package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/cursor-rules.d.ts +1 -1
  352. package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/shared-sdk-content.d.ts +1 -1
  353. package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/shared-sdk-content.js +11 -5
  354. package/dist/react-sdk/application-runtime/common-services/analytics/analytics-service.d.ts +1 -1
  355. package/dist/react-sdk/application-runtime/common-services/initialize.d.ts +2 -2
  356. package/dist/react-sdk/application-runtime/hooks/index.d.ts +2 -0
  357. package/dist/react-sdk/application-runtime/hooks/index.js +1 -1
  358. package/dist/react-sdk/application-runtime/hooks/loading/index.d.ts +5 -0
  359. package/dist/react-sdk/application-runtime/hooks/loading/index.js +1 -0
  360. package/dist/react-sdk/application-runtime/hooks/loading/loader-host-context.d.ts +11 -0
  361. package/dist/react-sdk/application-runtime/hooks/loading/loader-host-context.js +1 -0
  362. package/dist/react-sdk/application-runtime/hooks/loading/use-is-awaiting-loading-phase.d.ts +8 -0
  363. package/dist/react-sdk/application-runtime/hooks/loading/use-is-awaiting-loading-phase.js +1 -0
  364. package/dist/react-sdk/application-runtime/hooks/loading/use-is-loader-hosted.d.ts +7 -0
  365. package/dist/react-sdk/application-runtime/hooks/loading/use-is-loader-hosted.js +1 -0
  366. package/dist/react-sdk/application-runtime/hooks/loading/use-loading-actions.d.ts +9 -0
  367. package/dist/react-sdk/application-runtime/hooks/loading/use-loading-actions.js +1 -0
  368. package/dist/react-sdk/application-runtime/hooks/loading/use-loading-application.d.ts +24 -0
  369. package/dist/react-sdk/application-runtime/hooks/loading/use-loading-application.js +1 -0
  370. package/dist/react-sdk/application-runtime/hooks/responsive/index.d.ts +3 -0
  371. package/dist/react-sdk/application-runtime/hooks/responsive/index.js +1 -0
  372. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-actions.d.ts +9 -0
  373. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-actions.js +1 -0
  374. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-application.d.ts +26 -0
  375. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-application.js +1 -0
  376. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-state.d.ts +10 -0
  377. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-state.js +1 -0
  378. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-store.d.ts +24 -0
  379. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-store.js +1 -0
  380. package/dist/react-sdk/application-runtime/hooks/use-access-service.d.ts +5 -0
  381. package/dist/react-sdk/application-runtime/hooks/use-access-service.js +1 -1
  382. package/dist/react-sdk/application-runtime/hooks/use-phoenix-element-slots.d.ts +2 -1
  383. package/dist/react-sdk/application-runtime/hooks/use-web-component-readonly-sync.d.ts +4 -11
  384. package/dist/react-sdk/application-runtime/hooks/use-web-component-readonly-sync.js +1 -1
  385. package/dist/react-sdk/application-runtime/hooks/use-web-component.d.ts +5 -0
  386. package/dist/react-sdk/application-runtime/hooks/use-web-component.js +1 -1
  387. package/dist/react-sdk/application-runtime/host-properties/actions.d.ts +15 -0
  388. package/dist/react-sdk/application-runtime/host-properties/actions.js +1 -0
  389. package/dist/react-sdk/application-runtime/host-properties/connect-host-properties.d.ts +30 -0
  390. package/dist/react-sdk/application-runtime/host-properties/connect-host-properties.js +1 -0
  391. package/dist/react-sdk/application-runtime/host-properties/domain-host-properties.d.ts +7 -0
  392. package/dist/react-sdk/application-runtime/host-properties/domain-host-properties.js +1 -0
  393. package/dist/react-sdk/application-runtime/host-properties/host-property-descriptors.d.ts +21 -0
  394. package/dist/react-sdk/application-runtime/host-properties/host-property-descriptors.js +1 -0
  395. package/dist/react-sdk/application-runtime/host-properties/host-property-scopes.d.ts +42 -0
  396. package/dist/react-sdk/application-runtime/host-properties/host-property-scopes.js +1 -0
  397. package/dist/react-sdk/application-runtime/host-properties/index.d.ts +8 -0
  398. package/dist/react-sdk/application-runtime/host-properties/index.js +1 -0
  399. package/dist/react-sdk/application-runtime/host-properties/initialize.d.ts +7 -0
  400. package/dist/react-sdk/application-runtime/host-properties/initialize.js +1 -0
  401. package/dist/react-sdk/application-runtime/host-properties/selectors.d.ts +54 -0
  402. package/dist/react-sdk/application-runtime/host-properties/selectors.js +1 -0
  403. package/dist/react-sdk/application-runtime/host-properties/types.d.ts +18 -0
  404. package/dist/react-sdk/application-runtime/index.d.ts +3 -0
  405. package/dist/react-sdk/application-runtime/integration/initialize.d.ts +7 -6
  406. package/dist/react-sdk/application-runtime/integration/initialize.js +1 -1
  407. package/dist/react-sdk/application-runtime/integration/selectors.d.ts +11 -2
  408. package/dist/react-sdk/application-runtime/integration/selectors.js +1 -1
  409. package/dist/react-sdk/application-runtime/integration/types.d.ts +5 -2
  410. package/dist/react-sdk/application-runtime/loading/actions.d.ts +45 -0
  411. package/dist/react-sdk/application-runtime/loading/actions.js +1 -0
  412. package/dist/react-sdk/application-runtime/loading/index.d.ts +4 -0
  413. package/dist/react-sdk/application-runtime/loading/index.js +1 -0
  414. package/dist/react-sdk/application-runtime/loading/initialize.d.ts +3 -0
  415. package/dist/react-sdk/application-runtime/loading/initialize.js +1 -0
  416. package/dist/react-sdk/application-runtime/loading/selectors.d.ts +18 -0
  417. package/dist/react-sdk/application-runtime/loading/selectors.js +1 -0
  418. package/dist/react-sdk/application-runtime/loading/types.d.ts +59 -0
  419. package/dist/react-sdk/application-runtime/mocks/create-mock-application-runtime.js +1 -1
  420. package/dist/react-sdk/application-runtime/provider/ApplicationRuntime.d.ts +6 -0
  421. package/dist/react-sdk/application-runtime/provider/ApplicationRuntime.js +1 -1
  422. package/dist/react-sdk/application-runtime/provider/ApplicationType.d.ts +7 -0
  423. package/dist/react-sdk/application-runtime/provider/ApplicationType.js +1 -1
  424. package/dist/react-sdk/application-runtime/responsive/actions.d.ts +47 -0
  425. package/dist/react-sdk/application-runtime/responsive/actions.js +1 -0
  426. package/dist/react-sdk/application-runtime/responsive/index.d.ts +4 -0
  427. package/dist/react-sdk/application-runtime/responsive/index.js +1 -0
  428. package/dist/react-sdk/application-runtime/responsive/initialize.d.ts +3 -0
  429. package/dist/react-sdk/application-runtime/responsive/initialize.js +1 -0
  430. package/dist/react-sdk/application-runtime/responsive/selectors.d.ts +20 -0
  431. package/dist/react-sdk/application-runtime/responsive/selectors.js +1 -0
  432. package/dist/react-sdk/application-runtime/responsive/types.d.ts +104 -0
  433. package/dist/react-sdk/application-runtime/routing/routing-service.d.ts +34 -2
  434. package/dist/react-sdk/application-runtime/routing/routing-service.js +1 -1
  435. package/dist/react-sdk/application-runtime/runtime/create-application-runtime.d.ts +313 -10
  436. package/dist/react-sdk/application-runtime/runtime/create-application-runtime.js +1 -1
  437. package/dist/react-sdk/domain-application-context/BackendDataProvider.js +1 -1
  438. package/dist/react-sdk/domain-application-context/FrontendDataProvider.js +1 -1
  439. package/dist/react-sdk/domain-application-context/PublishedDataProvider.js +1 -1
  440. package/dist/react-sdk/domain-context/common-hooks/index.d.ts +1 -0
  441. package/dist/react-sdk/domain-context/common-hooks/index.js +1 -1
  442. package/dist/react-sdk/domain-context/common-hooks/use-configuration-save-state.d.ts +23 -0
  443. package/dist/react-sdk/domain-context/common-hooks/use-configuration-save-state.js +1 -0
  444. package/dist/react-sdk/domain-context/common-hooks/use-domain-context.d.ts +1 -0
  445. package/dist/react-sdk/domain-context/index.d.ts +1 -0
  446. package/dist/react-sdk/domain-context/index.js +1 -1
  447. package/dist/react-sdk/domain-context/system-hooks/undo-redo-hooks.js +1 -1
  448. package/dist/react-sdk/elements/index.d.ts +0 -1
  449. package/dist/react-sdk/index.d.ts +2 -1
  450. package/dist/react-sdk/index.js +1 -1
  451. package/dist/react-sdk/loading-state/LoaderHost.d.ts +20 -0
  452. package/dist/react-sdk/loading-state/LoaderHost.js +1 -0
  453. package/dist/react-sdk/loading-state/built-in-loading-phase.d.ts +9 -0
  454. package/dist/react-sdk/loading-state/built-in-loading-phase.js +1 -0
  455. package/dist/react-sdk/loading-state/create-app-loader.d.ts +18 -0
  456. package/dist/react-sdk/loading-state/create-app-loader.js +1 -0
  457. package/dist/react-sdk/loading-state/index.d.ts +4 -2
  458. package/dist/react-sdk/loading-state/index.js +1 -1
  459. package/dist/react-sdk/loading-state/{LoadingStateProvider.d.ts → loading-state-hooks.d.ts} +3 -12
  460. package/dist/react-sdk/loading-state/loading-state-hooks.js +1 -0
  461. package/dist/react-sdk/page-elements/ConfigurationNotFoundPagePropsProvider.d.ts +17 -0
  462. package/dist/react-sdk/page-elements/ConfigurationNotFoundPagePropsProvider.js +1 -0
  463. package/dist/react-sdk/page-elements/ConfiguratorErrorPagePropsProvider.d.ts +17 -0
  464. package/dist/react-sdk/page-elements/ConfiguratorErrorPagePropsProvider.js +1 -0
  465. package/dist/react-sdk/page-elements/configurator-error-page-props.js +1 -0
  466. package/dist/{configurator/configuration-not-found-page → react-sdk/page-elements}/create-configuration-not-found-page.d.ts +7 -1
  467. package/dist/react-sdk/page-elements/create-configuration-not-found-page.js +1 -0
  468. package/dist/{configurator/configurator-error-page → react-sdk/page-elements}/create-configurator-error-page.d.ts +6 -1
  469. package/dist/react-sdk/page-elements/create-configurator-error-page.js +1 -0
  470. package/dist/react-sdk/page-elements/create-preset-ui.d.ts +9 -0
  471. package/dist/react-sdk/page-elements/create-preset-ui.js +1 -0
  472. package/dist/react-sdk/page-elements/index.d.ts +6 -0
  473. package/dist/react-sdk/page-elements/index.js +1 -0
  474. package/dist/react-sdk/scene-control-elements/AddEntityFabElement.d.ts +36 -0
  475. package/dist/react-sdk/scene-control-elements/AddEntityFabElement.js +1 -0
  476. package/dist/react-sdk/scene-control-elements/DeleteEntityFabElement.d.ts +44 -0
  477. package/dist/react-sdk/scene-control-elements/DeleteEntityFabElement.js +1 -0
  478. package/dist/react-sdk/scene-control-elements/NavigationHotspotElement.d.ts +38 -0
  479. package/dist/react-sdk/scene-control-elements/NavigationHotspotElement.js +1 -0
  480. package/dist/react-sdk/scene-control-elements/SelectionHotspotElement.d.ts +30 -0
  481. package/dist/react-sdk/scene-control-elements/SelectionHotspotElement.js +1 -0
  482. package/dist/react-sdk/scene-control-elements/ToolMenuFabElement.d.ts +30 -0
  483. package/dist/react-sdk/scene-control-elements/ToolMenuFabElement.js +1 -0
  484. package/dist/react-sdk/scene-control-elements/create-add-entity-fab.d.ts +9 -0
  485. package/dist/react-sdk/scene-control-elements/create-add-entity-fab.js +1 -0
  486. package/dist/react-sdk/scene-control-elements/create-delete-entity-fab.d.ts +9 -0
  487. package/dist/react-sdk/scene-control-elements/create-delete-entity-fab.js +1 -0
  488. package/dist/react-sdk/scene-control-elements/create-navigation-hotspot.d.ts +10 -0
  489. package/dist/react-sdk/scene-control-elements/create-navigation-hotspot.js +1 -0
  490. package/dist/react-sdk/scene-control-elements/create-selection-hotspot.d.ts +9 -0
  491. package/dist/react-sdk/scene-control-elements/create-selection-hotspot.js +1 -0
  492. package/dist/react-sdk/scene-control-elements/create-tool-menu-fab.d.ts +9 -0
  493. package/dist/react-sdk/scene-control-elements/create-tool-menu-fab.js +1 -0
  494. package/dist/react-sdk/scene-control-elements/index.d.ts +11 -0
  495. package/dist/react-sdk/scene-control-elements/index.js +1 -0
  496. package/dist/react-sdk/scene-control-elements/scene-control-tooltip-visibility.d.ts +5 -0
  497. package/dist/react-sdk/scene-control-elements/scene-control-tooltip-visibility.js +1 -0
  498. package/dist/react-sdk/session/SessionServiceProvider.js +1 -1
  499. package/dist/react-sdk/web-component/create-web-component.js +1 -1
  500. package/dist/react-sdk/web-component/god-mode/god-mode-registry.d.ts +30 -0
  501. package/dist/react-sdk/web-component/god-mode/god-mode-registry.js +1 -0
  502. package/dist/react-sdk/web-component/god-mode/index.d.ts +1 -0
  503. package/dist/react-sdk/web-component/god-mode/index.js +1 -0
  504. package/dist/ui-sdk/icons/__generated/Lightbulb.d.ts +3 -0
  505. package/dist/ui-sdk/icons/__generated/Lightbulb.js +1 -0
  506. package/dist/ui-sdk/icons/__generated/XCircle.d.ts +3 -0
  507. package/dist/ui-sdk/icons/__generated/XCircle.js +1 -0
  508. package/dist/ui-sdk/icons/__generated/index.d.ts +4 -0
  509. package/dist/ui-sdk/icons/__generated/index.js +1 -1
  510. package/dist/ui-sdk/icons/icons-registry.js +1 -1
  511. package/dist/ui-sdk/responsive-application/ResponsiveApplicationProvider.d.ts +2 -2
  512. package/dist/ui-sdk/responsive-application/responsive-state-store.d.ts +11 -9
  513. package/dist/ui-sdk/responsive-application/use-store-responsive-info.js +1 -1
  514. package/dist/ui-sdk/theme/types.d.ts +5 -1
  515. package/package.json +13 -3
  516. package/schema/domain-spec.json +140 -0
  517. package/dist/configurator/app-loader-content/app-loader-content-name.d.ts +0 -5
  518. package/dist/configurator/app-loader-content/app-loader-content-name.js +0 -1
  519. package/dist/configurator/configuration-not-found-page/create-configuration-not-found-page.js +0 -1
  520. package/dist/configurator/configurator-error-page/create-configurator-error-page.js +0 -1
  521. package/dist/configurator/ui/assistant/form/built-in-form-registry.d.ts +0 -10
  522. package/dist/configurator/ui/assistant/form/built-in-form-registry.js +0 -1
  523. package/dist/configurator/ui/assistant/form/form-registry.js +0 -8
  524. package/dist/domain/application/assistant/assistant-form-elements.d.ts +0 -28
  525. package/dist/domain/application/assistant/assistant-form-elements.js +0 -1
  526. package/dist/domain/extensions/client-side-image.js +0 -1
  527. package/dist/domain/extensions/save-template-thumbnail.js +0 -1
  528. package/dist/react-sdk/application-runtime/runtime/god-mode.d.ts +0 -19
  529. package/dist/react-sdk/application-runtime/runtime/god-mode.js +0 -1
  530. package/dist/react-sdk/loading-state/LoadingStateProvider.js +0 -1
  531. package/dist/react-sdk/loading-state/loading-state-manager.d.ts +0 -37
  532. package/dist/react-sdk/loading-state/loading-state-manager.js +0 -1
  533. package/dist/react-sdk/responsive-state/ResponsiveStateProvider.d.ts +0 -25
  534. package/dist/react-sdk/responsive-state/ResponsiveStateProvider.js +0 -1
  535. package/dist/react-sdk/responsive-state/canvas-padding.d.ts +0 -14
  536. package/dist/react-sdk/responsive-state/index.d.ts +0 -5
  537. package/dist/react-sdk/responsive-state/index.js +0 -1
  538. package/dist/react-sdk/responsive-state/responsive-state-manager.d.ts +0 -111
  539. package/dist/react-sdk/responsive-state/responsive-state-manager.js +0 -1
  540. package/dist/react-sdk/responsive-state/use-responsive-state-manager.d.ts +0 -10
  541. package/dist/react-sdk/responsive-state/use-responsive-state-manager.js +0 -1
  542. package/dist/react-sdk/responsive-state/use-responsive-state.d.ts +0 -11
  543. package/dist/react-sdk/responsive-state/use-responsive-state.js +0 -1
  544. /package/dist/{configurator/ui/assistant/form/form-element-definition.js → react-sdk/application-runtime/host-properties/types.js} +0 -0
  545. /package/dist/react-sdk/{elements/configurator-error-page-props.js → application-runtime/loading/types.js} +0 -0
  546. /package/dist/react-sdk/{responsive-state/canvas-padding.js → application-runtime/responsive/types.js} +0 -0
  547. /package/dist/react-sdk/{elements → page-elements}/configurator-error-page-props.d.ts +0 -0
@@ -0,0 +1,3516 @@
1
+ # Ardent Compressor
2
+
3
+ Quote an engineered product from a form, with no 3D model at all. An industrial compressor maker, Ardent Air, lets plant engineers size a rotary screw compressor and ask for a firm quote. The engineer works through five steps: the air demand, the air treatment, the plant room, service and delivery, and a review. Questions appear only when an earlier answer makes them relevant, conflicts between choices are flagged at the field that can fix them, and a price breakdown with every line, the subtotal and an express-delivery surcharge follows each change. **Request quote** sends the configuration to an application engineer and shows the answer. The configurator mounts no canvas: the spec holds the product, the prices, the rules and the quote action, and the layout is an ordinary React form.
4
+
5
+ - [Try it](#try-it)
6
+ - [What the example demonstrates](#what-the-example-demonstrates)
7
+ - [A configurator with no scene](#a-configurator-with-no-scene)
8
+ - [Steps from the spec](#steps-from-the-spec)
9
+ - [Questions that depend on earlier answers](#questions-that-depend-on-earlier-answers)
10
+ - [Options that follow the configuration](#options-that-follow-the-configuration)
11
+ - [Validation from invariants](#validation-from-invariants)
12
+ - [A live price breakdown](#a-live-price-breakdown)
13
+ - [Request a quote](#request-a-quote)
14
+ - [On a phone](#on-a-phone)
15
+ - [Test cases](#test-cases)
16
+ - [What this layout leaves out](#what-this-layout-leaves-out)
17
+ - [Related pages](#related-pages)
18
+ - [Complete Implementation](#complete-implementation)
19
+
20
+ ## Try it
21
+
22
+ The phone version is under [On a phone](#on-a-phone).
23
+
24
+ ## What the example demonstrates
25
+
26
+ [Custom Layout](?path=/docs/layouts-custom-layout--docs) explains how a project layout works. This page uses the same element for a product that has no model: the configuration backend, with its spec, rules, storage and finalize actions, runs a form.
27
+
28
+ | Piece | Where |
29
+ | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
30
+ | A `ConfiguratorLayout` that mounts no `LayoutSlots.SharedCanvas` | [A configurator with no scene](#a-configurator-with-no-scene) |
31
+ | `navigationSteps` with `phx.frontend.useStepNavigation()` | `StepHeader`, [Steps from the spec](#steps-from-the-spec) |
32
+ | `$filter.active` with `$eval`, and `phx.frontend.useComponent()` to follow it | `ChoiceField`, [Questions that depend on earlier answers](#questions-that-depend-on-earlier-answers) |
33
+ | `attributeSelection` options read from an expression | [Options that follow the configuration](#options-that-follow-the-configuration) |
34
+ | `warning` invariants shown at the field, read with `phx.frontend.useFinalizeViolations()` | `useWarnings`, `FieldShell`, [Validation from invariants](#validation-from-invariants) |
35
+ | Typed expressions declared in `expressions:` and read with `phx.frontend.useRootEntityState()` | `PriceBreakdown`, [A live price breakdown](#a-live-price-breakdown) |
36
+ | `genericDimension`, `attributeSwitch` and `textValue` controls in the layout's own inputs | `NumberField`, `SwitchField`, `TextInputField` |
37
+ | `phx.frontend.useFinalizeActions()` with a project handler that stands in for a webhook | `QuoteAction`, `request-quote.ts`, [Request a quote](#request-a-quote) |
38
+ | `phx.frontend.useUndoRedo()` in the brand bar | `DesktopPage` |
39
+
40
+ ## A configurator with no scene
41
+
42
+ `src/elements/ConfiguratorLayout.tsx` mounts the services and a page, and never `LayoutSlots.SharedCanvas`. Without a canvas the app loader leaves as soon as the configuration is up, nothing waits for a scene, and no model, environment map or texture is downloaded, even when the spec names one; see [Configurator Interface → App Loader](?path=/docs/phoenix-configurator-interface--docs#app-loader). The spec needs no camera and no scene component.
43
+
44
+ ```tsx
45
+ import { Box } from '@mui/material'
46
+
47
+ import { LayoutSlots } from '@salsita-ai/sdk/configurator'
48
+
49
+ import { phx } from '../../.phoenix/phx.js'
50
+ import { ARDENT } from '../_form/index.js'
51
+ import { DesktopPage } from '../_shared/DesktopPage.js'
52
+ import { PhonePage } from '../_shared/PhonePage.js'
53
+
54
+ // A quote builder with no scene: the layout mounts the services and a page, never `LayoutSlots.SharedCanvas`, so the
55
+ // configurator loads no 3D scene and the app loader does not wait for one. Desktop or phone follows the width of the
56
+ // configurator's container.
57
+ const ConfiguratorLayout = () => {
58
+ // `null` until the container has been measured
59
+ const viewports = phx.frontend.useActiveViewports()
60
+ const isDesktop = viewports?.includes('desktop') ?? false
61
+
62
+ return (
63
+ <Box component="div" sx={{ position: 'absolute', inset: 0, overflow: 'clip', bgcolor: ARDENT.page }}>
64
+ <LayoutSlots.Services />
65
+ {viewports !== null && (isDesktop ? <DesktopPage /> : <PhonePage />)}
66
+ </Box>
67
+ )
68
+ }
69
+
70
+ export default phx.frontend.createCustomPhoenixLayoutElement('ConfiguratorLayout', ConfiguratorLayout)
71
+ ```
72
+
73
+ `phx.frontend.useActiveViewports()` measures the configurator's container, not the window, so the **Phone** story's 390-pixel frame shows the phone page. Finalizing works the same without a canvas, as long as the spec asks for no client-side images: an image capture waits for a canvas and fails after 10 seconds without one.
74
+
75
+ ## Steps from the spec
76
+
77
+ The steps are the spec's `navigationSteps`. Each step names the navigation state it enters; the last one also holds `requested`, the state the quote handler enters, so the review stays the active step after the request.
78
+
79
+ ```yaml
80
+ navigationStates:
81
+ - duty
82
+ - treatment
83
+ - installation
84
+ - service
85
+ - review
86
+ - requested
87
+ ```
88
+
89
+ ```yaml
90
+ components:
91
+ Compressor:
92
+ navigationSteps:
93
+ main:
94
+ steps:
95
+ - label: Duty
96
+ navigationStates:
97
+ - duty
98
+ - label: Air treatment
99
+ navigationStates:
100
+ - treatment
101
+ - label: Installation
102
+ navigationStates:
103
+ - installation
104
+ - label: Service and delivery
105
+ navigationStates:
106
+ - service
107
+ - label: Review
108
+ navigationStates:
109
+ - review
110
+ - requested
111
+ ```
112
+
113
+ `StepHeader` draws them with `phx.frontend.useStepNavigation()`: each step has a `label`, a `status` of `complete`, `active` or `upcoming`, and `goTo(index)` enters it. The hook enforces no order, so the engineer can jump back to any step from the review. A step that holds a conflict carries a mark until it is resolved; which step resolves which invariant is the layout's own map, `STEP_WARNINGS`. `StepForm` renders the active step's sections by switching on `phx.frontend.useActiveNavigationState()`, and the page's **Back** and **Next** buttons call `previous()` and `next()`.
114
+
115
+ ```tsx
116
+ import { Box, ButtonBase, Typography } from '@mui/material'
117
+
118
+ import { phx } from '../../.phoenix/phx.js'
119
+ import { ARDENT, useWarnings } from '../_form/index.js'
120
+ import { STEP_WARNINGS } from './step-warnings.js'
121
+
122
+ const CHECK_ICON = 'M5 12.5 10 17.5 19 7'
123
+
124
+ interface StepHeaderProps {
125
+ readonly compact: boolean
126
+ }
127
+
128
+ // The spec's `navigationSteps` as a stepper. Every step can be entered, in any order; a step that holds a warning
129
+ // carries a mark until the warning is resolved. On a phone the stepper is one line with a segmented progress bar.
130
+ export const StepHeader = ({ compact }: StepHeaderProps) => {
131
+ const { steps, activeStep, goTo } = phx.frontend.useStepNavigation()
132
+ const warnings = useWarnings()
133
+
134
+ const hasWarning = (states: readonly string[]) => {
135
+ return states.some(state => warnings.some(warning => STEP_WARNINGS[state]?.includes(warning.type)))
136
+ }
137
+
138
+ if (compact) {
139
+ return (
140
+ <Box
141
+ component="nav"
142
+ aria-label="Quote steps"
143
+ sx={{ display: 'grid', gap: 1, paddingInline: 2, paddingBlock: 1.5 }}
144
+ >
145
+ <Box component="div" sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
146
+ <Typography sx={{ fontFamily: ARDENT.font, fontSize: 13, color: ARDENT.steel }}>
147
+ Step {(activeStep?.index ?? 0) + 1} of {steps.length}
148
+ </Typography>
149
+ <Typography component="h2" sx={{ fontFamily: ARDENT.font, fontSize: 17, fontWeight: 600, color: ARDENT.ink }}>
150
+ {activeStep?.label}
151
+ </Typography>
152
+ </Box>
153
+ <Box component="ol" sx={{ display: 'flex', gap: 0.5, margin: 0, padding: 0, listStyle: 'none' }}>
154
+ {steps.map(step => (
155
+ <Box component="li" key={step.state} sx={{ flex: 1 }}>
156
+ <ButtonBase
157
+ aria-label={`${step.label}${hasWarning(step.navigationStates) ? ', needs attention' : ''}`}
158
+ aria-current={step.status === 'active' ? 'step' : undefined}
159
+ onClick={() => goTo(step.index)}
160
+ sx={{
161
+ display: 'block',
162
+ inlineSize: '100%',
163
+ blockSize: 6,
164
+ borderRadius: 3,
165
+ // A bigger target than the bar itself, for a finger
166
+ outline: '8px solid transparent',
167
+ bgcolor: hasWarning(step.navigationStates)
168
+ ? ARDENT.warning
169
+ : step.status === 'upcoming'
170
+ ? ARDENT.line
171
+ : ARDENT.signal,
172
+ }}
173
+ />
174
+ </Box>
175
+ ))}
176
+ </Box>
177
+ </Box>
178
+ )
179
+ }
180
+
181
+ return (
182
+ <Box
183
+ component="nav"
184
+ aria-label="Quote steps"
185
+ sx={{ display: 'flex', alignItems: 'center', gap: 1, paddingInline: 4, paddingBlock: 1.5, overflowX: 'auto' }}
186
+ >
187
+ {steps.map((step, index) => {
188
+ const warn = hasWarning(step.navigationStates)
189
+ const isActive = step.status === 'active'
190
+ const isComplete = step.status === 'complete'
191
+
192
+ return (
193
+ <Box component="div" key={step.state} sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 'none' }}>
194
+ {index > 0 && (
195
+ <Box
196
+ component="span"
197
+ aria-hidden
198
+ sx={{ inlineSize: 32, blockSize: 2, bgcolor: isComplete || isActive ? ARDENT.signal : ARDENT.line }}
199
+ />
200
+ )}
201
+ <ButtonBase
202
+ aria-current={isActive ? 'step' : undefined}
203
+ onClick={() => goTo(step.index)}
204
+ sx={{
205
+ display: 'flex',
206
+ alignItems: 'center',
207
+ gap: 1,
208
+ paddingInline: 1,
209
+ paddingBlock: 0.75,
210
+ borderRadius: 2,
211
+ '&:hover': { bgcolor: ARDENT.page },
212
+ '&.Mui-focusVisible': { outline: `2px solid ${ARDENT.navy}` },
213
+ }}
214
+ >
215
+ <Box
216
+ component="span"
217
+ sx={{
218
+ display: 'grid',
219
+ placeItems: 'center',
220
+ inlineSize: 28,
221
+ blockSize: 28,
222
+ borderRadius: '50%',
223
+ fontFamily: ARDENT.font,
224
+ fontSize: 14,
225
+ fontWeight: 600,
226
+ color: isActive || isComplete ? ARDENT.panel : ARDENT.steel,
227
+ bgcolor: isActive ? ARDENT.signal : isComplete ? ARDENT.navy : 'transparent',
228
+ border: isActive || isComplete ? 'none' : `2px solid ${ARDENT.line}`,
229
+ }}
230
+ >
231
+ {isComplete ? (
232
+ <Box component="svg" viewBox="0 0 24 24" aria-hidden sx={{ inlineSize: 16, blockSize: 16 }}>
233
+ <path d={CHECK_ICON} fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" />
234
+ </Box>
235
+ ) : (
236
+ index + 1
237
+ )}
238
+ </Box>
239
+ <Typography
240
+ component="span"
241
+ sx={{
242
+ fontFamily: ARDENT.font,
243
+ fontSize: 15,
244
+ fontWeight: isActive ? 600 : 500,
245
+ color: isActive ? ARDENT.ink : ARDENT.steel,
246
+ whiteSpace: 'nowrap',
247
+ }}
248
+ >
249
+ {step.label}
250
+ </Typography>
251
+ {warn && (
252
+ <Box
253
+ component="span"
254
+ role="img"
255
+ aria-label="needs attention"
256
+ sx={{ inlineSize: 8, blockSize: 8, borderRadius: '50%', bgcolor: ARDENT.warning }}
257
+ />
258
+ )}
259
+ </ButtonBase>
260
+ </Box>
261
+ )
262
+ })}
263
+ </Box>
264
+ )
265
+ }
266
+ ```
267
+
268
+ ## Questions that depend on earlier answers
269
+
270
+ A question that only makes sense after an earlier answer is a spec component with a filter. The dew point is asked only with a desiccant dryer, and heat recovery only for a water-cooled machine:
271
+
272
+ ```yaml
273
+ components:
274
+ Compressor:
275
+ attributeSelection:
276
+ dewPoint:
277
+ value:
278
+ $bind: dewPoint
279
+ options:
280
+ - label: −40 °C
281
+ description: Outdoor lines, food and pharma
282
+ value: Minus40
283
+ - label: −70 °C
284
+ description: Electronics and laboratories
285
+ value: Minus70
286
+ $filter:
287
+ active:
288
+ $eval: self.dryer === 'Desiccant'
289
+ attributeSwitch:
290
+ heatRecovery:
291
+ value:
292
+ $bind: heatRecovery
293
+ $filter:
294
+ active:
295
+ $eval: self.cooling === 'Water'
296
+ ```
297
+
298
+ The layout lists every field of a step and lets the spec decide which exist. A control hook such as `phx.frontend.useAttributeSelection()` throws when its component is not active, so `ChoiceField` first asks `phx.frontend.useComponent()` for the component and mounts the cards only when it is there. `SwitchField` does the same for `attributeSwitch`. The value of a hidden question stays in the configuration, so the expressions and the summary read it only where the question applies, such as the dew point of a desiccant dryer.
299
+
300
+ ```tsx
301
+ import { Box, ButtonBase, Typography } from '@mui/material'
302
+ import type { KeyboardEvent } from 'react'
303
+
304
+ import { attributeSelectionComponent } from '@salsita-ai/sdk/domain'
305
+
306
+ import { phx } from '../../.phoenix/phx.js'
307
+ import { FieldShell } from './FieldShell.js'
308
+ import { ARDENT } from './tokens.js'
309
+
310
+ // Arrow keys move the selection between the cards, as in a native radio group
311
+ const ARROW_STEPS: Readonly<Record<string, number | undefined>> = {
312
+ ArrowRight: 1,
313
+ ArrowDown: 1,
314
+ ArrowLeft: -1,
315
+ ArrowUp: -1,
316
+ }
317
+
318
+ interface ChoiceFieldProps {
319
+ readonly instanceName: string
320
+ readonly label: string
321
+ readonly hint?: string
322
+ readonly warningTypes?: readonly string[]
323
+ readonly readOnly: boolean
324
+ // The narrowest a card gets before the cards wrap onto another row
325
+ readonly minCardWidth?: number
326
+ }
327
+
328
+ const ChoiceCards = ({ instanceName, label, hint, warningTypes, readOnly, minCardWidth = 150 }: ChoiceFieldProps) => {
329
+ const resolve = phx.frontend.useResolveLocalizable()
330
+ const compressorId = phx.frontend.useRootEntityId()
331
+ const { value, options } = phx.frontend.useAttributeSelection(compressorId, instanceName)
332
+ // An option a `fixable` or `fatal` invariant rules out is `disabled`, with the reason in its `hint`
333
+ const optionHint = options.find(option => option.disabled && option.hint)?.hint
334
+
335
+ const handleKeyDown = (event: KeyboardEvent<HTMLElement>, index: number) => {
336
+ const step = ARROW_STEPS[event.key]
337
+
338
+ if (step === undefined) {
339
+ return
340
+ }
341
+
342
+ event.preventDefault()
343
+ const next = (index + step + options.length) % options.length
344
+ options[next]?.onSelect()
345
+ event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="radio"]')[next]?.focus()
346
+ }
347
+
348
+ return (
349
+ <FieldShell id={`ardent-${instanceName}`} label={label} hint={optionHint ?? hint} warningTypes={warningTypes}>
350
+ <Box
351
+ component="div"
352
+ role="radiogroup"
353
+ aria-labelledby={`ardent-${instanceName}-label`}
354
+ sx={{ display: 'grid', gridTemplateColumns: `repeat(auto-fit, minmax(${minCardWidth}px, 1fr))`, gap: 1.25 }}
355
+ >
356
+ {options.map((option, index) => {
357
+ const isSelected = option.value === value
358
+ const description = resolve(option.description)
359
+
360
+ return (
361
+ <ButtonBase
362
+ key={String(option.value)}
363
+ role="radio"
364
+ aria-checked={isSelected}
365
+ aria-disabled={option.disabled}
366
+ tabIndex={isSelected ? 0 : -1}
367
+ disabled={readOnly && !isSelected}
368
+ onClick={readOnly ? undefined : option.onSelect}
369
+ onKeyDown={readOnly ? undefined : event => handleKeyDown(event, index)}
370
+ sx={{
371
+ display: 'grid',
372
+ gridTemplateColumns: 'auto 1fr',
373
+ alignItems: 'start',
374
+ columnGap: 1.25,
375
+ rowGap: 0.25,
376
+ padding: 1.5,
377
+ textAlign: 'start',
378
+ borderRadius: 2,
379
+ border: `1px solid ${isSelected ? ARDENT.signal : ARDENT.line}`,
380
+ boxShadow: isSelected ? `inset 0 0 0 1px ${ARDENT.signal}` : 'none',
381
+ bgcolor: isSelected ? ARDENT.signalTint : ARDENT.panel,
382
+ fontFamily: ARDENT.font,
383
+ opacity: option.disabled ? 0.45 : 1,
384
+ transition: 'border-color 140ms, background-color 140ms',
385
+ '&:hover': { borderColor: isSelected ? ARDENT.signal : ARDENT.steel },
386
+ '&.Mui-focusVisible': { outline: `2px solid ${ARDENT.navy}`, outlineOffset: 2 },
387
+ '&.Mui-disabled': { opacity: 0.45 },
388
+ }}
389
+ >
390
+ {/* The radio mark: a ring, filled with a dot when the card is chosen */}
391
+ <Box
392
+ component="span"
393
+ aria-hidden
394
+ sx={{
395
+ gridRow: 'span 2',
396
+ marginBlockStart: 0.25,
397
+ inlineSize: 18,
398
+ blockSize: 18,
399
+ borderRadius: '50%',
400
+ border: `2px solid ${isSelected ? ARDENT.signal : ARDENT.steel}`,
401
+ boxShadow: isSelected ? `inset 0 0 0 3px ${ARDENT.signalTint}` : 'none',
402
+ bgcolor: isSelected ? ARDENT.signal : 'transparent',
403
+ }}
404
+ />
405
+ <Typography
406
+ component="span"
407
+ sx={{ fontFamily: ARDENT.font, fontSize: 15, fontWeight: 600, color: ARDENT.ink }}
408
+ >
409
+ {resolve(option.title)}
410
+ </Typography>
411
+ {description && (
412
+ <Typography
413
+ component="span"
414
+ sx={{ fontFamily: ARDENT.font, fontSize: 13, lineHeight: 1.35, color: ARDENT.steel }}
415
+ >
416
+ {description}
417
+ </Typography>
418
+ )}
419
+ </ButtonBase>
420
+ )
421
+ })}
422
+ </Box>
423
+ </FieldShell>
424
+ )
425
+ }
426
+
427
+ // One `attributeSelection` of the spec as a group of cards. The field exists only while the spec's component is
428
+ // active: a `$filter` in the spec decides when a question is asked, and the layout follows it. The control hook throws
429
+ // for an inactive component, so the cards mount only after `useComponent` has found it. An option that a `fixable`
430
+ // invariant rules out is faded with its hint below the cards; it stays clickable, the change is rejected and the
431
+ // built-in snackbar says why.
432
+ export const ChoiceField = (props: ChoiceFieldProps) => {
433
+ const compressorId = phx.frontend.useRootEntityId()
434
+ const component = phx.frontend.useComponent({
435
+ entityId: compressorId,
436
+ componentType: attributeSelectionComponent,
437
+ instanceName: props.instanceName,
438
+ })
439
+
440
+ return component === null ? null : <ChoiceCards {...props} />
441
+ }
442
+ ```
443
+
444
+ ## Options that follow the configuration
445
+
446
+ The delivery options name their lead time, and express delivery is offered only for the stock frames up to 110 kW. The options are an expression instead of a list:
447
+
448
+ ```yaml
449
+ components:
450
+ Compressor:
451
+ attributeSelection:
452
+ delivery:
453
+ value:
454
+ $bind: delivery
455
+ options:
456
+ $read: $deliveryOptions
457
+ ```
458
+
459
+ `$deliveryOptions` in `src/expressions/compressor.ts` returns `{ label, description, value }` entries, "Standard, 6 weeks" and "Express, 3 weeks" for the first configuration. When a larger frame drops the express option while it is chosen, no card is selected and the `expressUnavailable` warning below tells the engineer to choose again.
460
+
461
+ ## Validation from invariants
462
+
463
+ The rules between choices are invariants of the spec. Every one is a `warning`: a change that breaks it is kept, so the engineer sees the conflict and fixes it where it is shown, and finalizing waits until no warning is left. A `fixable` or `fatal` invariant would reject the change instead, as the [Kestrel Kettle](?path=/docs/layouts-gallery-kestrel-kettle--docs#a-rule-between-two-choices) shows. Two rules name numbers in their message, so they are declared with `types` and build the message in code.
464
+
465
+ ```yaml
466
+ invariants:
467
+ Compressor:
468
+ flowRange:
469
+ types:
470
+ - flowAboveRange
471
+ desiccantNeedsFiltration:
472
+ type: desiccantNeedsFiltration
473
+ severity: warning
474
+ message: A desiccant dryer needs high-efficiency or oil-vapour filtration upstream, or oil aerosol spoils the desiccant.
475
+ receiverSize:
476
+ types:
477
+ - receiverTooSmall
478
+ expressUnavailable:
479
+ type: expressUnavailable
480
+ severity: warning
481
+ message: Express delivery covers stock frames up to 110 kW. Choose standard delivery for this frame.
482
+ companyMissing:
483
+ type: companyMissing
484
+ severity: warning
485
+ message: Enter the company the quote is for.
486
+ emailInvalid:
487
+ type: emailInvalid
488
+ severity: warning
489
+ message: Enter a valid email address for the quote.
490
+ ```
491
+
492
+ ```ts
493
+ import { phx } from '../../.phoenix/phx.js'
494
+
495
+ const PRESSURE_LABEL = { Bar7: '7.5 bar', Bar10: '10 bar', Bar13: '13 bar' }
496
+ const RECEIVER_LITRES = { None: 0, Litres500: 500, Litres1000: 1000, Litres2000: 2000 }
497
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
498
+
499
+ // Each rule is a `warning` in the spec: a change that breaks it is kept, so the customer can fix the conflict where it
500
+ // is shown, and finalizing waits until no warning is left
501
+ export default phx.domain.implementCustomInvariants({
502
+ Compressor: {
503
+ // The largest frame delivers less air at a higher pressure, so the limit depends on an earlier choice
504
+ flowRange: s => {
505
+ if (s.flow <= s.$maxFlow) {
506
+ return []
507
+ }
508
+
509
+ return [
510
+ {
511
+ type: 'flowAboveRange',
512
+ severity: 'warning',
513
+ message: `At ${PRESSURE_LABEL[s.pressure]} the largest frame delivers ${s.$maxFlow.toFixed(1)} m³/min. Lower the flow or the pressure.`,
514
+ },
515
+ ]
516
+ },
517
+ desiccantNeedsFiltration: s => {
518
+ return s.dryer !== 'Desiccant' || s.filtration !== 'General'
519
+ },
520
+ // The message names the size the flow needs, so it is built here rather than in the spec
521
+ receiverSize: s => {
522
+ if (RECEIVER_LITRES[s.receiver] >= s.$minReceiverLitres) {
523
+ return []
524
+ }
525
+
526
+ return [
527
+ {
528
+ type: 'receiverTooSmall',
529
+ severity: 'warning',
530
+ message: `A fixed-speed compressor at ${s.flow.toFixed(1)} m³/min needs a receiver of at least ${s.$minReceiverLitres.toLocaleString('en')} L.`,
531
+ },
532
+ ]
533
+ },
534
+ expressUnavailable: s => {
535
+ return s.delivery !== 'Express' || s.$frameKw <= 110
536
+ },
537
+ companyMissing: s => {
538
+ return s.company.trim().length > 0
539
+ },
540
+ emailInvalid: s => {
541
+ return EMAIL.test(s.email.trim())
542
+ },
543
+ },
544
+ })
545
+ ```
546
+
547
+ The flow's limit depends on the pressure: at 13 bar the largest frame delivers less air. The `flow` dimension keeps a fixed range of 2 to 40 m³/min and the `flowAboveRange` warning carries the limit. A `max` read from an expression would not do: a pressure change that leaves the flow above a dimension's `max` is rejected with a built-in message, so the engineer could not choose 13 bar at all.
548
+
549
+ `useWarnings` resolves the warnings of the committed configuration. It reads them from `phx.frontend.useFinalizeViolations()`, which lists the violations that block finalizing; `phx.frontend.useStateValidity()` holds the same list, but on `phx.frontend` it is typed as the value it returns rather than as a hook, so a call does not type-check.
550
+
551
+ ```ts
552
+ import { useMemo } from 'react'
553
+
554
+ import { phx } from '../../.phoenix/phx.js'
555
+
556
+ export interface Warning {
557
+ readonly type: string
558
+ readonly text: string
559
+ }
560
+
561
+ // The warnings of the committed configuration with their text resolved. A `warning` invariant keeps the change, so its
562
+ // violation stays in the committed state until the customer resolves it, and finalizing waits for that.
563
+ // `useFinalizeViolations` lists them; `useStateValidity` holds the same list, but on `phx.frontend` it is typed as the
564
+ // validity value instead of a hook, so calling it does not type-check.
565
+ export const useWarnings = (): readonly Warning[] => {
566
+ const { violations } = phx.frontend.useFinalizeViolations()
567
+ const resolve = phx.frontend.useResolveLocalizable()
568
+
569
+ return useMemo(() => {
570
+ return (violations ?? []).map(({ violation }) => ({ type: violation.type, text: resolve(violation.message) ?? '' }))
571
+ }, [violations, resolve])
572
+ }
573
+ ```
574
+
575
+ `FieldShell` shows a field's warning below its input in place of the help text. A field names the types it can resolve, so the receiver field shows the receiver rule and the filter package the desiccant rule. The contact fields on the review step show theirs once the engineer has left the field, or after a request was attempted. The review step also lists every conflict left, each with a button to the step that resolves it.
576
+
577
+ ## A live price breakdown
578
+
579
+ Every number of the breakdown is an expression. Declaring them in `expressions:` types them for the layout: `phx.frontend.useRootEntityState()` returns the root entity with its expressions, so `$quoteLines` is an array of `{ key, label, detail, amount }`. An expression that is only inferred from where the spec reads it gets the type of that place, which can be wider than the layout wants.
580
+
581
+ ```yaml
582
+ expressions:
583
+ Compressor:
584
+ $leadTimeWeeks:
585
+ type: number
586
+ $quoteLines:
587
+ type: array
588
+ items:
589
+ type: object
590
+ properties:
591
+ key:
592
+ type: string
593
+ label:
594
+ type: string
595
+ detail:
596
+ type: string
597
+ amount:
598
+ type: number
599
+ required:
600
+ - key
601
+ - label
602
+ - detail
603
+ - amount
604
+ additionalProperties: false
605
+ $subtotal:
606
+ type: number
607
+ $leadTimeSurcharge:
608
+ type: number
609
+ $total:
610
+ type: number
611
+ ```
612
+
613
+ `src/expressions/compressor.ts` picks the smallest frame that delivers the flow at the pressure and prices it, then adds a line for each option that costs extra: the variable speed drive, the dryer, the filters, water cooling and heat recovery, the receiver, the canopy, the 60 Hz build, the service plan and commissioning. Express delivery adds 8 % of the subtotal. The `price` component reads `$total`, so `phx.frontend.useRootEntityPrice()` formats the total in the component's currency:
614
+
615
+ ```yaml
616
+ components:
617
+ Compressor:
618
+ price:
619
+ main:
620
+ price:
621
+ $read: $total
622
+ currency: EUR
623
+ ```
624
+
625
+ ```tsx
626
+ import { Box, Typography } from '@mui/material'
627
+
628
+ import { phx } from '../../.phoenix/phx.js'
629
+ import { ARDENT, OVERLINE_SX } from '../_form/index.js'
630
+
631
+ const formatEuro = (amount: number) => {
632
+ return amount.toLocaleString('en-IE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })
633
+ }
634
+
635
+ const ROW_SX = {
636
+ display: 'grid',
637
+ gridTemplateColumns: '1fr auto',
638
+ columnGap: 2,
639
+ alignItems: 'baseline',
640
+ paddingBlock: 1,
641
+ } as const
642
+
643
+ // The quote's lines, subtotal, lead-time surcharge and total. Every number comes from an expression of the spec
644
+ // (`$quoteLines`, `$subtotal`, `$leadTimeSurcharge`, `$leadTimeWeeks`), read from the root entity, so the breakdown
645
+ // changes with each choice, and the total is the `price` component's value.
646
+ export const PriceBreakdown = () => {
647
+ const compressor = phx.frontend.useRootEntityState()
648
+ const total = phx.frontend.useRootEntityPrice()
649
+
650
+ return (
651
+ <Box component="section" aria-label="Price breakdown" sx={{ display: 'grid', fontFamily: ARDENT.font }}>
652
+ <Typography component="h2" sx={{ ...OVERLINE_SX, paddingBlockEnd: 1 }}>
653
+ Price breakdown
654
+ </Typography>
655
+
656
+ <Box component="ul" sx={{ margin: 0, padding: 0, listStyle: 'none' }}>
657
+ {compressor.$quoteLines.map(line => (
658
+ <Box component="li" key={line.key} sx={{ ...ROW_SX, borderBlockEnd: `1px solid ${ARDENT.line}` }}>
659
+ <Box component="div" sx={{ display: 'grid', minInlineSize: 0 }}>
660
+ <Typography
661
+ component="span"
662
+ sx={{ fontFamily: ARDENT.font, fontSize: 15, fontWeight: 500, color: ARDENT.ink }}
663
+ >
664
+ {line.label}
665
+ </Typography>
666
+ <Typography component="span" sx={{ fontFamily: ARDENT.font, fontSize: 13, color: ARDENT.steel }}>
667
+ {line.detail}
668
+ </Typography>
669
+ </Box>
670
+ <Typography
671
+ component="span"
672
+ sx={{ fontFamily: ARDENT.font, fontSize: 15, color: ARDENT.ink, fontVariantNumeric: 'tabular-nums' }}
673
+ >
674
+ {formatEuro(line.amount)}
675
+ </Typography>
676
+ </Box>
677
+ ))}
678
+ </Box>
679
+
680
+ <Box component="div" sx={{ ...ROW_SX, paddingBlockStart: 1.5 }}>
681
+ <Typography component="span" sx={{ fontFamily: ARDENT.font, fontSize: 15, color: ARDENT.steel }}>
682
+ Subtotal
683
+ </Typography>
684
+ <Typography
685
+ component="span"
686
+ sx={{ fontFamily: ARDENT.font, fontSize: 15, color: ARDENT.ink, fontVariantNumeric: 'tabular-nums' }}
687
+ >
688
+ {formatEuro(compressor.$subtotal)}
689
+ </Typography>
690
+ </Box>
691
+ {compressor.$leadTimeSurcharge > 0 && (
692
+ <Box component="div" sx={ROW_SX}>
693
+ <Typography component="span" sx={{ fontFamily: ARDENT.font, fontSize: 15, color: ARDENT.steel }}>
694
+ Express delivery surcharge
695
+ </Typography>
696
+ <Typography
697
+ component="span"
698
+ sx={{ fontFamily: ARDENT.font, fontSize: 15, color: ARDENT.ink, fontVariantNumeric: 'tabular-nums' }}
699
+ >
700
+ {formatEuro(compressor.$leadTimeSurcharge)}
701
+ </Typography>
702
+ </Box>
703
+ )}
704
+
705
+ <Box
706
+ component="div"
707
+ sx={{ ...ROW_SX, marginBlockStart: 1, paddingBlockStart: 1.5, borderBlockStart: `2px solid ${ARDENT.ink}` }}
708
+ >
709
+ <Typography component="span" sx={{ fontFamily: ARDENT.font, fontSize: 17, fontWeight: 600, color: ARDENT.ink }}>
710
+ Total, ex works
711
+ </Typography>
712
+ <Typography
713
+ component="span"
714
+ sx={{
715
+ fontFamily: ARDENT.font,
716
+ fontSize: 26,
717
+ fontWeight: 700,
718
+ color: ARDENT.ink,
719
+ fontVariantNumeric: 'tabular-nums',
720
+ }}
721
+ >
722
+ {total}
723
+ </Typography>
724
+ </Box>
725
+ <Typography sx={{ fontFamily: ARDENT.font, fontSize: 14, color: ARDENT.steel, textAlign: 'end' }}>
726
+ Lead time {compressor.$leadTimeWeeks} weeks · prices excl. VAT
727
+ </Typography>
728
+ </Box>
729
+ )
730
+ }
731
+ ```
732
+
733
+ On a desktop the breakdown is a column beside the form. On a phone the total sits in the bottom bar and opens the breakdown as a sheet.
734
+
735
+ ## Request a quote
736
+
737
+ The spec declares one finalize action. Its handler is project code registered with `phx.frontend.createPhoenixHandler`; in a deployed project it sends the configuration to the project's finalize webhook (see [Finalization](?path=/docs/phoenix-finalization--docs) and [Webhooks](?path=/docs/phoenix-webhooks--docs)), and a CPQ system behind that webhook prices and answers. Storybook has no server, so this handler waits and answers with a fixed engineer and a reference made from the configuration's id.
738
+
739
+ ```yaml
740
+ components:
741
+ Compressor:
742
+ finalizeAction:
743
+ quote:
744
+ variant: primary
745
+ label: Request quote
746
+ title: Send the configuration to an application engineer for a firm quote
747
+ icon: PaperPlaneTilt
748
+ handler: requestQuote
749
+ ```
750
+
751
+ ```ts
752
+ import { useCallback } from 'react'
753
+
754
+ import { phx } from '../../.phoenix/phx.js'
755
+ import { setQuoteReceipt } from '../_shared/quote-receipt.js'
756
+
757
+ const VALID_DAYS = 30
758
+
759
+ // Stands in for Ardent's quote service. A deployed project sends the configuration to its finalize webhook here and
760
+ // waits for the answer; this one answers after a moment with a fixed engineer and a reference made from the
761
+ // configuration's id, then enters the `requested` navigation state.
762
+ export default phx.frontend.createPhoenixHandler('requestQuote', () => {
763
+ const navigate = phx.frontend.useNavigate()
764
+ const configurationId = phx.frontend.useConfigurationId()
765
+ const total = phx.frontend.useRootEntityPrice()
766
+
767
+ return {
768
+ handler: useCallback(async () => {
769
+ await new Promise(resolve => setTimeout(resolve, 1600))
770
+
771
+ const validUntil = new Date(Date.now() + VALID_DAYS * 24 * 60 * 60 * 1000)
772
+
773
+ setQuoteReceipt({
774
+ reference: `AQ-${String(configurationId)
775
+ .replace(/[^a-z0-9]/gi, '')
776
+ .slice(-6)
777
+ .toUpperCase()}`,
778
+ engineer: 'Marta Kovács, application engineer',
779
+ validUntil: validUntil.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }),
780
+ total: total ?? '',
781
+ })
782
+ navigate('requested')
783
+ }, [configurationId, navigate, total]),
784
+ }
785
+ })
786
+ ```
787
+
788
+ A finalize action's `run()` resolves without a value, so the answer has no way back to the layout through the action. The handler keeps it in a small store of the project's own, which the review step reads:
789
+
790
+ ```ts
791
+ import { useSyncExternalStore } from 'react'
792
+
793
+ /** What the quote service answers when a quote is requested: the reference the engineer will quote against */
794
+ export interface QuoteReceipt {
795
+ readonly reference: string
796
+ readonly engineer: string
797
+ readonly validUntil: string
798
+ readonly total: string
799
+ }
800
+
801
+ let receipt: QuoteReceipt | null = null
802
+ const listeners = new Set<() => void>()
803
+
804
+ const subscribe = (listener: () => void) => {
805
+ listeners.add(listener)
806
+
807
+ return () => {
808
+ listeners.delete(listener)
809
+ }
810
+ }
811
+
812
+ // A finalize action's `run()` resolves without a value, so the `requestQuote` handler keeps the service's answer here
813
+ // for the layout to show. The project owns this store; the configurator does not persist it.
814
+ export const setQuoteReceipt = (next: QuoteReceipt | null) => {
815
+ receipt = next
816
+ listeners.forEach(listener => listener())
817
+ }
818
+
819
+ export const useQuoteReceipt = () => {
820
+ return useSyncExternalStore(subscribe, () => receipt)
821
+ }
822
+ ```
823
+
824
+ `QuoteAction` runs the action from `phx.frontend.useFinalizeActions().primary`. While a warning holds, the action is `disabled` and `hints` lists the warnings; the button stays clickable and, instead of running, reveals the warnings of the contact fields the engineer has not visited yet. During the request the button shows a spinner and "Sending to an engineer…"; afterwards the review step shows the reference, the engineer, the validity and the quoted total, and **Back to summary** returns to the `review` state. A failed request would show the handler's error below the button. A viewer of a shared configuration sees no button.
825
+
826
+ ```tsx
827
+ import { Alert, Box, Button, CircularProgress, Typography } from '@mui/material'
828
+
829
+ import { phx } from '../../.phoenix/phx.js'
830
+ import { ARDENT } from '../_form/index.js'
831
+
832
+ interface QuoteActionProps {
833
+ // Called instead of running the action while a warning blocks it, so the layout can show every warning
834
+ readonly onBlocked: () => void
835
+ // Leaves out the list of what blocks the request, for the phone's bottom bar
836
+ readonly compact: boolean
837
+ }
838
+
839
+ // The spec's `quote` finalize action. While any warning holds, `useFinalizeActions` reports the action `disabled` with
840
+ // the warnings as `hints`: the button stays clickable and reveals them instead of running. A viewer sees no button.
841
+ export const QuoteAction = ({ onBlocked, compact }: QuoteActionProps) => {
842
+ const { primary, hints } = phx.frontend.useFinalizeActions()
843
+ const { accessInfo } = phx.frontend.useAccessService()
844
+
845
+ if (primary === null || accessInfo.viewMode) {
846
+ return null
847
+ }
848
+
849
+ return (
850
+ <Box component="div" sx={{ display: 'grid', gap: 1.25 }}>
851
+ <Button
852
+ variant="contained"
853
+ disableElevation
854
+ disabled={primary.isPending}
855
+ aria-disabled={primary.disabled}
856
+ title={primary.title}
857
+ onClick={() => (primary.disabled ? onBlocked() : void primary.run())}
858
+ startIcon={primary.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
859
+ sx={{
860
+ minBlockSize: 52,
861
+ borderRadius: 2,
862
+ bgcolor: primary.disabled ? ARDENT.steel : ARDENT.signal,
863
+ color: ARDENT.panel,
864
+ fontFamily: ARDENT.font,
865
+ fontSize: 17,
866
+ fontWeight: 600,
867
+ textTransform: 'none',
868
+ '&:hover': { bgcolor: primary.disabled ? ARDENT.steel : ARDENT.signalDark },
869
+ '&.Mui-disabled': { bgcolor: ARDENT.signal, color: ARDENT.panel, opacity: 0.7 },
870
+ }}
871
+ >
872
+ {primary.isPending ? 'Sending to an engineer…' : primary.label}
873
+ </Button>
874
+ {!compact && primary.disabled && hints && (
875
+ <Typography sx={{ fontFamily: ARDENT.font, fontSize: 14, lineHeight: 1.4, color: ARDENT.warning }}>
876
+ Resolve before requesting: {hints}
877
+ </Typography>
878
+ )}
879
+ {primary.error && (
880
+ <Alert severity="error">The quote could not be sent: {primary.error.message} Try again in a moment.</Alert>
881
+ )}
882
+ </Box>
883
+ )
884
+ }
885
+ ```
886
+
887
+ ## On a phone
888
+
889
+ The same layout in a frame 390 pixels wide. The stepper is one line with a segmented bar, the form scrolls, and the bottom bar holds the total, the lead time and the way on: **Next** on every step, **Request quote** on the review. Tapping the total opens the breakdown above the bar. Opened on its own from the sidebar, the **Phone** story sets Storybook's viewport to 390 × 844.
890
+
891
+ ## Test cases
892
+
893
+ - The page opens on **Duty** with the RSV-75 frame selected for 9 m³/min at 10 bar. The breakdown lists the compressor, the refrigerant dryer, the 500 L receiver, the Essential plan and commissioning, a total of **€40,975.00** and a lead time of 6 weeks.
894
+ - Typing 30 into the flow selects the RSV-250 frame and the total rises to €111,640.00; the stepper marks **Installation**, where a fixed-speed machine at this flow needs a 1,200 L receiver.
895
+ - Choosing **13 bar** with a flow of 30 shows "At 13 bar the largest frame delivers 29.5 m³/min. Lower the flow or the pressure." under the flow and marks **Duty**; choosing 10 bar again removes it.
896
+ - **Variable speed** adds the "Variable speed drive" line and clears the receiver mark.
897
+ - On **Air treatment**, **Desiccant** makes **Pressure dew point** appear and shows the filtration warning under **Filter package**; **High efficiency** clears it; **Refrigerant** removes the dew point question again.
898
+ - On **Installation**, **Water-cooled** makes **Heat recovery** appear; turning it on adds the "Heat recovery" line.
899
+ - On **Service and delivery** with the RSV-250 frame, **Delivery** offers only standard delivery. With a flow of 12 it also offers express; choosing it adds the "Express delivery surcharge" and shortens the lead time.
900
+ - Raising the flow to 25 while express is chosen shows "Express delivery covers stock frames up to 110 kW. …" under **Delivery**.
901
+ - The **Review** step lists the conflict with **Go to Service and delivery**. **Request quote** reveals "Enter the company the quote is for." and "Enter a valid email address for the quote." under the empty contact fields and does not send.
902
+ - **Go to Service and delivery** and **Standard** delivery clear the conflict: the mark leaves the stepper and the list leaves the review.
903
+ - An email without a domain shows the email warning after leaving the field; a valid company and email clear both warnings and the text under **Request quote**.
904
+ - **Request quote** shows a spinner and "Sending to an engineer…" for about two seconds, then "Quote requested" with a reference, the engineer, the date the price is valid until and the quoted total. **Back to summary** returns to the contact form with its values.
905
+ - **Undo** reverts the last change and **Redo** repeats it.
906
+ - In the **Phone** story, **Next** walks through the steps, the total opens and closes the breakdown, **Request quote** on the review reveals the contact warnings, and a filled form shows "Quote requested".
907
+ - The browser console shows no errors, the page has no `canvas` element, and no model or texture is requested.
908
+
909
+ ## What this layout leaves out
910
+
911
+ - **Read mode.** The fields are inert and **Request quote** 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.
912
+ - **A quote that outlives the page.** The answer lives in the project's store for as long as the page; a configuration reopened in the `requested` state shows "Quote requested" without the details. A deployed project keeps the answer where its webhook stores 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
+ - [Fenwick Window](?path=/docs/layouts-gallery-fenwick-window--docs) draws the configuration as a shop drawing, and [Sunfold Solar](?path=/docs/layouts-gallery-sunfold-solar--docs) shows it as a dashboard; 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 → Invariants](?path=/docs/phoenix-domain-model--docs#invariants) and [Domain Model → Expressions](?path=/docs/phoenix-domain-model--docs#expressions) describe the rules and the computed values.
921
+ - [Finalization](?path=/docs/phoenix-finalization--docs) describes the server side of a finalize action.
922
+
923
+ ## Complete Implementation
924
+
925
+ {/* AUTO-GENERATED SECTION: This section is automatically updated by 'phoenix story build' */}
926
+
927
+ <details>
928
+ <summary>Click to view the complete implementation files</summary>
929
+
930
+ ### ./spec/domain.yaml
931
+
932
+ ```yaml
933
+ model:
934
+ latest:
935
+ entityTypes:
936
+ - Compressor
937
+ aliases:
938
+ RootEntity:
939
+ - Compressor
940
+ commonTypes:
941
+ Pressure:
942
+ enum:
943
+ - Bar7
944
+ - Bar10
945
+ - Bar13
946
+ DutyCycle:
947
+ enum:
948
+ - Intermittent
949
+ - Continuous
950
+ Drive:
951
+ enum:
952
+ - FixedSpeed
953
+ - VariableSpeed
954
+ Dryer:
955
+ enum:
956
+ - None
957
+ - Refrigerant
958
+ - Desiccant
959
+ DewPoint:
960
+ enum:
961
+ - Minus40
962
+ - Minus70
963
+ Filtration:
964
+ enum:
965
+ - General
966
+ - HighEfficiency
967
+ - OilVapour
968
+ Cooling:
969
+ enum:
970
+ - Air
971
+ - Water
972
+ Receiver:
973
+ enum:
974
+ - None
975
+ - Litres500
976
+ - Litres1000
977
+ - Litres2000
978
+ Enclosure:
979
+ enum:
980
+ - Standard
981
+ - LowNoise
982
+ Supply:
983
+ enum:
984
+ - V400Hz50
985
+ - V480Hz60
986
+ ServicePlan:
987
+ enum:
988
+ - None
989
+ - Essential
990
+ - TotalCare
991
+ Delivery:
992
+ enum:
993
+ - Standard
994
+ - Express
995
+ shapes:
996
+ Compressor:
997
+ flow:
998
+ type: number
999
+ pressure:
1000
+ $ref: '#/model/latest/commonTypes/Pressure'
1001
+ dutyCycle:
1002
+ $ref: '#/model/latest/commonTypes/DutyCycle'
1003
+ drive:
1004
+ $ref: '#/model/latest/commonTypes/Drive'
1005
+ dryer:
1006
+ $ref: '#/model/latest/commonTypes/Dryer'
1007
+ dewPoint:
1008
+ $ref: '#/model/latest/commonTypes/DewPoint'
1009
+ filtration:
1010
+ $ref: '#/model/latest/commonTypes/Filtration'
1011
+ cooling:
1012
+ $ref: '#/model/latest/commonTypes/Cooling'
1013
+ heatRecovery:
1014
+ type: boolean
1015
+ receiver:
1016
+ $ref: '#/model/latest/commonTypes/Receiver'
1017
+ enclosure:
1018
+ $ref: '#/model/latest/commonTypes/Enclosure'
1019
+ supply:
1020
+ $ref: '#/model/latest/commonTypes/Supply'
1021
+ servicePlan:
1022
+ $ref: '#/model/latest/commonTypes/ServicePlan'
1023
+ commissioning:
1024
+ type: boolean
1025
+ delivery:
1026
+ $ref: '#/model/latest/commonTypes/Delivery'
1027
+ company:
1028
+ type: string
1029
+ email:
1030
+ type: string
1031
+ projectReference:
1032
+ type: string
1033
+
1034
+ presets:
1035
+ - rootEntityType: Compressor
1036
+ initialDomainEntities:
1037
+ - id: Compressor-1
1038
+ type: Compressor
1039
+ flow: 9
1040
+ pressure: Bar10
1041
+ dutyCycle: Intermittent
1042
+ drive: FixedSpeed
1043
+ dryer: Refrigerant
1044
+ dewPoint: Minus40
1045
+ filtration: General
1046
+ cooling: Air
1047
+ heatRecovery: false
1048
+ receiver: Litres500
1049
+ enclosure: Standard
1050
+ supply: V400Hz50
1051
+ servicePlan: Essential
1052
+ commissioning: true
1053
+ delivery: Standard
1054
+ company: ''
1055
+ email: ''
1056
+ projectReference: ''
1057
+
1058
+ navigationStates:
1059
+ - duty
1060
+ - treatment
1061
+ - installation
1062
+ - service
1063
+ - review
1064
+ - requested
1065
+
1066
+ # The quote's price, lead time and summary: declared here, computed in src/expressions/compressor.ts
1067
+ expressions:
1068
+ Compressor:
1069
+ $maxFlow:
1070
+ type: number
1071
+ $frameName:
1072
+ type: string
1073
+ $frameKw:
1074
+ type: number
1075
+ $minReceiverLitres:
1076
+ type: number
1077
+ $leadTimeWeeks:
1078
+ type: number
1079
+ $quoteLines:
1080
+ type: array
1081
+ items:
1082
+ type: object
1083
+ properties:
1084
+ key:
1085
+ type: string
1086
+ label:
1087
+ type: string
1088
+ detail:
1089
+ type: string
1090
+ amount:
1091
+ type: number
1092
+ required:
1093
+ - key
1094
+ - label
1095
+ - detail
1096
+ - amount
1097
+ additionalProperties: false
1098
+ $subtotal:
1099
+ type: number
1100
+ $leadTimeSurcharge:
1101
+ type: number
1102
+ $total:
1103
+ type: number
1104
+ $specSheet:
1105
+ type: array
1106
+ items:
1107
+ type: object
1108
+ properties:
1109
+ label:
1110
+ type: string
1111
+ value:
1112
+ type: string
1113
+ required:
1114
+ - label
1115
+ - value
1116
+ additionalProperties: false
1117
+
1118
+ # Every rule is a warning: the change is kept, the layout shows the message at the field, and finalizing waits
1119
+ # until it is resolved
1120
+ invariants:
1121
+ Compressor:
1122
+ flowRange:
1123
+ types:
1124
+ - flowAboveRange
1125
+ desiccantNeedsFiltration:
1126
+ type: desiccantNeedsFiltration
1127
+ severity: warning
1128
+ message: A desiccant dryer needs high-efficiency or oil-vapour filtration upstream, or oil aerosol spoils the desiccant.
1129
+ receiverSize:
1130
+ types:
1131
+ - receiverTooSmall
1132
+ expressUnavailable:
1133
+ type: expressUnavailable
1134
+ severity: warning
1135
+ message: Express delivery covers stock frames up to 110 kW. Choose standard delivery for this frame.
1136
+ companyMissing:
1137
+ type: companyMissing
1138
+ severity: warning
1139
+ message: Enter the company the quote is for.
1140
+ emailInvalid:
1141
+ type: emailInvalid
1142
+ severity: warning
1143
+ message: Enter a valid email address for the quote.
1144
+
1145
+ components:
1146
+ Compressor:
1147
+ price:
1148
+ main:
1149
+ price:
1150
+ $read: $total
1151
+ currency: EUR
1152
+ navigationSteps:
1153
+ main:
1154
+ steps:
1155
+ - label: Duty
1156
+ navigationStates:
1157
+ - duty
1158
+ - label: Air treatment
1159
+ navigationStates:
1160
+ - treatment
1161
+ - label: Installation
1162
+ navigationStates:
1163
+ - installation
1164
+ - label: Service and delivery
1165
+ navigationStates:
1166
+ - service
1167
+ - label: Review
1168
+ navigationStates:
1169
+ - review
1170
+ - requested
1171
+ finalizeAction:
1172
+ quote:
1173
+ variant: primary
1174
+ label: Request quote
1175
+ title: Send the configuration to an application engineer for a firm quote
1176
+ icon: PaperPlaneTilt
1177
+ handler: requestQuote
1178
+
1179
+ # Duty
1180
+ genericDimension:
1181
+ flow:
1182
+ value:
1183
+ $bind: flow
1184
+ min: 2
1185
+ max: 40
1186
+ unit: m³/min
1187
+ decimalPlaces: 1
1188
+ attributeSelection:
1189
+ pressure:
1190
+ value:
1191
+ $bind: pressure
1192
+ options:
1193
+ - label: 7.5 bar
1194
+ description: Workshops and general tools
1195
+ value: Bar7
1196
+ - label: 10 bar
1197
+ description: Most production plants
1198
+ value: Bar10
1199
+ - label: 13 bar
1200
+ description: Blow moulding pre-blow, long networks
1201
+ value: Bar13
1202
+ dutyCycle:
1203
+ value:
1204
+ $bind: dutyCycle
1205
+ options:
1206
+ - label: Intermittent
1207
+ description: Up to 60 % load, one or two shifts
1208
+ value: Intermittent
1209
+ - label: Continuous
1210
+ description: Around the clock, heavy-duty package
1211
+ value: Continuous
1212
+ drive:
1213
+ value:
1214
+ $bind: drive
1215
+ options:
1216
+ - label: Fixed speed
1217
+ description: Load and unload control, lowest first cost
1218
+ value: FixedSpeed
1219
+ - label: Variable speed
1220
+ description: Follows demand, about 30 % less energy
1221
+ value: VariableSpeed
1222
+
1223
+ # Air treatment
1224
+ dryer:
1225
+ value:
1226
+ $bind: dryer
1227
+ options:
1228
+ - label: No dryer
1229
+ description: Wet air, for tools that tolerate it
1230
+ value: None
1231
+ - label: Refrigerant
1232
+ description: Pressure dew point +3 °C
1233
+ value: Refrigerant
1234
+ - label: Desiccant
1235
+ description: Pressure dew point below freezing
1236
+ value: Desiccant
1237
+ dewPoint:
1238
+ value:
1239
+ $bind: dewPoint
1240
+ options:
1241
+ - label: −40 °C
1242
+ description: Outdoor lines, food and pharma
1243
+ value: Minus40
1244
+ - label: −70 °C
1245
+ description: Electronics and laboratories
1246
+ value: Minus70
1247
+ $filter:
1248
+ active:
1249
+ $eval: self.dryer === 'Desiccant'
1250
+ filtration:
1251
+ value:
1252
+ $bind: filtration
1253
+ options:
1254
+ - label: General purpose
1255
+ description: Removes particles down to 1 µm
1256
+ value: General
1257
+ - label: High efficiency
1258
+ description: Oil aerosol down to 0.01 mg/m³
1259
+ value: HighEfficiency
1260
+ - label: Oil vapour
1261
+ description: Adds activated carbon, 0.003 mg/m³
1262
+ value: OilVapour
1263
+
1264
+ # Installation
1265
+ cooling:
1266
+ value:
1267
+ $bind: cooling
1268
+ options:
1269
+ - label: Air-cooled
1270
+ description: Needs ventilation in the plant room
1271
+ value: Air
1272
+ - label: Water-cooled
1273
+ description: For hot rooms and heat recovery
1274
+ value: Water
1275
+ receiver:
1276
+ value:
1277
+ $bind: receiver
1278
+ options:
1279
+ - label: No receiver
1280
+ value: None
1281
+ - label: 500 L
1282
+ value: Litres500
1283
+ - label: 1,000 L
1284
+ value: Litres1000
1285
+ - label: 2,000 L
1286
+ value: Litres2000
1287
+ enclosure:
1288
+ value:
1289
+ $bind: enclosure
1290
+ options:
1291
+ - label: Standard canopy
1292
+ description: About 72 dB(A) at 1 m
1293
+ value: Standard
1294
+ - label: Low-noise canopy
1295
+ description: About 66 dB(A) at 1 m
1296
+ value: LowNoise
1297
+ supply:
1298
+ value:
1299
+ $bind: supply
1300
+ options:
1301
+ - label: 400 V, 50 Hz
1302
+ value: V400Hz50
1303
+ - label: 480 V, 60 Hz
1304
+ value: V480Hz60
1305
+
1306
+ # Service and delivery
1307
+ servicePlan:
1308
+ value:
1309
+ $bind: servicePlan
1310
+ options:
1311
+ - label: No plan
1312
+ description: Service on call
1313
+ value: None
1314
+ - label: Essential, 3 years
1315
+ description: Scheduled service and parts
1316
+ value: Essential
1317
+ - label: Total care, 5 years
1318
+ description: Parts, labour and an extended warranty
1319
+ value: TotalCare
1320
+ delivery:
1321
+ value:
1322
+ $bind: delivery
1323
+ options:
1324
+ $read: $deliveryOptions
1325
+ attributeSwitch:
1326
+ heatRecovery:
1327
+ value:
1328
+ $bind: heatRecovery
1329
+ $filter:
1330
+ active:
1331
+ $eval: self.cooling === 'Water'
1332
+ commissioning:
1333
+ value:
1334
+ $bind: commissioning
1335
+ textValue:
1336
+ company:
1337
+ value:
1338
+ $bind: company
1339
+ email:
1340
+ value:
1341
+ $bind: email
1342
+ projectReference:
1343
+ value:
1344
+ $bind: projectReference
1345
+ ```
1346
+
1347
+ ### ./src/\_form/ChoiceField.tsx
1348
+
1349
+ ```tsx
1350
+ import { Box, ButtonBase, Typography } from '@mui/material'
1351
+ import type { KeyboardEvent } from 'react'
1352
+
1353
+ import { attributeSelectionComponent } from '@salsita-ai/sdk/domain'
1354
+
1355
+ import { phx } from '../../.phoenix/phx.js'
1356
+ import { FieldShell } from './FieldShell.js'
1357
+ import { ARDENT } from './tokens.js'
1358
+
1359
+ // Arrow keys move the selection between the cards, as in a native radio group
1360
+ const ARROW_STEPS: Readonly<Record<string, number | undefined>> = {
1361
+ ArrowRight: 1,
1362
+ ArrowDown: 1,
1363
+ ArrowLeft: -1,
1364
+ ArrowUp: -1,
1365
+ }
1366
+
1367
+ interface ChoiceFieldProps {
1368
+ readonly instanceName: string
1369
+ readonly label: string
1370
+ readonly hint?: string
1371
+ readonly warningTypes?: readonly string[]
1372
+ readonly readOnly: boolean
1373
+ // The narrowest a card gets before the cards wrap onto another row
1374
+ readonly minCardWidth?: number
1375
+ }
1376
+
1377
+ const ChoiceCards = ({ instanceName, label, hint, warningTypes, readOnly, minCardWidth = 150 }: ChoiceFieldProps) => {
1378
+ const resolve = phx.frontend.useResolveLocalizable()
1379
+ const compressorId = phx.frontend.useRootEntityId()
1380
+ const { value, options } = phx.frontend.useAttributeSelection(compressorId, instanceName)
1381
+ // An option a `fixable` or `fatal` invariant rules out is `disabled`, with the reason in its `hint`
1382
+ const optionHint = options.find(option => option.disabled && option.hint)?.hint
1383
+
1384
+ const handleKeyDown = (event: KeyboardEvent<HTMLElement>, index: number) => {
1385
+ const step = ARROW_STEPS[event.key]
1386
+
1387
+ if (step === undefined) {
1388
+ return
1389
+ }
1390
+
1391
+ event.preventDefault()
1392
+ const next = (index + step + options.length) % options.length
1393
+ options[next]?.onSelect()
1394
+ event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="radio"]')[next]?.focus()
1395
+ }
1396
+
1397
+ return (
1398
+ <FieldShell id={`ardent-${instanceName}`} label={label} hint={optionHint ?? hint} warningTypes={warningTypes}>
1399
+ <Box
1400
+ component="div"
1401
+ role="radiogroup"
1402
+ aria-labelledby={`ardent-${instanceName}-label`}
1403
+ sx={{ display: 'grid', gridTemplateColumns: `repeat(auto-fit, minmax(${minCardWidth}px, 1fr))`, gap: 1.25 }}
1404
+ >
1405
+ {options.map((option, index) => {
1406
+ const isSelected = option.value === value
1407
+ const description = resolve(option.description)
1408
+
1409
+ return (
1410
+ <ButtonBase
1411
+ key={String(option.value)}
1412
+ role="radio"
1413
+ aria-checked={isSelected}
1414
+ aria-disabled={option.disabled}
1415
+ tabIndex={isSelected ? 0 : -1}
1416
+ disabled={readOnly && !isSelected}
1417
+ onClick={readOnly ? undefined : option.onSelect}
1418
+ onKeyDown={readOnly ? undefined : event => handleKeyDown(event, index)}
1419
+ sx={{
1420
+ display: 'grid',
1421
+ gridTemplateColumns: 'auto 1fr',
1422
+ alignItems: 'start',
1423
+ columnGap: 1.25,
1424
+ rowGap: 0.25,
1425
+ padding: 1.5,
1426
+ textAlign: 'start',
1427
+ borderRadius: 2,
1428
+ border: `1px solid ${isSelected ? ARDENT.signal : ARDENT.line}`,
1429
+ boxShadow: isSelected ? `inset 0 0 0 1px ${ARDENT.signal}` : 'none',
1430
+ bgcolor: isSelected ? ARDENT.signalTint : ARDENT.panel,
1431
+ fontFamily: ARDENT.font,
1432
+ opacity: option.disabled ? 0.45 : 1,
1433
+ transition: 'border-color 140ms, background-color 140ms',
1434
+ '&:hover': { borderColor: isSelected ? ARDENT.signal : ARDENT.steel },
1435
+ '&.Mui-focusVisible': { outline: `2px solid ${ARDENT.navy}`, outlineOffset: 2 },
1436
+ '&.Mui-disabled': { opacity: 0.45 },
1437
+ }}
1438
+ >
1439
+ {/* The radio mark: a ring, filled with a dot when the card is chosen */}
1440
+ <Box
1441
+ component="span"
1442
+ aria-hidden
1443
+ sx={{
1444
+ gridRow: 'span 2',
1445
+ marginBlockStart: 0.25,
1446
+ inlineSize: 18,
1447
+ blockSize: 18,
1448
+ borderRadius: '50%',
1449
+ border: `2px solid ${isSelected ? ARDENT.signal : ARDENT.steel}`,
1450
+ boxShadow: isSelected ? `inset 0 0 0 3px ${ARDENT.signalTint}` : 'none',
1451
+ bgcolor: isSelected ? ARDENT.signal : 'transparent',
1452
+ }}
1453
+ />
1454
+ <Typography
1455
+ component="span"
1456
+ sx={{ fontFamily: ARDENT.font, fontSize: 15, fontWeight: 600, color: ARDENT.ink }}
1457
+ >
1458
+ {resolve(option.title)}
1459
+ </Typography>
1460
+ {description && (
1461
+ <Typography
1462
+ component="span"
1463
+ sx={{ fontFamily: ARDENT.font, fontSize: 13, lineHeight: 1.35, color: ARDENT.steel }}
1464
+ >
1465
+ {description}
1466
+ </Typography>
1467
+ )}
1468
+ </ButtonBase>
1469
+ )
1470
+ })}
1471
+ </Box>
1472
+ </FieldShell>
1473
+ )
1474
+ }
1475
+
1476
+ // One `attributeSelection` of the spec as a group of cards. The field exists only while the spec's component is
1477
+ // active: a `$filter` in the spec decides when a question is asked, and the layout follows it. The control hook throws
1478
+ // for an inactive component, so the cards mount only after `useComponent` has found it. An option that a `fixable`
1479
+ // invariant rules out is faded with its hint below the cards; it stays clickable, the change is rejected and the
1480
+ // built-in snackbar says why.
1481
+ export const ChoiceField = (props: ChoiceFieldProps) => {
1482
+ const compressorId = phx.frontend.useRootEntityId()
1483
+ const component = phx.frontend.useComponent({
1484
+ entityId: compressorId,
1485
+ componentType: attributeSelectionComponent,
1486
+ instanceName: props.instanceName,
1487
+ })
1488
+
1489
+ return component === null ? null : <ChoiceCards {...props} />
1490
+ }
1491
+ ```
1492
+
1493
+ ### ./src/\_form/FieldShell.tsx
1494
+
1495
+ ```tsx
1496
+ import { Box, Typography } from '@mui/material'
1497
+ import type { ReactNode } from 'react'
1498
+
1499
+ import { ARDENT } from './tokens.js'
1500
+ import { useWarnings } from './use-warnings.js'
1501
+
1502
+ const WARNING_ICON = 'M12 3 2 21h20L12 3Zm0 6v5m0 3.5v.5'
1503
+
1504
+ export interface FieldShellProps {
1505
+ readonly id: string
1506
+ readonly label: string
1507
+ // Help shown below the input while the field has no warning
1508
+ readonly hint?: string
1509
+ // The invariant types whose warning belongs to this field
1510
+ readonly warningTypes?: readonly string[]
1511
+ // Hides the warning until the customer has had a chance to fill the field in
1512
+ readonly showWarning?: boolean
1513
+ readonly aside?: ReactNode
1514
+ readonly children: ReactNode
1515
+ }
1516
+
1517
+ // A field's frame: the label, the input, and below it either the help text or the warning of an invariant that the
1518
+ // field can resolve. The warning comes from the committed configuration, so it appears and disappears with the rule.
1519
+ export const FieldShell = ({
1520
+ id,
1521
+ label,
1522
+ hint,
1523
+ warningTypes = [],
1524
+ showWarning = true,
1525
+ aside,
1526
+ children,
1527
+ }: FieldShellProps) => {
1528
+ const warning = useWarnings().find(candidate => warningTypes.includes(candidate.type))
1529
+ const visibleWarning = showWarning ? warning : undefined
1530
+
1531
+ return (
1532
+ <Box
1533
+ component="div"
1534
+ role="group"
1535
+ aria-labelledby={`${id}-label`}
1536
+ sx={{
1537
+ display: 'grid',
1538
+ gap: 1.25,
1539
+ animation: 'ardent-field-in 220ms ease-out',
1540
+ '@keyframes ardent-field-in': {
1541
+ from: { opacity: 0, transform: 'translateY(-4px)' },
1542
+ to: { opacity: 1, transform: 'none' },
1543
+ },
1544
+ }}
1545
+ >
1546
+ <Box component="div" sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 2 }}>
1547
+ <Typography
1548
+ id={`${id}-label`}
1549
+ component="h4"
1550
+ sx={{ fontFamily: ARDENT.font, fontSize: 16, fontWeight: 600, color: ARDENT.ink }}
1551
+ >
1552
+ {label}
1553
+ </Typography>
1554
+ {aside}
1555
+ </Box>
1556
+ {children}
1557
+ {visibleWarning ? (
1558
+ <Box
1559
+ component="p"
1560
+ role="alert"
1561
+ sx={{
1562
+ display: 'flex',
1563
+ gap: 1,
1564
+ alignItems: 'flex-start',
1565
+ margin: 0,
1566
+ paddingBlock: 1,
1567
+ paddingInline: 1.5,
1568
+ borderRadius: 1.5,
1569
+ bgcolor: ARDENT.warningTint,
1570
+ color: ARDENT.warning,
1571
+ fontFamily: ARDENT.font,
1572
+ fontSize: 14,
1573
+ lineHeight: 1.4,
1574
+ }}
1575
+ >
1576
+ <Box component="svg" viewBox="0 0 24 24" aria-hidden sx={{ inlineSize: 18, blockSize: 18, flex: 'none' }}>
1577
+ <path d={WARNING_ICON} fill="none" stroke="currentColor" strokeWidth={2} strokeLinejoin="round" />
1578
+ </Box>
1579
+ {visibleWarning.text}
1580
+ </Box>
1581
+ ) : (
1582
+ hint && <Typography sx={{ fontFamily: ARDENT.font, fontSize: 14, color: ARDENT.steel }}>{hint}</Typography>
1583
+ )}
1584
+ </Box>
1585
+ )
1586
+ }
1587
+ ```
1588
+
1589
+ ### ./src/\_form/NumberField.tsx
1590
+
1591
+ ```tsx
1592
+ import { Box, InputBase, Slider, Typography } from '@mui/material'
1593
+ import { useEffect, useState } from 'react'
1594
+
1595
+ import { phx } from '../../.phoenix/phx.js'
1596
+ import { FieldShell } from './FieldShell.js'
1597
+ import { ARDENT } from './tokens.js'
1598
+
1599
+ interface NumberFieldProps {
1600
+ readonly instanceName: string
1601
+ readonly label: string
1602
+ readonly hint?: string
1603
+ readonly warningTypes?: readonly string[]
1604
+ readonly readOnly: boolean
1605
+ readonly step: number
1606
+ }
1607
+
1608
+ // One `genericDimension` of the spec as a slider with a typed value beside it. Dragging stages each value, so the
1609
+ // price follows the thumb, and letting go commits it; a typed value is committed on Enter or when the input loses
1610
+ // focus. The slider's range is the component's `min` and `max`, which the spec can read from expressions.
1611
+ export const NumberField = ({ instanceName, label, hint, warningTypes, readOnly, step }: NumberFieldProps) => {
1612
+ const compressorId = phx.frontend.useRootEntityId()
1613
+ const dimension = phx.frontend.useGenericDimension(compressorId, instanceName)
1614
+ const [draft, setDraft] = useState(String(dimension.value))
1615
+ const min = dimension.min ?? 0
1616
+ const max = dimension.max ?? 100
1617
+
1618
+ useEffect(() => {
1619
+ setDraft(String(dimension.value))
1620
+ }, [dimension.value])
1621
+
1622
+ const commitDraft = () => {
1623
+ const parsed = Number(draft.replace(',', '.'))
1624
+
1625
+ if (!Number.isFinite(parsed)) {
1626
+ setDraft(String(dimension.value))
1627
+
1628
+ return
1629
+ }
1630
+
1631
+ dimension.update(parsed)
1632
+ dimension.resolve()
1633
+ }
1634
+
1635
+ return (
1636
+ <FieldShell
1637
+ id={`ardent-${instanceName}`}
1638
+ label={label}
1639
+ hint={hint}
1640
+ warningTypes={warningTypes}
1641
+ aside={
1642
+ <Box
1643
+ component="label"
1644
+ sx={{
1645
+ display: 'flex',
1646
+ alignItems: 'baseline',
1647
+ gap: 0.75,
1648
+ paddingInline: 1.25,
1649
+ paddingBlock: 0.5,
1650
+ borderRadius: 1.5,
1651
+ border: `1px solid ${ARDENT.line}`,
1652
+ bgcolor: ARDENT.panel,
1653
+ '&:focus-within': { borderColor: ARDENT.navy },
1654
+ }}
1655
+ >
1656
+ <InputBase
1657
+ value={draft}
1658
+ disabled={readOnly}
1659
+ onChange={event => setDraft(event.target.value)}
1660
+ onBlur={commitDraft}
1661
+ onKeyDown={event => {
1662
+ if (event.key === 'Enter') {
1663
+ commitDraft()
1664
+ }
1665
+ }}
1666
+ inputProps={{ inputMode: 'decimal', 'aria-label': `${label}, ${dimension.unit}` }}
1667
+ sx={{
1668
+ inlineSize: 56,
1669
+ fontFamily: ARDENT.font,
1670
+ fontSize: 20,
1671
+ fontWeight: 600,
1672
+ color: ARDENT.ink,
1673
+ '& input': { padding: 0, textAlign: 'end' },
1674
+ }}
1675
+ />
1676
+ <Typography component="span" sx={{ fontFamily: ARDENT.font, fontSize: 14, color: ARDENT.steel }}>
1677
+ {dimension.unit}
1678
+ </Typography>
1679
+ </Box>
1680
+ }
1681
+ >
1682
+ <Box component="div" sx={{ paddingInline: 1.25 }}>
1683
+ <Slider
1684
+ value={Math.min(dimension.value, max)}
1685
+ min={min}
1686
+ max={max}
1687
+ step={step}
1688
+ disabled={readOnly}
1689
+ onChange={(_, value) => dimension.update(Array.isArray(value) ? (value[0] ?? min) : value)}
1690
+ onChangeCommitted={() => dimension.resolve()}
1691
+ marks={[
1692
+ { value: min, label: `${min}` },
1693
+ { value: max, label: `${max}` },
1694
+ ]}
1695
+ slotProps={{ input: { 'aria-label': label } }}
1696
+ sx={{
1697
+ color: ARDENT.signal,
1698
+ blockSize: 6,
1699
+ '& .MuiSlider-rail': { bgcolor: ARDENT.line, opacity: 1 },
1700
+ '& .MuiSlider-thumb': {
1701
+ inlineSize: 22,
1702
+ blockSize: 22,
1703
+ bgcolor: ARDENT.panel,
1704
+ border: `3px solid ${ARDENT.signal}`,
1705
+ },
1706
+ '& .MuiSlider-markLabel': { fontFamily: ARDENT.font, fontSize: 12, color: ARDENT.steel },
1707
+ '& .MuiSlider-mark': { display: 'none' },
1708
+ }}
1709
+ />
1710
+ </Box>
1711
+ </FieldShell>
1712
+ )
1713
+ }
1714
+ ```
1715
+
1716
+ ### ./src/\_form/SwitchField.tsx
1717
+
1718
+ ```tsx
1719
+ import { Box, Switch, Typography } from '@mui/material'
1720
+
1721
+ import { attributeSwitchComponent } from '@salsita-ai/sdk/domain'
1722
+
1723
+ import { phx } from '../../.phoenix/phx.js'
1724
+ import { ARDENT } from './tokens.js'
1725
+
1726
+ interface SwitchFieldProps {
1727
+ readonly instanceName: string
1728
+ readonly label: string
1729
+ readonly description: string
1730
+ readonly readOnly: boolean
1731
+ }
1732
+
1733
+ const SwitchRow = ({ instanceName, label, description, readOnly }: SwitchFieldProps) => {
1734
+ const compressorId = phx.frontend.useRootEntityId()
1735
+ const toggle = phx.frontend.useAttributeSwitch(compressorId, instanceName)
1736
+ const labelId = `ardent-${instanceName}-label`
1737
+
1738
+ return (
1739
+ <Box
1740
+ component="div"
1741
+ sx={{
1742
+ display: 'flex',
1743
+ alignItems: 'center',
1744
+ justifyContent: 'space-between',
1745
+ gap: 2,
1746
+ padding: 1.5,
1747
+ borderRadius: 2,
1748
+ border: `1px solid ${ARDENT.line}`,
1749
+ animation: 'ardent-switch-in 220ms ease-out',
1750
+ '@keyframes ardent-switch-in': { from: { opacity: 0 }, to: { opacity: 1 } },
1751
+ }}
1752
+ >
1753
+ <Box component="div" sx={{ display: 'grid', gap: 0.25 }}>
1754
+ <Typography id={labelId} sx={{ fontFamily: ARDENT.font, fontSize: 16, fontWeight: 600, color: ARDENT.ink }}>
1755
+ {label}
1756
+ </Typography>
1757
+ <Typography sx={{ fontFamily: ARDENT.font, fontSize: 14, color: ARDENT.steel }}>{description}</Typography>
1758
+ </Box>
1759
+ <Switch
1760
+ checked={toggle.state.value}
1761
+ disabled={readOnly || toggle.disabled}
1762
+ onChange={toggle.onChange}
1763
+ inputProps={{ 'aria-labelledby': labelId }}
1764
+ sx={{
1765
+ '& .MuiSwitch-switchBase.Mui-checked': { color: ARDENT.panel },
1766
+ '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: ARDENT.signal, opacity: 1 },
1767
+ '& .MuiSwitch-track': { bgcolor: ARDENT.steel, opacity: 0.5 },
1768
+ }}
1769
+ />
1770
+ </Box>
1771
+ )
1772
+ }
1773
+
1774
+ // One `attributeSwitch` of the spec as a labelled switch, shown only while the spec's component is active
1775
+ export const SwitchField = (props: SwitchFieldProps) => {
1776
+ const compressorId = phx.frontend.useRootEntityId()
1777
+ const component = phx.frontend.useComponent({
1778
+ entityId: compressorId,
1779
+ componentType: attributeSwitchComponent,
1780
+ instanceName: props.instanceName,
1781
+ })
1782
+
1783
+ return component === null ? null : <SwitchRow {...props} />
1784
+ }
1785
+ ```
1786
+
1787
+ ### ./src/\_form/TextInputField.tsx
1788
+
1789
+ ```tsx
1790
+ import { InputBase } from '@mui/material'
1791
+ import { useState } from 'react'
1792
+
1793
+ import { phx } from '../../.phoenix/phx.js'
1794
+ import { FieldShell } from './FieldShell.js'
1795
+ import { ARDENT } from './tokens.js'
1796
+
1797
+ interface TextInputFieldProps {
1798
+ readonly instanceName: string
1799
+ readonly label: string
1800
+ readonly hint?: string
1801
+ readonly warningTypes?: readonly string[]
1802
+ readonly readOnly: boolean
1803
+ readonly type?: 'text' | 'email'
1804
+ readonly autoComplete?: string
1805
+ // Shows the field's warning before the customer has left the field, after a request was attempted
1806
+ readonly revealWarning: boolean
1807
+ }
1808
+
1809
+ // One `textValue` of the spec as a text input. Typing stages the text and leaving the field commits it, so the
1810
+ // invariants judge the committed text; their warning is shown once the customer has left the field.
1811
+ export const TextInputField = ({
1812
+ instanceName,
1813
+ label,
1814
+ hint,
1815
+ warningTypes,
1816
+ readOnly,
1817
+ type = 'text',
1818
+ autoComplete,
1819
+ revealWarning,
1820
+ }: TextInputFieldProps) => {
1821
+ const compressorId = phx.frontend.useRootEntityId()
1822
+ const text = phx.frontend.useTextValue(compressorId, instanceName)
1823
+ const [touched, setTouched] = useState(false)
1824
+
1825
+ return (
1826
+ <FieldShell
1827
+ id={`ardent-${instanceName}`}
1828
+ label={label}
1829
+ hint={hint}
1830
+ warningTypes={warningTypes}
1831
+ showWarning={touched || revealWarning}
1832
+ >
1833
+ <InputBase
1834
+ value={text.value}
1835
+ type={type}
1836
+ disabled={readOnly}
1837
+ autoComplete={autoComplete}
1838
+ onChange={event => text.update(event, event.target.value)}
1839
+ onBlur={() => {
1840
+ text.resolve()
1841
+ setTouched(true)
1842
+ }}
1843
+ inputProps={{ 'aria-labelledby': `ardent-${instanceName}-label` }}
1844
+ sx={{
1845
+ paddingInline: 1.5,
1846
+ paddingBlock: 1,
1847
+ borderRadius: 2,
1848
+ border: `1px solid ${ARDENT.line}`,
1849
+ bgcolor: ARDENT.panel,
1850
+ fontFamily: ARDENT.font,
1851
+ fontSize: 16,
1852
+ color: ARDENT.ink,
1853
+ '&.Mui-focused': { borderColor: ARDENT.navy, boxShadow: `0 0 0 3px ${ARDENT.page}` },
1854
+ }}
1855
+ />
1856
+ </FieldShell>
1857
+ )
1858
+ }
1859
+ ```
1860
+
1861
+ ### ./src/\_form/tokens.ts
1862
+
1863
+ ```ts
1864
+ // Ardent Air's palette and type: a navy shell, white work surfaces and the signal orange of the machines' doors.
1865
+ // Barlow is the brand's typeface; the stack falls back to system fonts where it is not loaded.
1866
+ export const ARDENT = {
1867
+ navy: '#0d1b2e',
1868
+ navySoft: '#1b2c45',
1869
+ ink: '#15202e',
1870
+ steel: '#5a6778',
1871
+ line: '#dde2e9',
1872
+ page: '#f1f3f6',
1873
+ panel: '#ffffff',
1874
+ signal: '#f0641a',
1875
+ signalDark: '#c84f0e',
1876
+ signalTint: '#fff1e8',
1877
+ warning: '#9a4b00',
1878
+ warningTint: '#fff6e0',
1879
+ success: '#17703f',
1880
+ successTint: '#e9f6ee',
1881
+ font: "'Barlow', 'Helvetica Neue', Arial, sans-serif",
1882
+ } as const
1883
+
1884
+ // Small capitals with wide tracking, for section labels and table headings
1885
+ export const OVERLINE_SX = {
1886
+ fontFamily: ARDENT.font,
1887
+ fontSize: 12,
1888
+ fontWeight: 500,
1889
+ letterSpacing: '0.14em',
1890
+ textTransform: 'uppercase',
1891
+ color: ARDENT.steel,
1892
+ } as const
1893
+ ```
1894
+
1895
+ ### ./src/\_form/use-warnings.ts
1896
+
1897
+ ```ts
1898
+ import { useMemo } from 'react'
1899
+
1900
+ import { phx } from '../../.phoenix/phx.js'
1901
+
1902
+ export interface Warning {
1903
+ readonly type: string
1904
+ readonly text: string
1905
+ }
1906
+
1907
+ // The warnings of the committed configuration with their text resolved. A `warning` invariant keeps the change, so its
1908
+ // violation stays in the committed state until the customer resolves it, and finalizing waits for that.
1909
+ // `useFinalizeViolations` lists them; `useStateValidity` holds the same list, but on `phx.frontend` it is typed as the
1910
+ // validity value instead of a hook, so calling it does not type-check.
1911
+ export const useWarnings = (): readonly Warning[] => {
1912
+ const { violations } = phx.frontend.useFinalizeViolations()
1913
+ const resolve = phx.frontend.useResolveLocalizable()
1914
+
1915
+ return useMemo(() => {
1916
+ return (violations ?? []).map(({ violation }) => ({ type: violation.type, text: resolve(violation.message) ?? '' }))
1917
+ }, [violations, resolve])
1918
+ }
1919
+ ```
1920
+
1921
+ ### ./src/\_shared/DesktopPage.tsx
1922
+
1923
+ ```tsx
1924
+ import { Box, Button, IconButton, Tooltip, Typography } from '@mui/material'
1925
+ import { useEffect, useRef, useState } from 'react'
1926
+
1927
+ import { LayoutSlots } from '@salsita-ai/sdk/configurator'
1928
+
1929
+ import { phx } from '../../.phoenix/phx.js'
1930
+ import { ARDENT, useWarnings } from '../_form/index.js'
1931
+ import { PriceBreakdown } from './PriceBreakdown.js'
1932
+ import { QuoteAction } from './QuoteAction.js'
1933
+ import { StepForm } from './StepForm.js'
1934
+ import { StepHeader } from './StepHeader.js'
1935
+ import { STEP_WARNINGS } from './step-warnings.js'
1936
+
1937
+ const UNDO_ICON = 'M9 14 4 9l5-5M4 9h10.5a5.5 5.5 0 0 1 0 11H11'
1938
+ const REDO_ICON = 'M15 14l5-5-5-5M20 9H9.5a5.5 5.5 0 0 0 0 11H13'
1939
+ const PANEL_WIDTH = 400
1940
+
1941
+ interface IconProps {
1942
+ readonly path: string
1943
+ }
1944
+
1945
+ const Icon = ({ path }: IconProps) => {
1946
+ return (
1947
+ <Box component="svg" viewBox="0 0 24 24" aria-hidden sx={{ inlineSize: 20, blockSize: 20 }}>
1948
+ <path d={path} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
1949
+ </Box>
1950
+ )
1951
+ }
1952
+
1953
+ // The desktop page: the brand bar with undo and redo, the stepper, the active step's form, and a column with the live
1954
+ // price breakdown and the way on. The form scrolls; the column stays.
1955
+ export const DesktopPage = () => {
1956
+ const { accessInfo } = phx.frontend.useAccessService()
1957
+ const { canUndo, canRedo, undo, redo } = phx.frontend.useUndoRedo()
1958
+ const { activeStep, isLast, canGoPrevious, canGoNext, steps, next, previous, goTo } = phx.frontend.useStepNavigation()
1959
+ const navigationState = phx.frontend.useActiveNavigationState()
1960
+ const compressor = phx.frontend.useRootEntityState()
1961
+ const warnings = useWarnings()
1962
+ const [revealWarnings, setRevealWarnings] = useState(false)
1963
+ const formRef = useRef<HTMLElement>(null)
1964
+
1965
+ // Each step starts at its top, as a new page would
1966
+ useEffect(() => {
1967
+ formRef.current?.scrollTo({ top: 0 })
1968
+ }, [navigationState])
1969
+ const readOnly = accessInfo.viewMode
1970
+ const nextStep = steps[(activeStep?.index ?? -1) + 1]
1971
+ const conflictCount = warnings.filter(warning =>
1972
+ Object.values(STEP_WARNINGS).some(types => types.includes(warning.type))
1973
+ ).length
1974
+
1975
+ return (
1976
+ <Box
1977
+ component="div"
1978
+ sx={{
1979
+ position: 'absolute',
1980
+ inset: 0,
1981
+ display: 'grid',
1982
+ gridTemplateRows: 'auto auto minmax(0, 1fr)',
1983
+ gridTemplateColumns: `minmax(0, 1fr) ${PANEL_WIDTH}px`,
1984
+ bgcolor: ARDENT.page,
1985
+ fontFamily: ARDENT.font,
1986
+ }}
1987
+ >
1988
+ <Box
1989
+ component="header"
1990
+ sx={{
1991
+ gridColumn: '1 / -1',
1992
+ display: 'flex',
1993
+ alignItems: 'center',
1994
+ gap: 3,
1995
+ paddingInline: 4,
1996
+ blockSize: 64,
1997
+ bgcolor: ARDENT.navy,
1998
+ color: ARDENT.panel,
1999
+ }}
2000
+ >
2001
+ <Typography sx={{ fontFamily: ARDENT.font, fontSize: 22, fontWeight: 700, letterSpacing: '0.12em' }}>
2002
+ ARDENT
2003
+ <Box component="span" sx={{ color: ARDENT.signal }}>
2004
+ {' '}
2005
+ AIR
2006
+ </Box>
2007
+ </Typography>
2008
+ <Box component="span" aria-hidden sx={{ inlineSize: 1, blockSize: 28, bgcolor: ARDENT.navySoft }} />
2009
+ <Typography component="h1" sx={{ fontFamily: ARDENT.font, fontSize: 18, fontWeight: 500 }}>
2010
+ Compressor quote
2011
+ </Typography>
2012
+ <Typography sx={{ fontFamily: ARDENT.font, fontSize: 15, color: '#9fb0c6' }}>
2013
+ {compressor.$frameName} · {compressor.$frameKw} kW
2014
+ </Typography>
2015
+ <Box component="div" sx={{ marginInlineStart: 'auto', display: 'flex', gap: 0.5 }}>
2016
+ <Tooltip title="Undo">
2017
+ <span>
2018
+ <IconButton
2019
+ aria-label="Undo"
2020
+ disabled={!canUndo || readOnly}
2021
+ onClick={undo}
2022
+ sx={{ color: ARDENT.panel, '&.Mui-disabled': { color: ARDENT.navySoft } }}
2023
+ >
2024
+ <Icon path={UNDO_ICON} />
2025
+ </IconButton>
2026
+ </span>
2027
+ </Tooltip>
2028
+ <Tooltip title="Redo">
2029
+ <span>
2030
+ <IconButton
2031
+ aria-label="Redo"
2032
+ disabled={!canRedo || readOnly}
2033
+ onClick={redo}
2034
+ sx={{ color: ARDENT.panel, '&.Mui-disabled': { color: ARDENT.navySoft } }}
2035
+ >
2036
+ <Icon path={REDO_ICON} />
2037
+ </IconButton>
2038
+ </span>
2039
+ </Tooltip>
2040
+ </Box>
2041
+ </Box>
2042
+
2043
+ <Box
2044
+ component="div"
2045
+ sx={{ gridColumn: '1 / -1', bgcolor: ARDENT.panel, borderBlockEnd: `1px solid ${ARDENT.line}` }}
2046
+ >
2047
+ <StepHeader compact={false} />
2048
+ </Box>
2049
+
2050
+ <Box component="main" ref={formRef} sx={{ overflowY: 'auto', minBlockSize: 0 }}>
2051
+ <Box component="div" sx={{ display: 'grid', gap: 3, maxInlineSize: 820, marginInline: 'auto', padding: 4 }}>
2052
+ <Box component="div" sx={{ display: 'grid', gap: 0.5 }}>
2053
+ <Typography sx={{ fontFamily: ARDENT.font, fontSize: 14, color: ARDENT.steel }}>
2054
+ Step {(activeStep?.index ?? 0) + 1} of {steps.length}
2055
+ </Typography>
2056
+ <Typography
2057
+ component="h2"
2058
+ sx={{ fontFamily: ARDENT.font, fontSize: 32, fontWeight: 700, color: ARDENT.ink }}
2059
+ >
2060
+ {activeStep?.label}
2061
+ </Typography>
2062
+ </Box>
2063
+ <StepForm readOnly={readOnly} revealWarnings={revealWarnings} />
2064
+ <Box
2065
+ component="nav"
2066
+ aria-label="Step navigation"
2067
+ sx={{ display: 'flex', justifyContent: 'space-between', gap: 2 }}
2068
+ >
2069
+ <Button
2070
+ disabled={!canGoPrevious}
2071
+ onClick={previous}
2072
+ sx={{
2073
+ color: ARDENT.ink,
2074
+ fontFamily: ARDENT.font,
2075
+ fontSize: 16,
2076
+ textTransform: 'none',
2077
+ visibility: canGoPrevious ? 'visible' : 'hidden',
2078
+ }}
2079
+ >
2080
+ ← Back
2081
+ </Button>
2082
+ {canGoNext && !isLast && (
2083
+ <Button
2084
+ variant="contained"
2085
+ disableElevation
2086
+ onClick={next}
2087
+ sx={{
2088
+ borderRadius: 2,
2089
+ paddingInline: 3,
2090
+ bgcolor: ARDENT.navy,
2091
+ fontFamily: ARDENT.font,
2092
+ fontSize: 16,
2093
+ fontWeight: 600,
2094
+ textTransform: 'none',
2095
+ '&:hover': { bgcolor: ARDENT.ink },
2096
+ }}
2097
+ >
2098
+ Next: {nextStep?.label}
2099
+ </Button>
2100
+ )}
2101
+ </Box>
2102
+ </Box>
2103
+ </Box>
2104
+
2105
+ <Box
2106
+ component="aside"
2107
+ aria-label="Quote"
2108
+ sx={{
2109
+ display: 'flex',
2110
+ flexDirection: 'column',
2111
+ gap: 3,
2112
+ minBlockSize: 0,
2113
+ overflowY: 'auto',
2114
+ padding: 4,
2115
+ bgcolor: ARDENT.panel,
2116
+ borderInlineStart: `1px solid ${ARDENT.line}`,
2117
+ }}
2118
+ >
2119
+ <PriceBreakdown />
2120
+ {isLast ? (
2121
+ navigationState !== 'requested' && <QuoteAction onBlocked={() => setRevealWarnings(true)} compact={false} />
2122
+ ) : (
2123
+ <Box component="div" sx={{ display: 'grid', gap: 1 }}>
2124
+ {conflictCount > 0 && (
2125
+ <Typography sx={{ fontFamily: ARDENT.font, fontSize: 14, color: ARDENT.warning }}>
2126
+ {conflictCount === 1 ? 'One choice needs attention' : `${conflictCount} choices need attention`} before
2127
+ the quote can be requested.
2128
+ </Typography>
2129
+ )}
2130
+ <Button
2131
+ variant="outlined"
2132
+ onClick={() => goTo('review')}
2133
+ sx={{
2134
+ minBlockSize: 48,
2135
+ borderRadius: 2,
2136
+ borderColor: ARDENT.ink,
2137
+ color: ARDENT.ink,
2138
+ fontFamily: ARDENT.font,
2139
+ fontSize: 16,
2140
+ fontWeight: 600,
2141
+ textTransform: 'none',
2142
+ }}
2143
+ >
2144
+ Review and request quote
2145
+ </Button>
2146
+ </Box>
2147
+ )}
2148
+ {/* Snackbars for a failed save stack upwards from the bottom of the column */}
2149
+ <Box component="div" sx={{ marginBlockStart: 'auto' }}>
2150
+ <LayoutSlots.DesktopSnackbarsRoot />
2151
+ </Box>
2152
+ </Box>
2153
+ </Box>
2154
+ )
2155
+ }
2156
+ ```
2157
+
2158
+ ### ./src/\_shared/PhonePage.tsx
2159
+
2160
+ ```tsx
2161
+ import { Box, Button, ButtonBase, Typography } from '@mui/material'
2162
+ import { useEffect, useRef, useState } from 'react'
2163
+
2164
+ import { LayoutSlots } from '@salsita-ai/sdk/configurator'
2165
+
2166
+ import { phx } from '../../.phoenix/phx.js'
2167
+ import { ARDENT } from '../_form/index.js'
2168
+ import { PriceBreakdown } from './PriceBreakdown.js'
2169
+ import { QuoteAction } from './QuoteAction.js'
2170
+ import { StepForm } from './StepForm.js'
2171
+ import { StepHeader } from './StepHeader.js'
2172
+
2173
+ const CHEVRON_ICON = 'M6 15l6-6 6 6'
2174
+
2175
+ // The phone page: one column with the compact stepper, the active step's form, and a bar at the bottom with the total
2176
+ // and the way on. The total opens the price breakdown as a sheet above the bar.
2177
+ export const PhonePage = () => {
2178
+ const { accessInfo } = phx.frontend.useAccessService()
2179
+ const { activeStep, isLast, steps, next } = phx.frontend.useStepNavigation()
2180
+ const navigationState = phx.frontend.useActiveNavigationState()
2181
+ const compressor = phx.frontend.useRootEntityState()
2182
+ const total = phx.frontend.useRootEntityPrice()
2183
+ const [isBreakdownOpen, setBreakdownOpen] = useState(false)
2184
+ const [revealWarnings, setRevealWarnings] = useState(false)
2185
+ const formRef = useRef<HTMLElement>(null)
2186
+
2187
+ // Each step starts at its top, as a new page would
2188
+ useEffect(() => {
2189
+ formRef.current?.scrollTo({ top: 0 })
2190
+ }, [navigationState])
2191
+ const nextStep = steps[(activeStep?.index ?? -1) + 1]
2192
+
2193
+ return (
2194
+ <Box
2195
+ component="div"
2196
+ sx={{
2197
+ position: 'absolute',
2198
+ inset: 0,
2199
+ display: 'grid',
2200
+ gridTemplateRows: 'auto auto minmax(0, 1fr) auto',
2201
+ bgcolor: ARDENT.page,
2202
+ fontFamily: ARDENT.font,
2203
+ }}
2204
+ >
2205
+ <Box
2206
+ component="header"
2207
+ sx={{
2208
+ display: 'flex',
2209
+ alignItems: 'center',
2210
+ gap: 1.5,
2211
+ paddingInline: 2,
2212
+ blockSize: 52,
2213
+ bgcolor: ARDENT.navy,
2214
+ color: ARDENT.panel,
2215
+ }}
2216
+ >
2217
+ <Typography sx={{ fontFamily: ARDENT.font, fontSize: 18, fontWeight: 700, letterSpacing: '0.12em' }}>
2218
+ ARDENT
2219
+ <Box component="span" sx={{ color: ARDENT.signal }}>
2220
+ {' '}
2221
+ AIR
2222
+ </Box>
2223
+ </Typography>
2224
+ <Typography
2225
+ component="h1"
2226
+ sx={{ marginInlineStart: 'auto', fontFamily: ARDENT.font, fontSize: 15, color: '#c9d3e0' }}
2227
+ >
2228
+ Quote · {compressor.$frameName}
2229
+ </Typography>
2230
+ </Box>
2231
+
2232
+ <Box component="div" sx={{ bgcolor: ARDENT.panel, borderBlockEnd: `1px solid ${ARDENT.line}` }}>
2233
+ <StepHeader compact />
2234
+ </Box>
2235
+
2236
+ <Box component="main" ref={formRef} sx={{ overflowY: 'auto', minBlockSize: 0 }}>
2237
+ <Box component="div" sx={{ display: 'grid', gap: 2, padding: 2, paddingBlockEnd: 3 }}>
2238
+ <StepForm readOnly={accessInfo.viewMode} revealWarnings={revealWarnings} />
2239
+ </Box>
2240
+ </Box>
2241
+
2242
+ {isBreakdownOpen && (
2243
+ <Box
2244
+ component="div"
2245
+ onClick={() => setBreakdownOpen(false)}
2246
+ sx={{ position: 'absolute', inset: 0, bgcolor: 'rgba(13, 27, 46, 0.4)' }}
2247
+ />
2248
+ )}
2249
+
2250
+ <Box
2251
+ component="footer"
2252
+ sx={{
2253
+ position: 'relative',
2254
+ display: 'grid',
2255
+ gap: 1.5,
2256
+ padding: 2,
2257
+ bgcolor: ARDENT.panel,
2258
+ borderBlockStart: `1px solid ${ARDENT.line}`,
2259
+ boxShadow: '0 -8px 24px rgba(13, 27, 46, 0.08)',
2260
+ }}
2261
+ >
2262
+ {isBreakdownOpen && (
2263
+ <Box component="div" sx={{ maxBlockSize: '55vh', overflowY: 'auto', paddingBlockEnd: 1 }}>
2264
+ <PriceBreakdown />
2265
+ </Box>
2266
+ )}
2267
+ <Box component="div" sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
2268
+ <ButtonBase
2269
+ aria-expanded={isBreakdownOpen}
2270
+ aria-label={isBreakdownOpen ? 'Hide the price breakdown' : 'Show the price breakdown'}
2271
+ onClick={() => setBreakdownOpen(open => !open)}
2272
+ sx={{ display: 'grid', justifyItems: 'start', textAlign: 'start', borderRadius: 1, paddingInlineEnd: 1 }}
2273
+ >
2274
+ <Typography
2275
+ component="span"
2276
+ sx={{
2277
+ display: 'flex',
2278
+ alignItems: 'center',
2279
+ gap: 0.5,
2280
+ fontFamily: ARDENT.font,
2281
+ fontSize: 13,
2282
+ color: ARDENT.steel,
2283
+ }}
2284
+ >
2285
+ Total, {compressor.$leadTimeWeeks} weeks
2286
+ <Box
2287
+ component="svg"
2288
+ viewBox="0 0 24 24"
2289
+ aria-hidden
2290
+ sx={{
2291
+ inlineSize: 16,
2292
+ blockSize: 16,
2293
+ transform: isBreakdownOpen ? 'rotate(180deg)' : 'none',
2294
+ transition: 'transform 160ms',
2295
+ }}
2296
+ >
2297
+ <path d={CHEVRON_ICON} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
2298
+ </Box>
2299
+ </Typography>
2300
+ <Typography
2301
+ component="span"
2302
+ sx={{ fontFamily: ARDENT.font, fontSize: 22, fontWeight: 700, color: ARDENT.ink }}
2303
+ >
2304
+ {total}
2305
+ </Typography>
2306
+ </ButtonBase>
2307
+ <Box component="div" sx={{ flex: 1, minInlineSize: 0 }}>
2308
+ {isLast ? (
2309
+ navigationState !== 'requested' && <QuoteAction onBlocked={() => setRevealWarnings(true)} compact />
2310
+ ) : (
2311
+ <Button
2312
+ fullWidth
2313
+ variant="contained"
2314
+ disableElevation
2315
+ onClick={next}
2316
+ sx={{
2317
+ minBlockSize: 48,
2318
+ borderRadius: 2,
2319
+ bgcolor: ARDENT.navy,
2320
+ fontFamily: ARDENT.font,
2321
+ fontSize: 16,
2322
+ fontWeight: 600,
2323
+ textTransform: 'none',
2324
+ '&:hover': { bgcolor: ARDENT.ink },
2325
+ }}
2326
+ >
2327
+ Next: {nextStep?.label}
2328
+ </Button>
2329
+ )}
2330
+ </Box>
2331
+ </Box>
2332
+ </Box>
2333
+
2334
+ <LayoutSlots.MobileSnackbarsRoot />
2335
+ </Box>
2336
+ )
2337
+ }
2338
+ ```
2339
+
2340
+ ### ./src/\_shared/PriceBreakdown.tsx
2341
+
2342
+ ```tsx
2343
+ import { Box, Typography } from '@mui/material'
2344
+
2345
+ import { phx } from '../../.phoenix/phx.js'
2346
+ import { ARDENT, OVERLINE_SX } from '../_form/index.js'
2347
+
2348
+ const formatEuro = (amount: number) => {
2349
+ return amount.toLocaleString('en-IE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })
2350
+ }
2351
+
2352
+ const ROW_SX = {
2353
+ display: 'grid',
2354
+ gridTemplateColumns: '1fr auto',
2355
+ columnGap: 2,
2356
+ alignItems: 'baseline',
2357
+ paddingBlock: 1,
2358
+ } as const
2359
+
2360
+ // The quote's lines, subtotal, lead-time surcharge and total. Every number comes from an expression of the spec
2361
+ // (`$quoteLines`, `$subtotal`, `$leadTimeSurcharge`, `$leadTimeWeeks`), read from the root entity, so the breakdown
2362
+ // changes with each choice, and the total is the `price` component's value.
2363
+ export const PriceBreakdown = () => {
2364
+ const compressor = phx.frontend.useRootEntityState()
2365
+ const total = phx.frontend.useRootEntityPrice()
2366
+
2367
+ return (
2368
+ <Box component="section" aria-label="Price breakdown" sx={{ display: 'grid', fontFamily: ARDENT.font }}>
2369
+ <Typography component="h2" sx={{ ...OVERLINE_SX, paddingBlockEnd: 1 }}>
2370
+ Price breakdown
2371
+ </Typography>
2372
+
2373
+ <Box component="ul" sx={{ margin: 0, padding: 0, listStyle: 'none' }}>
2374
+ {compressor.$quoteLines.map(line => (
2375
+ <Box component="li" key={line.key} sx={{ ...ROW_SX, borderBlockEnd: `1px solid ${ARDENT.line}` }}>
2376
+ <Box component="div" sx={{ display: 'grid', minInlineSize: 0 }}>
2377
+ <Typography
2378
+ component="span"
2379
+ sx={{ fontFamily: ARDENT.font, fontSize: 15, fontWeight: 500, color: ARDENT.ink }}
2380
+ >
2381
+ {line.label}
2382
+ </Typography>
2383
+ <Typography component="span" sx={{ fontFamily: ARDENT.font, fontSize: 13, color: ARDENT.steel }}>
2384
+ {line.detail}
2385
+ </Typography>
2386
+ </Box>
2387
+ <Typography
2388
+ component="span"
2389
+ sx={{ fontFamily: ARDENT.font, fontSize: 15, color: ARDENT.ink, fontVariantNumeric: 'tabular-nums' }}
2390
+ >
2391
+ {formatEuro(line.amount)}
2392
+ </Typography>
2393
+ </Box>
2394
+ ))}
2395
+ </Box>
2396
+
2397
+ <Box component="div" sx={{ ...ROW_SX, paddingBlockStart: 1.5 }}>
2398
+ <Typography component="span" sx={{ fontFamily: ARDENT.font, fontSize: 15, color: ARDENT.steel }}>
2399
+ Subtotal
2400
+ </Typography>
2401
+ <Typography
2402
+ component="span"
2403
+ sx={{ fontFamily: ARDENT.font, fontSize: 15, color: ARDENT.ink, fontVariantNumeric: 'tabular-nums' }}
2404
+ >
2405
+ {formatEuro(compressor.$subtotal)}
2406
+ </Typography>
2407
+ </Box>
2408
+ {compressor.$leadTimeSurcharge > 0 && (
2409
+ <Box component="div" sx={ROW_SX}>
2410
+ <Typography component="span" sx={{ fontFamily: ARDENT.font, fontSize: 15, color: ARDENT.steel }}>
2411
+ Express delivery surcharge
2412
+ </Typography>
2413
+ <Typography
2414
+ component="span"
2415
+ sx={{ fontFamily: ARDENT.font, fontSize: 15, color: ARDENT.ink, fontVariantNumeric: 'tabular-nums' }}
2416
+ >
2417
+ {formatEuro(compressor.$leadTimeSurcharge)}
2418
+ </Typography>
2419
+ </Box>
2420
+ )}
2421
+
2422
+ <Box
2423
+ component="div"
2424
+ sx={{ ...ROW_SX, marginBlockStart: 1, paddingBlockStart: 1.5, borderBlockStart: `2px solid ${ARDENT.ink}` }}
2425
+ >
2426
+ <Typography component="span" sx={{ fontFamily: ARDENT.font, fontSize: 17, fontWeight: 600, color: ARDENT.ink }}>
2427
+ Total, ex works
2428
+ </Typography>
2429
+ <Typography
2430
+ component="span"
2431
+ sx={{
2432
+ fontFamily: ARDENT.font,
2433
+ fontSize: 26,
2434
+ fontWeight: 700,
2435
+ color: ARDENT.ink,
2436
+ fontVariantNumeric: 'tabular-nums',
2437
+ }}
2438
+ >
2439
+ {total}
2440
+ </Typography>
2441
+ </Box>
2442
+ <Typography sx={{ fontFamily: ARDENT.font, fontSize: 14, color: ARDENT.steel, textAlign: 'end' }}>
2443
+ Lead time {compressor.$leadTimeWeeks} weeks · prices excl. VAT
2444
+ </Typography>
2445
+ </Box>
2446
+ )
2447
+ }
2448
+ ```
2449
+
2450
+ ### ./src/\_shared/QuoteAction.tsx
2451
+
2452
+ ```tsx
2453
+ import { Alert, Box, Button, CircularProgress, Typography } from '@mui/material'
2454
+
2455
+ import { phx } from '../../.phoenix/phx.js'
2456
+ import { ARDENT } from '../_form/index.js'
2457
+
2458
+ interface QuoteActionProps {
2459
+ // Called instead of running the action while a warning blocks it, so the layout can show every warning
2460
+ readonly onBlocked: () => void
2461
+ // Leaves out the list of what blocks the request, for the phone's bottom bar
2462
+ readonly compact: boolean
2463
+ }
2464
+
2465
+ // The spec's `quote` finalize action. While any warning holds, `useFinalizeActions` reports the action `disabled` with
2466
+ // the warnings as `hints`: the button stays clickable and reveals them instead of running. A viewer sees no button.
2467
+ export const QuoteAction = ({ onBlocked, compact }: QuoteActionProps) => {
2468
+ const { primary, hints } = phx.frontend.useFinalizeActions()
2469
+ const { accessInfo } = phx.frontend.useAccessService()
2470
+
2471
+ if (primary === null || accessInfo.viewMode) {
2472
+ return null
2473
+ }
2474
+
2475
+ return (
2476
+ <Box component="div" sx={{ display: 'grid', gap: 1.25 }}>
2477
+ <Button
2478
+ variant="contained"
2479
+ disableElevation
2480
+ disabled={primary.isPending}
2481
+ aria-disabled={primary.disabled}
2482
+ title={primary.title}
2483
+ onClick={() => (primary.disabled ? onBlocked() : void primary.run())}
2484
+ startIcon={primary.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
2485
+ sx={{
2486
+ minBlockSize: 52,
2487
+ borderRadius: 2,
2488
+ bgcolor: primary.disabled ? ARDENT.steel : ARDENT.signal,
2489
+ color: ARDENT.panel,
2490
+ fontFamily: ARDENT.font,
2491
+ fontSize: 17,
2492
+ fontWeight: 600,
2493
+ textTransform: 'none',
2494
+ '&:hover': { bgcolor: primary.disabled ? ARDENT.steel : ARDENT.signalDark },
2495
+ '&.Mui-disabled': { bgcolor: ARDENT.signal, color: ARDENT.panel, opacity: 0.7 },
2496
+ }}
2497
+ >
2498
+ {primary.isPending ? 'Sending to an engineer…' : primary.label}
2499
+ </Button>
2500
+ {!compact && primary.disabled && hints && (
2501
+ <Typography sx={{ fontFamily: ARDENT.font, fontSize: 14, lineHeight: 1.4, color: ARDENT.warning }}>
2502
+ Resolve before requesting: {hints}
2503
+ </Typography>
2504
+ )}
2505
+ {primary.error && (
2506
+ <Alert severity="error">The quote could not be sent: {primary.error.message} Try again in a moment.</Alert>
2507
+ )}
2508
+ </Box>
2509
+ )
2510
+ }
2511
+ ```
2512
+
2513
+ ### ./src/\_shared/ReviewStep.tsx
2514
+
2515
+ ```tsx
2516
+ import { Box, Button, Typography } from '@mui/material'
2517
+
2518
+ import { phx } from '../../.phoenix/phx.js'
2519
+ import { ARDENT, OVERLINE_SX, TextInputField, useWarnings } from '../_form/index.js'
2520
+ import { setQuoteReceipt, useQuoteReceipt } from './quote-receipt.js'
2521
+ import { STEP_WARNINGS } from './step-warnings.js'
2522
+
2523
+ const CARD_SX = {
2524
+ display: 'grid',
2525
+ gap: 2.5,
2526
+ padding: { xs: 2.5, sm: 3 },
2527
+ borderRadius: 3,
2528
+ border: `1px solid ${ARDENT.line}`,
2529
+ bgcolor: ARDENT.panel,
2530
+ } as const
2531
+
2532
+ // The quote service's answer, kept visible after the request: what to quote against and who answers
2533
+ const Receipt = () => {
2534
+ const receipt = useQuoteReceipt()
2535
+ const navigate = phx.frontend.useNavigate()
2536
+ // The answer lives only as long as the page; a configuration reopened in the `requested` state shows no details
2537
+ const rows =
2538
+ receipt === null
2539
+ ? []
2540
+ : [
2541
+ { label: 'Reference', value: receipt.reference },
2542
+ { label: 'Your engineer', value: receipt.engineer },
2543
+ { label: 'Price valid until', value: receipt.validUntil },
2544
+ { label: 'Quoted total', value: receipt.total },
2545
+ ]
2546
+
2547
+ return (
2548
+ <Box
2549
+ component="section"
2550
+ role="status"
2551
+ sx={{ ...CARD_SX, borderColor: ARDENT.success, bgcolor: ARDENT.successTint }}
2552
+ >
2553
+ <Box component="header" sx={{ display: 'grid', gap: 0.5 }}>
2554
+ <Typography
2555
+ component="h3"
2556
+ sx={{ fontFamily: ARDENT.font, fontSize: 24, fontWeight: 700, color: ARDENT.success }}
2557
+ >
2558
+ Quote requested
2559
+ </Typography>
2560
+ <Typography sx={{ fontFamily: ARDENT.font, fontSize: 15, color: ARDENT.ink }}>
2561
+ Your engineer checks the duty and sends a firm quote within one working day.
2562
+ </Typography>
2563
+ </Box>
2564
+ <Box
2565
+ component="dl"
2566
+ sx={{ display: 'grid', gridTemplateColumns: 'max-content 1fr', columnGap: 3, rowGap: 1, margin: 0 }}
2567
+ >
2568
+ {rows.map(row => (
2569
+ <Box component="div" key={row.label} sx={{ display: 'contents' }}>
2570
+ <Typography component="dt" sx={{ fontFamily: ARDENT.font, fontSize: 15, color: ARDENT.steel }}>
2571
+ {row.label}
2572
+ </Typography>
2573
+ <Typography
2574
+ component="dd"
2575
+ sx={{ margin: 0, fontFamily: ARDENT.font, fontSize: 15, fontWeight: 600, color: ARDENT.ink }}
2576
+ >
2577
+ {row.value}
2578
+ </Typography>
2579
+ </Box>
2580
+ ))}
2581
+ </Box>
2582
+ <Box component="div">
2583
+ <Button
2584
+ variant="outlined"
2585
+ onClick={() => {
2586
+ setQuoteReceipt(null)
2587
+ navigate('review')
2588
+ }}
2589
+ sx={{
2590
+ borderColor: ARDENT.ink,
2591
+ color: ARDENT.ink,
2592
+ fontFamily: ARDENT.font,
2593
+ fontSize: 15,
2594
+ textTransform: 'none',
2595
+ }}
2596
+ >
2597
+ Back to summary
2598
+ </Button>
2599
+ </Box>
2600
+ </Box>
2601
+ )
2602
+ }
2603
+
2604
+ // The conflicts left between choices, each with a way to the step that resolves it
2605
+ const Conflicts = () => {
2606
+ const warnings = useWarnings()
2607
+ const { steps, goTo } = phx.frontend.useStepNavigation()
2608
+ const conflicts = warnings.flatMap(warning => {
2609
+ const step = steps.find(candidate => STEP_WARNINGS[candidate.state]?.includes(warning.type))
2610
+
2611
+ return step ? [{ ...warning, step }] : []
2612
+ })
2613
+
2614
+ if (conflicts.length === 0) {
2615
+ return null
2616
+ }
2617
+
2618
+ return (
2619
+ <Box
2620
+ component="section"
2621
+ role="alert"
2622
+ sx={{ ...CARD_SX, gap: 1.5, borderColor: ARDENT.warning, bgcolor: ARDENT.warningTint }}
2623
+ >
2624
+ <Typography component="h3" sx={{ fontFamily: ARDENT.font, fontSize: 18, fontWeight: 600, color: ARDENT.warning }}>
2625
+ {conflicts.length === 1 ? 'One choice needs attention' : `${conflicts.length} choices need attention`}
2626
+ </Typography>
2627
+ {conflicts.map(conflict => (
2628
+ <Box
2629
+ component="div"
2630
+ key={conflict.type}
2631
+ sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2 }}
2632
+ >
2633
+ <Typography sx={{ fontFamily: ARDENT.font, fontSize: 15, color: ARDENT.ink }}>{conflict.text}</Typography>
2634
+ <Button
2635
+ onClick={() => goTo(conflict.step.index)}
2636
+ sx={{
2637
+ flex: 'none',
2638
+ color: ARDENT.warning,
2639
+ fontFamily: ARDENT.font,
2640
+ fontSize: 15,
2641
+ fontWeight: 600,
2642
+ textTransform: 'none',
2643
+ }}
2644
+ >
2645
+ Go to {conflict.step.label}
2646
+ </Button>
2647
+ </Box>
2648
+ ))}
2649
+ </Box>
2650
+ )
2651
+ }
2652
+
2653
+ interface ReviewStepProps {
2654
+ readonly readOnly: boolean
2655
+ readonly revealWarnings: boolean
2656
+ }
2657
+
2658
+ // The last step: the conflicts left, the contact details the quote needs, and the summary of what is quoted, from the
2659
+ // spec's `$specSheet` expression. After the request, the service's answer takes the place of the contact details.
2660
+ export const ReviewStep = ({ readOnly, revealWarnings }: ReviewStepProps) => {
2661
+ const compressor = phx.frontend.useRootEntityState()
2662
+ const navigationState = phx.frontend.useActiveNavigationState()
2663
+ const isRequested = navigationState === 'requested'
2664
+
2665
+ return (
2666
+ <>
2667
+ {isRequested ? (
2668
+ <Receipt />
2669
+ ) : (
2670
+ <>
2671
+ <Conflicts />
2672
+ <Box component="section" sx={CARD_SX}>
2673
+ <Typography component="h3" sx={OVERLINE_SX}>
2674
+ Your details
2675
+ </Typography>
2676
+ <Box component="div" sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2.5 }}>
2677
+ <TextInputField
2678
+ instanceName="company"
2679
+ label="Company"
2680
+ warningTypes={['companyMissing']}
2681
+ readOnly={readOnly}
2682
+ autoComplete="organization"
2683
+ revealWarning={revealWarnings}
2684
+ />
2685
+ <TextInputField
2686
+ instanceName="email"
2687
+ label="Email"
2688
+ type="email"
2689
+ warningTypes={['emailInvalid']}
2690
+ readOnly={readOnly}
2691
+ autoComplete="email"
2692
+ revealWarning={revealWarnings}
2693
+ />
2694
+ </Box>
2695
+ <TextInputField
2696
+ instanceName="projectReference"
2697
+ label="Project reference"
2698
+ hint="Optional. It is printed on the quote."
2699
+ readOnly={readOnly}
2700
+ revealWarning={revealWarnings}
2701
+ />
2702
+ </Box>
2703
+ </>
2704
+ )}
2705
+
2706
+ <Box component="section" sx={CARD_SX}>
2707
+ <Typography component="h3" sx={OVERLINE_SX}>
2708
+ {isRequested ? 'What was quoted' : 'Quote summary'}
2709
+ </Typography>
2710
+ <Box
2711
+ component="dl"
2712
+ sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, columnGap: 4, margin: 0 }}
2713
+ >
2714
+ {compressor.$specSheet.map(row => (
2715
+ <Box
2716
+ component="div"
2717
+ key={row.label}
2718
+ sx={{
2719
+ display: 'flex',
2720
+ justifyContent: 'space-between',
2721
+ gap: 2,
2722
+ paddingBlock: 1,
2723
+ borderBlockEnd: `1px solid ${ARDENT.line}`,
2724
+ }}
2725
+ >
2726
+ <Typography component="dt" sx={{ fontFamily: ARDENT.font, fontSize: 15, color: ARDENT.steel }}>
2727
+ {row.label}
2728
+ </Typography>
2729
+ <Typography
2730
+ component="dd"
2731
+ sx={{
2732
+ margin: 0,
2733
+ fontFamily: ARDENT.font,
2734
+ fontSize: 15,
2735
+ fontWeight: 500,
2736
+ color: ARDENT.ink,
2737
+ textAlign: 'end',
2738
+ }}
2739
+ >
2740
+ {row.value}
2741
+ </Typography>
2742
+ </Box>
2743
+ ))}
2744
+ </Box>
2745
+ </Box>
2746
+ </>
2747
+ )
2748
+ }
2749
+ ```
2750
+
2751
+ ### ./src/\_shared/StepForm.tsx
2752
+
2753
+ ```tsx
2754
+ import { Box, Typography } from '@mui/material'
2755
+ import type { ReactNode } from 'react'
2756
+
2757
+ import { phx } from '../../.phoenix/phx.js'
2758
+ import { ARDENT, ChoiceField, NumberField, OVERLINE_SX, SwitchField } from '../_form/index.js'
2759
+ import { ReviewStep } from './ReviewStep.js'
2760
+
2761
+ interface SectionProps {
2762
+ readonly title: string
2763
+ readonly description?: string
2764
+ readonly children: ReactNode
2765
+ }
2766
+
2767
+ const Section = ({ title, description, children }: SectionProps) => {
2768
+ return (
2769
+ <Box
2770
+ component="section"
2771
+ sx={{
2772
+ display: 'grid',
2773
+ gap: 3,
2774
+ padding: { xs: 2.5, sm: 3 },
2775
+ borderRadius: 3,
2776
+ border: `1px solid ${ARDENT.line}`,
2777
+ bgcolor: ARDENT.panel,
2778
+ }}
2779
+ >
2780
+ <Box component="header" sx={{ display: 'grid', gap: 0.5 }}>
2781
+ <Typography component="h3" sx={OVERLINE_SX}>
2782
+ {title}
2783
+ </Typography>
2784
+ {description && (
2785
+ <Typography sx={{ fontFamily: ARDENT.font, fontSize: 15, color: ARDENT.steel }}>{description}</Typography>
2786
+ )}
2787
+ </Box>
2788
+ {children}
2789
+ </Box>
2790
+ )
2791
+ }
2792
+
2793
+ // The frame the duty selects, from the spec's expressions, so the customer sees what the numbers mean
2794
+ const FrameStrip = () => {
2795
+ const compressor = phx.frontend.useRootEntityState()
2796
+
2797
+ return (
2798
+ <Box
2799
+ component="div"
2800
+ role="status"
2801
+ sx={{
2802
+ display: 'flex',
2803
+ flexWrap: 'wrap',
2804
+ alignItems: 'baseline',
2805
+ columnGap: 1.5,
2806
+ rowGap: 0.5,
2807
+ paddingInline: 2,
2808
+ paddingBlock: 1.5,
2809
+ borderRadius: 2,
2810
+ bgcolor: ARDENT.navy,
2811
+ color: ARDENT.panel,
2812
+ fontFamily: ARDENT.font,
2813
+ }}
2814
+ >
2815
+ <Typography component="span" sx={{ ...OVERLINE_SX, color: '#9fb0c6' }}>
2816
+ Selected frame
2817
+ </Typography>
2818
+ <Typography component="span" sx={{ fontFamily: ARDENT.font, fontSize: 20, fontWeight: 700 }}>
2819
+ {compressor.$frameName}
2820
+ </Typography>
2821
+ <Typography component="span" sx={{ fontFamily: ARDENT.font, fontSize: 15, color: '#c9d3e0' }}>
2822
+ {compressor.$frameKw} kW motor, the smallest frame that meets the duty
2823
+ </Typography>
2824
+ </Box>
2825
+ )
2826
+ }
2827
+
2828
+ interface StepFormProps {
2829
+ readonly readOnly: boolean
2830
+ readonly revealWarnings: boolean
2831
+ }
2832
+
2833
+ // The form of the active step. Each section lists the fields the step asks for; a field whose spec component is
2834
+ // inactive, such as the dew point without a desiccant dryer, renders nothing, so the conditions stay in the spec.
2835
+ export const StepForm = ({ readOnly, revealWarnings }: StepFormProps) => {
2836
+ const navigationState = phx.frontend.useActiveNavigationState()
2837
+ const compressor = phx.frontend.useRootEntityState()
2838
+ const receiverHint =
2839
+ compressor.$minReceiverLitres > 0
2840
+ ? `A fixed-speed machine at this flow needs at least ${compressor.$minReceiverLitres.toLocaleString('en')} L.`
2841
+ : 'A variable-speed machine follows demand and needs no large buffer.'
2842
+
2843
+ switch (navigationState) {
2844
+ case 'duty':
2845
+ return (
2846
+ <>
2847
+ <Section title="Air demand" description="How much air the plant draws at peak, and at what pressure.">
2848
+ <NumberField
2849
+ instanceName="flow"
2850
+ label="Free air delivery"
2851
+ hint="Read it from the plate of the machine you replace, or from a flow log."
2852
+ warningTypes={['flowAboveRange']}
2853
+ readOnly={readOnly}
2854
+ step={0.5}
2855
+ />
2856
+ <ChoiceField instanceName="pressure" label="Working pressure" readOnly={readOnly} />
2857
+ <FrameStrip />
2858
+ </Section>
2859
+ <Section title="Drive and duty">
2860
+ <ChoiceField instanceName="drive" label="Drive" readOnly={readOnly} minCardWidth={200} />
2861
+ <ChoiceField instanceName="dutyCycle" label="Duty cycle" readOnly={readOnly} minCardWidth={200} />
2862
+ </Section>
2863
+ </>
2864
+ )
2865
+ case 'treatment':
2866
+ return (
2867
+ <>
2868
+ <Section title="Drying" description="Compressed air leaves the machine saturated with water.">
2869
+ <ChoiceField instanceName="dryer" label="Dryer" readOnly={readOnly} />
2870
+ <ChoiceField instanceName="dewPoint" label="Pressure dew point" readOnly={readOnly} minCardWidth={200} />
2871
+ </Section>
2872
+ <Section title="Filtration">
2873
+ <ChoiceField
2874
+ instanceName="filtration"
2875
+ label="Filter package"
2876
+ warningTypes={['desiccantNeedsFiltration']}
2877
+ readOnly={readOnly}
2878
+ />
2879
+ </Section>
2880
+ </>
2881
+ )
2882
+ case 'installation':
2883
+ return (
2884
+ <>
2885
+ <Section title="Plant room">
2886
+ <ChoiceField instanceName="cooling" label="Cooling" readOnly={readOnly} minCardWidth={200} />
2887
+ <SwitchField
2888
+ instanceName="heatRecovery"
2889
+ label="Heat recovery"
2890
+ description="Turns up to 70 % of the motor's power into hot water for the plant."
2891
+ readOnly={readOnly}
2892
+ />
2893
+ <ChoiceField instanceName="enclosure" label="Canopy" readOnly={readOnly} minCardWidth={200} />
2894
+ </Section>
2895
+ <Section title="Air network">
2896
+ <ChoiceField
2897
+ instanceName="receiver"
2898
+ label="Air receiver"
2899
+ hint={receiverHint}
2900
+ warningTypes={['receiverTooSmall']}
2901
+ readOnly={readOnly}
2902
+ minCardWidth={110}
2903
+ />
2904
+ <ChoiceField instanceName="supply" label="Electrical supply" readOnly={readOnly} minCardWidth={200} />
2905
+ </Section>
2906
+ </>
2907
+ )
2908
+ case 'service':
2909
+ return (
2910
+ <>
2911
+ <Section title="Service">
2912
+ <ChoiceField instanceName="servicePlan" label="Service plan" readOnly={readOnly} />
2913
+ <SwitchField
2914
+ instanceName="commissioning"
2915
+ label="Commissioning on site"
2916
+ description="An Ardent engineer starts the machine and trains your staff."
2917
+ readOnly={readOnly}
2918
+ />
2919
+ </Section>
2920
+ <Section title="Delivery" description="Lead times are ex works, from the order's confirmation.">
2921
+ <ChoiceField
2922
+ instanceName="delivery"
2923
+ label="Delivery"
2924
+ warningTypes={['expressUnavailable']}
2925
+ readOnly={readOnly}
2926
+ minCardWidth={200}
2927
+ />
2928
+ </Section>
2929
+ </>
2930
+ )
2931
+ default:
2932
+ return <ReviewStep readOnly={readOnly} revealWarnings={revealWarnings} />
2933
+ }
2934
+ }
2935
+ ```
2936
+
2937
+ ### ./src/\_shared/StepHeader.tsx
2938
+
2939
+ ```tsx
2940
+ import { Box, ButtonBase, Typography } from '@mui/material'
2941
+
2942
+ import { phx } from '../../.phoenix/phx.js'
2943
+ import { ARDENT, useWarnings } from '../_form/index.js'
2944
+ import { STEP_WARNINGS } from './step-warnings.js'
2945
+
2946
+ const CHECK_ICON = 'M5 12.5 10 17.5 19 7'
2947
+
2948
+ interface StepHeaderProps {
2949
+ readonly compact: boolean
2950
+ }
2951
+
2952
+ // The spec's `navigationSteps` as a stepper. Every step can be entered, in any order; a step that holds a warning
2953
+ // carries a mark until the warning is resolved. On a phone the stepper is one line with a segmented progress bar.
2954
+ export const StepHeader = ({ compact }: StepHeaderProps) => {
2955
+ const { steps, activeStep, goTo } = phx.frontend.useStepNavigation()
2956
+ const warnings = useWarnings()
2957
+
2958
+ const hasWarning = (states: readonly string[]) => {
2959
+ return states.some(state => warnings.some(warning => STEP_WARNINGS[state]?.includes(warning.type)))
2960
+ }
2961
+
2962
+ if (compact) {
2963
+ return (
2964
+ <Box
2965
+ component="nav"
2966
+ aria-label="Quote steps"
2967
+ sx={{ display: 'grid', gap: 1, paddingInline: 2, paddingBlock: 1.5 }}
2968
+ >
2969
+ <Box component="div" sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
2970
+ <Typography sx={{ fontFamily: ARDENT.font, fontSize: 13, color: ARDENT.steel }}>
2971
+ Step {(activeStep?.index ?? 0) + 1} of {steps.length}
2972
+ </Typography>
2973
+ <Typography component="h2" sx={{ fontFamily: ARDENT.font, fontSize: 17, fontWeight: 600, color: ARDENT.ink }}>
2974
+ {activeStep?.label}
2975
+ </Typography>
2976
+ </Box>
2977
+ <Box component="ol" sx={{ display: 'flex', gap: 0.5, margin: 0, padding: 0, listStyle: 'none' }}>
2978
+ {steps.map(step => (
2979
+ <Box component="li" key={step.state} sx={{ flex: 1 }}>
2980
+ <ButtonBase
2981
+ aria-label={`${step.label}${hasWarning(step.navigationStates) ? ', needs attention' : ''}`}
2982
+ aria-current={step.status === 'active' ? 'step' : undefined}
2983
+ onClick={() => goTo(step.index)}
2984
+ sx={{
2985
+ display: 'block',
2986
+ inlineSize: '100%',
2987
+ blockSize: 6,
2988
+ borderRadius: 3,
2989
+ // A bigger target than the bar itself, for a finger
2990
+ outline: '8px solid transparent',
2991
+ bgcolor: hasWarning(step.navigationStates)
2992
+ ? ARDENT.warning
2993
+ : step.status === 'upcoming'
2994
+ ? ARDENT.line
2995
+ : ARDENT.signal,
2996
+ }}
2997
+ />
2998
+ </Box>
2999
+ ))}
3000
+ </Box>
3001
+ </Box>
3002
+ )
3003
+ }
3004
+
3005
+ return (
3006
+ <Box
3007
+ component="nav"
3008
+ aria-label="Quote steps"
3009
+ sx={{ display: 'flex', alignItems: 'center', gap: 1, paddingInline: 4, paddingBlock: 1.5, overflowX: 'auto' }}
3010
+ >
3011
+ {steps.map((step, index) => {
3012
+ const warn = hasWarning(step.navigationStates)
3013
+ const isActive = step.status === 'active'
3014
+ const isComplete = step.status === 'complete'
3015
+
3016
+ return (
3017
+ <Box component="div" key={step.state} sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 'none' }}>
3018
+ {index > 0 && (
3019
+ <Box
3020
+ component="span"
3021
+ aria-hidden
3022
+ sx={{ inlineSize: 32, blockSize: 2, bgcolor: isComplete || isActive ? ARDENT.signal : ARDENT.line }}
3023
+ />
3024
+ )}
3025
+ <ButtonBase
3026
+ aria-current={isActive ? 'step' : undefined}
3027
+ onClick={() => goTo(step.index)}
3028
+ sx={{
3029
+ display: 'flex',
3030
+ alignItems: 'center',
3031
+ gap: 1,
3032
+ paddingInline: 1,
3033
+ paddingBlock: 0.75,
3034
+ borderRadius: 2,
3035
+ '&:hover': { bgcolor: ARDENT.page },
3036
+ '&.Mui-focusVisible': { outline: `2px solid ${ARDENT.navy}` },
3037
+ }}
3038
+ >
3039
+ <Box
3040
+ component="span"
3041
+ sx={{
3042
+ display: 'grid',
3043
+ placeItems: 'center',
3044
+ inlineSize: 28,
3045
+ blockSize: 28,
3046
+ borderRadius: '50%',
3047
+ fontFamily: ARDENT.font,
3048
+ fontSize: 14,
3049
+ fontWeight: 600,
3050
+ color: isActive || isComplete ? ARDENT.panel : ARDENT.steel,
3051
+ bgcolor: isActive ? ARDENT.signal : isComplete ? ARDENT.navy : 'transparent',
3052
+ border: isActive || isComplete ? 'none' : `2px solid ${ARDENT.line}`,
3053
+ }}
3054
+ >
3055
+ {isComplete ? (
3056
+ <Box component="svg" viewBox="0 0 24 24" aria-hidden sx={{ inlineSize: 16, blockSize: 16 }}>
3057
+ <path d={CHECK_ICON} fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" />
3058
+ </Box>
3059
+ ) : (
3060
+ index + 1
3061
+ )}
3062
+ </Box>
3063
+ <Typography
3064
+ component="span"
3065
+ sx={{
3066
+ fontFamily: ARDENT.font,
3067
+ fontSize: 15,
3068
+ fontWeight: isActive ? 600 : 500,
3069
+ color: isActive ? ARDENT.ink : ARDENT.steel,
3070
+ whiteSpace: 'nowrap',
3071
+ }}
3072
+ >
3073
+ {step.label}
3074
+ </Typography>
3075
+ {warn && (
3076
+ <Box
3077
+ component="span"
3078
+ role="img"
3079
+ aria-label="needs attention"
3080
+ sx={{ inlineSize: 8, blockSize: 8, borderRadius: '50%', bgcolor: ARDENT.warning }}
3081
+ />
3082
+ )}
3083
+ </ButtonBase>
3084
+ </Box>
3085
+ )
3086
+ })}
3087
+ </Box>
3088
+ )
3089
+ }
3090
+ ```
3091
+
3092
+ ### ./src/\_shared/quote-receipt.ts
3093
+
3094
+ ```ts
3095
+ import { useSyncExternalStore } from 'react'
3096
+
3097
+ /** What the quote service answers when a quote is requested: the reference the engineer will quote against */
3098
+ export interface QuoteReceipt {
3099
+ readonly reference: string
3100
+ readonly engineer: string
3101
+ readonly validUntil: string
3102
+ readonly total: string
3103
+ }
3104
+
3105
+ let receipt: QuoteReceipt | null = null
3106
+ const listeners = new Set<() => void>()
3107
+
3108
+ const subscribe = (listener: () => void) => {
3109
+ listeners.add(listener)
3110
+
3111
+ return () => {
3112
+ listeners.delete(listener)
3113
+ }
3114
+ }
3115
+
3116
+ // A finalize action's `run()` resolves without a value, so the `requestQuote` handler keeps the service's answer here
3117
+ // for the layout to show. The project owns this store; the configurator does not persist it.
3118
+ export const setQuoteReceipt = (next: QuoteReceipt | null) => {
3119
+ receipt = next
3120
+ listeners.forEach(listener => listener())
3121
+ }
3122
+
3123
+ export const useQuoteReceipt = () => {
3124
+ return useSyncExternalStore(subscribe, () => receipt)
3125
+ }
3126
+ ```
3127
+
3128
+ ### ./src/\_shared/step-warnings.ts
3129
+
3130
+ ```ts
3131
+ // Which step resolves each conflict between choices: the stepper marks a step that holds one, and the review sends the
3132
+ // customer to the step that can fix it. The contact details' warnings stay with their fields on the review step.
3133
+ export const STEP_WARNINGS: Readonly<Record<string, readonly string[]>> = {
3134
+ duty: ['flowAboveRange'],
3135
+ treatment: ['desiccantNeedsFiltration'],
3136
+ installation: ['receiverTooSmall'],
3137
+ service: ['expressUnavailable'],
3138
+ }
3139
+ ```
3140
+
3141
+ ### ./src/elements/ConfiguratorLayout.tsx
3142
+
3143
+ ```tsx
3144
+ import { Box } from '@mui/material'
3145
+
3146
+ import { LayoutSlots } from '@salsita-ai/sdk/configurator'
3147
+
3148
+ import { phx } from '../../.phoenix/phx.js'
3149
+ import { ARDENT } from '../_form/index.js'
3150
+ import { DesktopPage } from '../_shared/DesktopPage.js'
3151
+ import { PhonePage } from '../_shared/PhonePage.js'
3152
+
3153
+ // A quote builder with no scene: the layout mounts the services and a page, never `LayoutSlots.SharedCanvas`, so the
3154
+ // configurator loads no 3D scene and the app loader does not wait for one. Desktop or phone follows the width of the
3155
+ // configurator's container.
3156
+ const ConfiguratorLayout = () => {
3157
+ // `null` until the container has been measured
3158
+ const viewports = phx.frontend.useActiveViewports()
3159
+ const isDesktop = viewports?.includes('desktop') ?? false
3160
+
3161
+ return (
3162
+ <Box component="div" sx={{ position: 'absolute', inset: 0, overflow: 'clip', bgcolor: ARDENT.page }}>
3163
+ <LayoutSlots.Services />
3164
+ {viewports !== null && (isDesktop ? <DesktopPage /> : <PhonePage />)}
3165
+ </Box>
3166
+ )
3167
+ }
3168
+
3169
+ export default phx.frontend.createCustomPhoenixLayoutElement('ConfiguratorLayout', ConfiguratorLayout)
3170
+ ```
3171
+
3172
+ ### ./src/expressions/compressor.ts
3173
+
3174
+ ```ts
3175
+ import { phx } from '../../.phoenix/phx.js'
3176
+
3177
+ // The frames Ardent builds, by motor power, with the free air delivery of each at 7.5 bar in m³/min
3178
+ const LARGEST_FRAME = { kw: 250, flow: 41 }
3179
+ const FRAMES = [
3180
+ { kw: 22, flow: 3.6 },
3181
+ { kw: 30, flow: 5 },
3182
+ { kw: 37, flow: 6.3 },
3183
+ { kw: 45, flow: 7.6 },
3184
+ { kw: 55, flow: 9.6 },
3185
+ { kw: 75, flow: 13 },
3186
+ { kw: 90, flow: 15.8 },
3187
+ { kw: 110, flow: 19.6 },
3188
+ { kw: 132, flow: 23 },
3189
+ { kw: 160, flow: 27.5 },
3190
+ { kw: 200, flow: 34 },
3191
+ LARGEST_FRAME,
3192
+ ]
3193
+
3194
+ // A higher pressure takes more power, so the same frame delivers less air
3195
+ const PRESSURE = {
3196
+ Bar7: { factor: 1, label: '7.5 bar' },
3197
+ Bar10: { factor: 0.85, label: '10 bar' },
3198
+ Bar13: { factor: 0.72, label: '13 bar' },
3199
+ }
3200
+
3201
+ const LARGEST_STOCK_FRAME_KW = 110
3202
+ const RECEIVER_LITRES = { None: 0, Litres500: 500, Litres1000: 1000, Litres2000: 2000 }
3203
+ const RECEIVER_PRICE = { None: 0, Litres500: 1150, Litres1000: 1900, Litres2000: 3400 }
3204
+ const EXPRESS_SURCHARGE = 0.08
3205
+
3206
+ const DRYER_LABEL = { None: 'No dryer', Refrigerant: 'Refrigerant dryer, +3 °C', Desiccant: 'Desiccant dryer' }
3207
+ const DEW_POINT_LABEL = { Minus40: '−40 °C', Minus70: '−70 °C' }
3208
+ const FILTRATION_LABEL = { General: 'General purpose', HighEfficiency: 'High efficiency', OilVapour: 'Oil vapour' }
3209
+ const PLAN_LABEL = { None: 'No plan', Essential: 'Essential, 3 years', TotalCare: 'Total care, 5 years' }
3210
+
3211
+ interface CompressorChoices {
3212
+ readonly flow: number
3213
+ readonly pressure: keyof typeof PRESSURE
3214
+ readonly drive: 'FixedSpeed' | 'VariableSpeed'
3215
+ readonly dryer: keyof typeof DRYER_LABEL
3216
+ readonly cooling: 'Air' | 'Water'
3217
+ readonly enclosure: 'Standard' | 'LowNoise'
3218
+ readonly supply: 'V400Hz50' | 'V480Hz60'
3219
+ }
3220
+
3221
+ // The smallest frame that delivers the flow at the pressure, or the largest one when none does
3222
+ const frameFor = (s: CompressorChoices) => {
3223
+ const factor = PRESSURE[s.pressure].factor
3224
+
3225
+ return FRAMES.find(frame => frame.flow * factor >= s.flow) ?? LARGEST_FRAME
3226
+ }
3227
+
3228
+ const basePriceOf = (kw: number) => {
3229
+ return 6000 + 360 * kw
3230
+ }
3231
+
3232
+ const standardWeeks = (s: CompressorChoices) => {
3233
+ return (
3234
+ (s.drive === 'VariableSpeed' ? 8 : 6) +
3235
+ (s.dryer === 'Desiccant' ? 2 : 0) +
3236
+ (s.cooling === 'Water' ? 1 : 0) +
3237
+ (s.enclosure === 'LowNoise' ? 1 : 0) +
3238
+ (s.supply === 'V480Hz60' ? 1 : 0)
3239
+ )
3240
+ }
3241
+
3242
+ const expressWeeks = (s: CompressorChoices) => {
3243
+ return Math.max(3, standardWeeks(s) - 4)
3244
+ }
3245
+
3246
+ export default phx.domain.implementCustomExpressions({
3247
+ Compressor: {
3248
+ $maxFlow: s => {
3249
+ return Math.floor(LARGEST_FRAME.flow * PRESSURE[s.pressure].factor * 2) / 2
3250
+ },
3251
+ $frameKw: s => {
3252
+ return frameFor(s).kw
3253
+ },
3254
+ $frameName: s => {
3255
+ return `RSV-${frameFor(s).kw}`
3256
+ },
3257
+ // A fixed-speed machine loads and unloads, so it needs a buffer of about 40 litres per m³/min
3258
+ $minReceiverLitres: s => {
3259
+ return s.drive === 'FixedSpeed' ? Math.ceil(s.flow * 40) : 0
3260
+ },
3261
+ $leadTimeWeeks: s => {
3262
+ return s.delivery === 'Express' ? expressWeeks(s) : standardWeeks(s)
3263
+ },
3264
+ // The delivery options name their lead time; express is offered for stock frames only
3265
+ $deliveryOptions: s => {
3266
+ const standard = {
3267
+ label: `Standard, ${standardWeeks(s)} weeks`,
3268
+ description: 'Built to order',
3269
+ value: 'Standard',
3270
+ }
3271
+ const express = {
3272
+ label: `Express, ${expressWeeks(s)} weeks`,
3273
+ description: `From stock, ${EXPRESS_SURCHARGE * 100} % surcharge`,
3274
+ value: 'Express',
3275
+ }
3276
+
3277
+ return frameFor(s).kw > LARGEST_STOCK_FRAME_KW ? [standard] : [standard, express]
3278
+ },
3279
+ $quoteLines: s => {
3280
+ const frame = frameFor(s)
3281
+ const base = basePriceOf(frame.kw)
3282
+ const lines = [
3283
+ {
3284
+ key: 'frame',
3285
+ label: `RSV-${frame.kw} rotary screw compressor`,
3286
+ detail: `${frame.kw} kW, ${PRESSURE[s.pressure].label}, ${(frame.flow * PRESSURE[s.pressure].factor).toFixed(1)} m³/min`,
3287
+ amount: base,
3288
+ },
3289
+ ]
3290
+
3291
+ if (s.drive === 'VariableSpeed') {
3292
+ lines.push({
3293
+ key: 'drive',
3294
+ label: 'Variable speed drive',
3295
+ detail: 'Inverter and PM motor',
3296
+ amount: base * 0.28,
3297
+ })
3298
+ }
3299
+
3300
+ if (s.dutyCycle === 'Continuous') {
3301
+ lines.push({ key: 'duty', label: 'Heavy-duty package', detail: 'For continuous duty', amount: base * 0.06 })
3302
+ }
3303
+
3304
+ if (s.dryer === 'Refrigerant') {
3305
+ lines.push({ key: 'dryer', label: 'Refrigerant dryer', detail: '+3 °C dew point', amount: 1200 + 95 * s.flow })
3306
+ }
3307
+
3308
+ if (s.dryer === 'Desiccant') {
3309
+ const dryer = (3800 + 310 * s.flow) * (s.dewPoint === 'Minus70' ? 1.18 : 1)
3310
+ lines.push({
3311
+ key: 'dryer',
3312
+ label: 'Desiccant dryer',
3313
+ detail: `${DEW_POINT_LABEL[s.dewPoint]} dew point`,
3314
+ amount: dryer,
3315
+ })
3316
+ }
3317
+
3318
+ if (s.filtration === 'HighEfficiency') {
3319
+ lines.push({
3320
+ key: 'filters',
3321
+ label: 'High-efficiency filters',
3322
+ detail: '0.01 mg/m³',
3323
+ amount: 1450 + 20 * s.flow,
3324
+ })
3325
+ }
3326
+
3327
+ if (s.filtration === 'OilVapour') {
3328
+ lines.push({ key: 'filters', label: 'Oil-vapour filters', detail: '0.003 mg/m³', amount: 2900 + 40 * s.flow })
3329
+ }
3330
+
3331
+ if (s.cooling === 'Water') {
3332
+ lines.push({ key: 'cooling', label: 'Water cooling', detail: 'Plate heat exchangers', amount: base * 0.07 })
3333
+ }
3334
+
3335
+ if (s.cooling === 'Water' && s.heatRecovery) {
3336
+ lines.push({
3337
+ key: 'heat',
3338
+ label: 'Heat recovery',
3339
+ detail: 'Up to 70 °C hot water',
3340
+ amount: 4200 + 25 * frame.kw,
3341
+ })
3342
+ }
3343
+
3344
+ if (s.receiver !== 'None') {
3345
+ const litres = RECEIVER_LITRES[s.receiver]
3346
+ lines.push({
3347
+ key: 'receiver',
3348
+ label: 'Air receiver',
3349
+ detail: `${litres.toLocaleString('en')} L, 11 bar`,
3350
+ amount: RECEIVER_PRICE[s.receiver],
3351
+ })
3352
+ }
3353
+
3354
+ if (s.enclosure === 'LowNoise') {
3355
+ lines.push({ key: 'canopy', label: 'Low-noise canopy', detail: 'About 66 dB(A)', amount: 2600 + 30 * frame.kw })
3356
+ }
3357
+
3358
+ if (s.supply === 'V480Hz60') {
3359
+ lines.push({ key: 'supply', label: '60 Hz build', detail: '480 V motor and starter', amount: base * 0.04 })
3360
+ }
3361
+
3362
+ if (s.servicePlan !== 'None') {
3363
+ const share = s.servicePlan === 'Essential' ? 0.09 : 0.22
3364
+ lines.push({ key: 'service', label: 'Service plan', detail: PLAN_LABEL[s.servicePlan], amount: base * share })
3365
+ }
3366
+
3367
+ if (s.commissioning) {
3368
+ lines.push({
3369
+ key: 'commissioning',
3370
+ label: 'Commissioning on site',
3371
+ detail: 'One engineer, one day',
3372
+ amount: 1800,
3373
+ })
3374
+ }
3375
+
3376
+ return lines.map(line => ({ ...line, amount: Math.round(line.amount) }))
3377
+ },
3378
+ $subtotal: s => {
3379
+ return s.$quoteLines.reduce((sum, line) => sum + line.amount, 0)
3380
+ },
3381
+ $leadTimeSurcharge: s => {
3382
+ return s.delivery === 'Express' ? Math.round(s.$subtotal * EXPRESS_SURCHARGE) : 0
3383
+ },
3384
+ $total: s => {
3385
+ return s.$subtotal + s.$leadTimeSurcharge
3386
+ },
3387
+ $specSheet: s => {
3388
+ const frame = frameFor(s)
3389
+
3390
+ return [
3391
+ { label: 'Model', value: `RSV-${frame.kw}, ${frame.kw} kW` },
3392
+ { label: 'Free air delivery', value: `${s.flow.toFixed(1)} m³/min required` },
3393
+ { label: 'Working pressure', value: PRESSURE[s.pressure].label },
3394
+ { label: 'Drive', value: s.drive === 'VariableSpeed' ? 'Variable speed' : 'Fixed speed' },
3395
+ { label: 'Duty', value: s.dutyCycle === 'Continuous' ? 'Continuous' : 'Intermittent' },
3396
+ {
3397
+ label: 'Dryer',
3398
+ value: s.dryer === 'Desiccant' ? `Desiccant, ${DEW_POINT_LABEL[s.dewPoint]}` : DRYER_LABEL[s.dryer],
3399
+ },
3400
+ { label: 'Filtration', value: FILTRATION_LABEL[s.filtration] },
3401
+ {
3402
+ label: 'Cooling',
3403
+ value: s.cooling === 'Water' ? `Water${s.heatRecovery ? ', with heat recovery' : ''}` : 'Air',
3404
+ },
3405
+ {
3406
+ label: 'Receiver',
3407
+ value: s.receiver === 'None' ? 'None' : `${RECEIVER_LITRES[s.receiver].toLocaleString('en')} L`,
3408
+ },
3409
+ { label: 'Canopy', value: s.enclosure === 'LowNoise' ? 'Low-noise' : 'Standard' },
3410
+ { label: 'Supply', value: s.supply === 'V480Hz60' ? '480 V, 60 Hz' : '400 V, 50 Hz' },
3411
+ { label: 'Service', value: PLAN_LABEL[s.servicePlan] },
3412
+ { label: 'Commissioning', value: s.commissioning ? 'On site' : 'Not included' },
3413
+ { label: 'Delivery', value: `${s.delivery === 'Express' ? 'Express' : 'Standard'}, ${s.$leadTimeWeeks} weeks` },
3414
+ ]
3415
+ },
3416
+ },
3417
+ })
3418
+ ```
3419
+
3420
+ ### ./src/handlers/request-quote.ts
3421
+
3422
+ ```ts
3423
+ import { useCallback } from 'react'
3424
+
3425
+ import { phx } from '../../.phoenix/phx.js'
3426
+ import { setQuoteReceipt } from '../_shared/quote-receipt.js'
3427
+
3428
+ const VALID_DAYS = 30
3429
+
3430
+ // Stands in for Ardent's quote service. A deployed project sends the configuration to its finalize webhook here and
3431
+ // waits for the answer; this one answers after a moment with a fixed engineer and a reference made from the
3432
+ // configuration's id, then enters the `requested` navigation state.
3433
+ export default phx.frontend.createPhoenixHandler('requestQuote', () => {
3434
+ const navigate = phx.frontend.useNavigate()
3435
+ const configurationId = phx.frontend.useConfigurationId()
3436
+ const total = phx.frontend.useRootEntityPrice()
3437
+
3438
+ return {
3439
+ handler: useCallback(async () => {
3440
+ await new Promise(resolve => setTimeout(resolve, 1600))
3441
+
3442
+ const validUntil = new Date(Date.now() + VALID_DAYS * 24 * 60 * 60 * 1000)
3443
+
3444
+ setQuoteReceipt({
3445
+ reference: `AQ-${String(configurationId)
3446
+ .replace(/[^a-z0-9]/gi, '')
3447
+ .slice(-6)
3448
+ .toUpperCase()}`,
3449
+ engineer: 'Marta Kovács, application engineer',
3450
+ validUntil: validUntil.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }),
3451
+ total: total ?? '',
3452
+ })
3453
+ navigate('requested')
3454
+ }, [configurationId, navigate, total]),
3455
+ }
3456
+ })
3457
+ ```
3458
+
3459
+ ### ./src/invariants/compressor.ts
3460
+
3461
+ ```ts
3462
+ import { phx } from '../../.phoenix/phx.js'
3463
+
3464
+ const PRESSURE_LABEL = { Bar7: '7.5 bar', Bar10: '10 bar', Bar13: '13 bar' }
3465
+ const RECEIVER_LITRES = { None: 0, Litres500: 500, Litres1000: 1000, Litres2000: 2000 }
3466
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
3467
+
3468
+ // Each rule is a `warning` in the spec: a change that breaks it is kept, so the customer can fix the conflict where it
3469
+ // is shown, and finalizing waits until no warning is left
3470
+ export default phx.domain.implementCustomInvariants({
3471
+ Compressor: {
3472
+ // The largest frame delivers less air at a higher pressure, so the limit depends on an earlier choice
3473
+ flowRange: s => {
3474
+ if (s.flow <= s.$maxFlow) {
3475
+ return []
3476
+ }
3477
+
3478
+ return [
3479
+ {
3480
+ type: 'flowAboveRange',
3481
+ severity: 'warning',
3482
+ message: `At ${PRESSURE_LABEL[s.pressure]} the largest frame delivers ${s.$maxFlow.toFixed(1)} m³/min. Lower the flow or the pressure.`,
3483
+ },
3484
+ ]
3485
+ },
3486
+ desiccantNeedsFiltration: s => {
3487
+ return s.dryer !== 'Desiccant' || s.filtration !== 'General'
3488
+ },
3489
+ // The message names the size the flow needs, so it is built here rather than in the spec
3490
+ receiverSize: s => {
3491
+ if (RECEIVER_LITRES[s.receiver] >= s.$minReceiverLitres) {
3492
+ return []
3493
+ }
3494
+
3495
+ return [
3496
+ {
3497
+ type: 'receiverTooSmall',
3498
+ severity: 'warning',
3499
+ message: `A fixed-speed compressor at ${s.flow.toFixed(1)} m³/min needs a receiver of at least ${s.$minReceiverLitres.toLocaleString('en')} L.`,
3500
+ },
3501
+ ]
3502
+ },
3503
+ expressUnavailable: s => {
3504
+ return s.delivery !== 'Express' || s.$frameKw <= 110
3505
+ },
3506
+ companyMissing: s => {
3507
+ return s.company.trim().length > 0
3508
+ },
3509
+ emailInvalid: s => {
3510
+ return EMAIL.test(s.email.trim())
3511
+ },
3512
+ },
3513
+ })
3514
+ ```
3515
+
3516
+ </details>