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

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