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

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 (558) hide show
  1. package/dist/application/index.d.ts +1 -0
  2. package/dist/application/index.js +1 -1
  3. package/dist/application/mount-application.d.ts +1 -1
  4. package/dist/application/mount-application.js +1 -1
  5. package/dist/application/mount-configurator.d.ts +19 -0
  6. package/dist/application/mount-configurator.js +1 -0
  7. package/dist/application/mounted-application-runtime.d.ts +4 -0
  8. package/dist/application/mounted-application-runtime.js +1 -0
  9. package/dist/application/register-web-component.d.ts +2 -0
  10. package/dist/application/register-web-component.js +1 -0
  11. package/dist/application-spec/errors/diagnostic-codes.d.ts +9 -0
  12. package/dist/application-spec/errors/diagnostic-codes.js +1 -1
  13. package/dist/application-spec/errors/diagnostic-messages.d.ts +10 -1
  14. package/dist/application-spec/errors/diagnostic-messages.js +1 -1
  15. package/dist/application-spec/generate-application/code/domain-runtime-files.js +1 -1
  16. package/dist/application-spec/generate-application/code/templates/application-types.js +6 -6
  17. package/dist/application-spec/generate-application/code/templates/common/doc-comment.d.ts +2 -0
  18. package/dist/application-spec/generate-application/code/templates/common/doc-comment.js +1 -0
  19. package/dist/application-spec/generate-application/code/templates/configurator-globals.d.ts +2 -1
  20. package/dist/application-spec/generate-application/code/templates/configurator-globals.js +19 -6
  21. package/dist/application-spec/generate-application/code/templates/global-definition/generate-action-types.js +7 -7
  22. package/dist/application-spec/generate-application/code/templates/global-definition/generate-assistant-form-element-types.d.ts +12 -0
  23. package/dist/application-spec/generate-application/code/templates/global-definition/generate-assistant-form-element-types.js +7 -0
  24. package/dist/application-spec/generate-application/code/templates/global-definition/generate-phx-types.js +24 -22
  25. package/dist/application-spec/generate-application/code/templates/global-definition/index.d.ts +1 -0
  26. package/dist/application-spec/generate-application/code/templates/global-definition/index.js +1 -1
  27. package/dist/application-spec/generate-application/code/templates/skills/sdk-content.d.ts +13 -6
  28. package/dist/application-spec/generate-application/code/templates/skills/sdk-content.js +2 -2
  29. package/dist/application-spec/generate-application/implementations/phx-application-types.d.ts +25 -0
  30. package/dist/application-spec/generate-application/implementations/phx-application-types.js +1 -0
  31. package/dist/application-spec/generate-application/implementations/phx-domain.d.ts +36 -0
  32. package/dist/application-spec/generate-application/implementations/phx-domain.js +1 -0
  33. package/dist/application-spec/generate-application/implementations/phx-model.d.ts +24 -0
  34. package/dist/application-spec/generate-application/implementations/phx-model.js +1 -0
  35. package/dist/application-spec/generate-application/implementations/phx.d.ts +22 -0
  36. package/dist/application-spec/generate-application/implementations/phx.js +1 -0
  37. package/dist/application-spec/generate-application/index.d.ts +2 -0
  38. package/dist/application-spec/generate-application/index.js +1 -1
  39. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-model-runtime.d.ts +3 -3
  40. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-runtime.d.ts +7 -4
  41. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-runtime.js +1 -1
  42. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-spec-runtime.d.ts +16 -0
  43. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-spec-runtime.js +1 -0
  44. package/dist/application-spec/generate-application/runtime/application-runtime.d.ts +17 -4
  45. package/dist/application-spec/generate-application/runtime/application-runtime.js +1 -1
  46. package/dist/application-spec/generate-application/runtime/application-spec-slice.d.ts +71 -0
  47. package/dist/application-spec/generate-application/runtime/application-spec-slice.js +1 -0
  48. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-model-runtime.d.ts +3 -3
  49. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-runtime.d.ts +7 -5
  50. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-runtime.js +1 -1
  51. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-spec-runtime.d.ts +16 -0
  52. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-spec-runtime.js +1 -0
  53. package/dist/application-spec/generate-application/runtime/create-application.d.ts +57 -0
  54. package/dist/application-spec/generate-application/runtime/create-application.js +1 -0
  55. package/dist/application-spec/generate-application/runtime/index.d.ts +8 -0
  56. package/dist/application-spec/generate-application/runtime/index.js +1 -0
  57. package/dist/application-spec/generate-application/runtime/sections/expression-runtime.d.ts +9 -3
  58. package/dist/application-spec/generate-application/runtime/sections/expression-runtime.js +1 -1
  59. package/dist/application-spec/generate-application/runtime/spec-runtime.d.ts +21 -0
  60. package/dist/application-spec/generate-application/runtime/spec-runtime.js +1 -0
  61. package/dist/application-spec/generate-application/runtime-spec/encoded-runtime-spec.d.ts +3 -2
  62. package/dist/application-spec/generate-application/runtime-spec/runtime-spec.d.ts +44 -9
  63. package/dist/application-spec/generate-application/runtime-spec/runtime-spec.js +1 -1
  64. package/dist/application-spec/parse-application-files/raw-spec/raw-assistant.d.ts +33 -3
  65. package/dist/application-spec/parse-application-files/raw-spec/raw-assistant.js +1 -1
  66. package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-form-elements.d.ts +9 -0
  67. package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-form-elements.js +1 -0
  68. package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-schema.d.ts +4 -1
  69. package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-schema.js +1 -1
  70. package/dist/client/authorized-client.d.ts +3 -1
  71. package/dist/client/authorized-client.js +1 -1
  72. package/dist/client/contract.d.ts +17 -0
  73. package/dist/client/index.d.ts +1 -1
  74. package/dist/client/local-authorized-client.d.ts +3 -1
  75. package/dist/client/local-authorized-client.js +1 -1
  76. package/dist/client/memory-authorized-client.d.ts +4 -1
  77. package/dist/client/memory-authorized-client.js +1 -1
  78. package/dist/client/phoenix-api.d.ts +228 -0
  79. package/dist/configurator/ConfiguratorApp.js +1 -1
  80. package/dist/configurator/app-loader-content/index.d.ts +0 -1
  81. package/dist/configurator/app-loader-content/index.js +1 -1
  82. package/dist/configurator/application-runtime/extensions/selectors.js +1 -1
  83. package/dist/configurator/application-runtime/extensions/systems/assistant-system/assistant-system.d.ts +17 -3
  84. package/dist/configurator/application-runtime/extensions/systems/assistant-system/assistant-system.js +1 -1
  85. package/dist/configurator/application-runtime/extensions/systems/assistant-system/graphql-tools.d.ts +34 -7
  86. package/dist/configurator/application-runtime/extensions/systems/assistant-system/graphql-tools.js +1 -1
  87. package/dist/configurator/application-runtime/extensions/systems/canvas-system.d.ts +8 -0
  88. package/dist/configurator/application-runtime/extensions/systems/canvas-system.js +1 -1
  89. package/dist/configurator/application-runtime/extensions/systems/export-system/export-busy-error.d.ts +8 -0
  90. package/dist/configurator/application-runtime/extensions/systems/export-system/export-busy-error.js +1 -0
  91. package/dist/configurator/application-runtime/extensions/systems/export-system/export-system.d.ts +23 -1
  92. package/dist/configurator/application-runtime/extensions/systems/export-system/export-system.js +1 -1
  93. package/dist/configurator/application-runtime/extensions/systems/graphql-system/entity-meta.d.ts +14 -1
  94. package/dist/configurator/application-runtime/extensions/systems/graphql-system/entity-meta.js +1 -1
  95. package/dist/configurator/application-runtime/extensions/systems/graphql-system/graphql-system.d.ts +51 -4
  96. package/dist/configurator/application-runtime/extensions/systems/graphql-system/graphql-system.js +1 -1
  97. package/dist/configurator/application-runtime/extensions/systems/image-rendering-system/image-rendering-system.d.ts +14 -5
  98. package/dist/configurator/application-runtime/extensions/systems/image-rendering-system/image-rendering-system.js +1 -1
  99. package/dist/configurator/application-runtime/extensions/systems/index.d.ts +1 -1
  100. package/dist/configurator/common/configurator-interface.d.ts +40 -6
  101. package/dist/configurator/common/configurator-interface.js +1 -1
  102. package/dist/configurator/configuration-not-found-page/BuiltInConfigurationNotFoundPage.d.ts +5 -0
  103. package/dist/configurator/configuration-not-found-page/BuiltInConfigurationNotFoundPage.js +1 -0
  104. package/dist/configurator/configuration-not-found-page/ConfigurationNotFoundPageElement.d.ts +0 -6
  105. package/dist/configurator/configuration-not-found-page/ConfigurationNotFoundPageElement.js +1 -1
  106. package/dist/configurator/configuration-not-found-page/index.d.ts +2 -2
  107. package/dist/configurator/configuration-not-found-page/index.js +1 -1
  108. package/dist/configurator/configurator-context/ConfiguratorResponsiveProvider.d.ts +3 -3
  109. package/dist/configurator/configurator-context/ConfiguratorResponsiveProvider.js +1 -1
  110. package/dist/configurator/configurator-context/PreviewContextProvider.js +1 -1
  111. package/dist/configurator/configurator-context/configurator-hooks/layout-hooks.d.ts +4 -0
  112. package/dist/configurator/configurator-context/configurator-routing/ConfiguratorRoutingProvider.js +1 -1
  113. package/dist/configurator/configurator-error-page/BuiltInConfiguratorErrorPage.d.ts +3 -3
  114. package/dist/configurator/configurator-error-page/BuiltInConfiguratorErrorPage.js +1 -1
  115. package/dist/configurator/configurator-error-page/ConfiguratorErrorPageElement.d.ts +0 -5
  116. package/dist/configurator/configurator-error-page/ConfiguratorErrorPageElement.js +1 -1
  117. package/dist/configurator/configurator-error-page/index.d.ts +1 -2
  118. package/dist/configurator/configurator-error-page/index.js +1 -1
  119. package/dist/configurator/index.d.ts +9 -2
  120. package/dist/configurator/index.js +1 -1
  121. package/dist/configurator/pages/default-configurator-elements.d.ts +3 -2
  122. package/dist/configurator/pages/default-configurator-elements.js +1 -1
  123. package/dist/configurator/pages/default-elements/index.d.ts +1 -0
  124. package/dist/configurator/pages/default-elements/index.js +1 -1
  125. package/dist/configurator/pages/default-elements/scene-controls/BuiltInAddEntityFab.d.ts +3 -0
  126. package/dist/configurator/pages/default-elements/scene-controls/BuiltInAddEntityFab.js +1 -0
  127. package/dist/configurator/pages/default-elements/scene-controls/BuiltInDeleteEntityFab.d.ts +3 -0
  128. package/dist/configurator/pages/default-elements/scene-controls/BuiltInDeleteEntityFab.js +1 -0
  129. package/dist/configurator/pages/default-elements/scene-controls/BuiltInNavigationHotspot.d.ts +3 -0
  130. package/dist/configurator/pages/default-elements/scene-controls/BuiltInNavigationHotspot.js +1 -0
  131. package/dist/configurator/pages/default-elements/scene-controls/BuiltInSelectionHotspot.d.ts +3 -0
  132. package/dist/configurator/pages/default-elements/scene-controls/BuiltInSelectionHotspot.js +1 -0
  133. package/dist/configurator/pages/default-elements/scene-controls/BuiltInToolMenuFab.d.ts +3 -0
  134. package/dist/configurator/pages/default-elements/scene-controls/BuiltInToolMenuFab.js +1 -0
  135. package/dist/configurator/pages/default-elements/scene-controls/index.d.ts +6 -0
  136. package/dist/configurator/pages/default-elements/scene-controls/index.js +1 -0
  137. package/dist/configurator/pages/default-elements/scene-controls/scene-control-elements.d.ts +6 -0
  138. package/dist/configurator/pages/default-elements/scene-controls/scene-control-elements.js +1 -0
  139. package/dist/configurator/pages/error-boundaries/LoadingErrorBoundary.js +1 -1
  140. package/dist/configurator/scene/index.d.ts +1 -0
  141. package/dist/configurator/scene/index.js +1 -1
  142. package/dist/configurator/scene/scene-controls/ToolFab.d.ts +4 -0
  143. package/dist/configurator/scene/scene-controls/ToolFab.js +1 -1
  144. package/dist/configurator/scene/scene-controls/use-scene-control-tooltip.d.ts +2 -1
  145. package/dist/configurator/scene/scene-entity/scene-controls/SceneEntityHtmlHotspots.js +1 -1
  146. package/dist/configurator/toolbar-actions/index.d.ts +1 -0
  147. package/dist/configurator/toolbar-actions/index.js +1 -1
  148. package/dist/configurator/toolbar-actions/use-toolbar-actions.d.ts +100 -0
  149. package/dist/configurator/toolbar-actions/use-toolbar-actions.js +1 -0
  150. package/dist/configurator/toolbar-actions/use-toolbar-help.d.ts +7 -0
  151. package/dist/configurator/toolbar-actions/use-toolbar-help.js +1 -0
  152. package/dist/configurator/ui/assistant/AssistantChips.d.ts +3 -2
  153. package/dist/configurator/ui/assistant/AssistantChips.js +1 -1
  154. package/dist/configurator/ui/assistant/AssistantMarkdown.d.ts +1 -1
  155. package/dist/configurator/ui/assistant/AssistantMarkdown.js +1 -1
  156. package/dist/configurator/ui/assistant/alert/AssistantAlert.d.ts +8 -0
  157. package/dist/configurator/ui/assistant/alert/AssistantAlert.js +1 -0
  158. package/dist/configurator/ui/assistant/alert/index.d.ts +2 -0
  159. package/dist/configurator/ui/assistant/alert/index.js +1 -0
  160. package/dist/configurator/ui/assistant/alert/remark-alerts.d.ts +10 -0
  161. package/dist/configurator/ui/assistant/alert/remark-alerts.js +1 -0
  162. package/dist/configurator/ui/assistant/form/AssistantForm.js +1 -1
  163. package/dist/configurator/ui/assistant/form/compose-answer.d.ts +1 -1
  164. package/dist/configurator/ui/assistant/form/compose-answer.js +1 -1
  165. package/dist/configurator/ui/assistant/form/form-model.d.ts +3 -4
  166. package/dist/configurator/ui/assistant/form/form-state.d.ts +1 -1
  167. package/dist/configurator/ui/assistant/form/index.d.ts +2 -0
  168. package/dist/configurator/ui/assistant/form/index.js +1 -1
  169. package/dist/configurator/ui/assistant/form/parse-form-block.d.ts +3 -2
  170. package/dist/configurator/ui/assistant/form/parse-form-block.js +1 -1
  171. package/dist/configurator/ui/assistant/form/read-form-attributes.d.ts +1 -1
  172. package/dist/configurator/ui/assistant/form/read-form-attributes.js +1 -1
  173. package/dist/configurator/ui/assistant/form/registry/built-in-form-elements.d.ts +3 -0
  174. package/dist/configurator/ui/assistant/form/registry/built-in-form-elements.js +1 -0
  175. package/dist/configurator/ui/assistant/form/registry/create-assistant-form-element.d.ts +31 -0
  176. package/dist/configurator/ui/assistant/form/registry/create-assistant-form-element.js +1 -0
  177. package/dist/configurator/ui/assistant/form/{form-element-definition.d.ts → registry/form-element-definition.d.ts} +3 -1
  178. package/dist/configurator/ui/assistant/form/registry/form-element-definition.js +1 -0
  179. package/dist/configurator/ui/assistant/form/{form-registry.d.ts → registry/form-registry.d.ts} +2 -2
  180. package/dist/configurator/ui/assistant/form/registry/form-registry.js +1 -0
  181. package/dist/configurator/ui/assistant/form/registry/index.d.ts +6 -0
  182. package/dist/configurator/ui/assistant/form/registry/index.js +1 -0
  183. package/dist/configurator/ui/assistant/form/registry/use-form-registry.d.ts +8 -0
  184. package/dist/configurator/ui/assistant/form/registry/use-form-registry.js +1 -0
  185. package/dist/configurator/ui/assistant/form-elements/FormCard.d.ts +9 -0
  186. package/dist/configurator/ui/assistant/form-elements/FormCard.js +1 -0
  187. package/dist/configurator/ui/assistant/form-elements/FormConfirm.d.ts +9 -0
  188. package/dist/configurator/ui/assistant/form-elements/FormConfirm.js +1 -0
  189. package/dist/configurator/ui/assistant/form-elements/FormGroup.d.ts +4 -1
  190. package/dist/configurator/ui/assistant/form-elements/FormNumber.d.ts +9 -0
  191. package/dist/configurator/ui/assistant/form-elements/FormNumber.js +1 -0
  192. package/dist/configurator/ui/assistant/form-elements/FormOptions.d.ts +4 -1
  193. package/dist/configurator/ui/assistant/form-elements/FormReply.d.ts +4 -1
  194. package/dist/configurator/ui/assistant/form-elements/FormReply.js +1 -1
  195. package/dist/configurator/ui/assistant/form-elements/FormSwatch.d.ts +10 -0
  196. package/dist/configurator/ui/assistant/form-elements/FormSwatch.js +1 -0
  197. package/dist/configurator/ui/assistant/form-elements/FormText.d.ts +4 -1
  198. package/dist/configurator/ui/assistant/form-elements/FormText.js +1 -1
  199. package/dist/configurator/ui/assistant/form-elements/clamp-form-number.d.ts +10 -0
  200. package/dist/configurator/ui/assistant/form-elements/clamp-form-number.js +1 -0
  201. package/dist/configurator/ui/assistant/form-elements/form-element-props.d.ts +20 -16
  202. package/dist/configurator/ui/assistant/form-elements/index.d.ts +4 -0
  203. package/dist/configurator/ui/assistant/form-elements/index.js +1 -1
  204. package/dist/configurator/ui/assistant/index.d.ts +3 -0
  205. package/dist/configurator/ui/assistant/index.js +1 -1
  206. package/dist/configurator/ui/assistant/resource-image/index.d.ts +1 -1
  207. package/dist/configurator/ui/assistant/resource-image/index.js +1 -1
  208. package/dist/configurator/ui/assistant/resource-image/resource-path.d.ts +0 -9
  209. package/dist/configurator/ui/assistant/resource-image/resource-path.js +1 -1
  210. package/dist/configurator/ui/assistant/screenshots/ScreenshotThumbnails.d.ts +9 -0
  211. package/dist/configurator/ui/assistant/screenshots/ScreenshotThumbnails.js +1 -0
  212. package/dist/configurator/ui/assistant/screenshots/index.d.ts +2 -0
  213. package/dist/configurator/ui/assistant/screenshots/index.js +1 -0
  214. package/dist/configurator/ui/assistant/screenshots/tool-screenshots.d.ts +22 -0
  215. package/dist/configurator/ui/assistant/screenshots/tool-screenshots.js +1 -0
  216. package/dist/configurator/ui/assistant/screenshots/use-screenshot-src.d.ts +8 -0
  217. package/dist/configurator/ui/assistant/screenshots/use-screenshot-src.js +1 -0
  218. package/dist/configurator/ui/assistant/streamed-markdown.d.ts +2 -2
  219. package/dist/configurator/ui/assistant/streamed-markdown.js +2 -2
  220. package/dist/configurator/ui/assistant/tool-chip.js +1 -1
  221. package/dist/configurator/ui/configurator-layout/slots/canvas/LayoutCanvas.js +1 -1
  222. package/dist/configurator/ui/configurator-layout/slots/canvas/SharedCanvas.js +1 -1
  223. package/dist/configurator/ui/configurator-layout/universal/ExportingOverlay.d.ts +5 -0
  224. package/dist/configurator/ui/configurator-layout/universal/ExportingOverlay.js +1 -0
  225. package/dist/configurator/ui/configurator-layout/universal/Layout.js +1 -1
  226. package/dist/configurator/ui/configurator-layout/universal/use-layout-scene-safe-area.js +1 -1
  227. package/dist/configurator/ui/index.d.ts +1 -0
  228. package/dist/configurator/ui/index.js +1 -1
  229. package/dist/configurator/ui/legacy/layout/common/ReactiveInterfaceLayout.js +1 -1
  230. package/dist/configurator/ui/phoenix-layout-elements/AppLoader.d.ts +0 -7
  231. package/dist/configurator/ui/phoenix-layout-elements/AppLoader.js +2 -2
  232. package/dist/configurator/ui/phoenix-layout-elements/index.d.ts +8 -1
  233. package/dist/configurator/ui/phoenix-layout-elements/index.js +1 -1
  234. package/dist/configurator/ui/preset-layout/PresetUi.d.ts +0 -2
  235. package/dist/configurator/ui/preset-layout/PresetUi.js +1 -1
  236. package/dist/configurator/ui/preset-layout/index.d.ts +6 -1
  237. package/dist/configurator/ui/preset-layout/index.js +1 -1
  238. package/dist/configurator/ui/save-message-snackbars/SaveMessageSnackbars.js +1 -1
  239. package/dist/docs/complex-use-cases/focus-mode.mdx +2 -0
  240. package/dist/docs/fabs/basic.mdx +4 -0
  241. package/dist/docs/index.json +95 -1
  242. package/dist/docs/integrations/webcomponent-api/basics.mdx +2 -0
  243. package/dist/docs/layouts/custom-layout.mdx +320 -158
  244. package/dist/docs/layouts/gallery/ardent-compressor.mdx +3516 -0
  245. package/dist/docs/layouts/gallery/corvid-drone.mdx +3428 -0
  246. package/dist/docs/layouts/gallery/fenwick-window.mdx +2803 -0
  247. package/dist/docs/layouts/gallery/ferrule-bar-station.mdx +6567 -0
  248. package/dist/docs/layouts/gallery/kestrel-kettle.mdx +1858 -0
  249. package/dist/docs/layouts/gallery/nightjar-guitar.mdx +3698 -0
  250. package/dist/docs/layouts/gallery/sunfold-solar.mdx +2683 -0
  251. package/dist/docs/layouts/gallery/tidepool-aquascape.mdx +5203 -0
  252. package/dist/docs/other-features/client-side-rendering/declarative.mdx +1 -1
  253. package/dist/docs/other-features/navigation-hotspot.mdx +2 -0
  254. package/dist/docs/phoenix/configurator-interface.mdx +72 -14
  255. package/dist/docs/phoenix/sdk-builder-workflow.mdx +5 -3
  256. package/dist/docs/phoenix/sessions-and-access.mdx +2 -0
  257. package/dist/docs/selection-and-highlight/hotspots.mdx +2 -0
  258. package/dist/domain/application/assistant/assistant-manifest.d.ts +16 -1
  259. package/dist/domain/application/assistant/assistant-resolved-manifest.d.ts +8 -1
  260. package/dist/domain/application/assistant/build-assistant-manifest.js +1 -1
  261. package/dist/domain/application/assistant/form/assistant-form-element-name.d.ts +11 -0
  262. package/dist/domain/application/assistant/form/assistant-form-element-name.js +8 -0
  263. package/dist/domain/application/assistant/form/assistant-form-elements.d.ts +174 -0
  264. package/dist/domain/application/assistant/form/assistant-form-elements.js +1 -0
  265. package/dist/domain/application/assistant/form/assistant-resource-path.d.ts +10 -0
  266. package/dist/domain/application/assistant/form/assistant-resource-path.js +1 -0
  267. package/dist/domain/application/assistant/form/index.d.ts +4 -0
  268. package/dist/domain/application/assistant/form/index.js +1 -0
  269. package/dist/domain/application/assistant/generate-assistant-prompt.d.ts +3 -0
  270. package/dist/domain/application/assistant/generate-assistant-prompt.js +1 -1
  271. package/dist/domain/application/assistant/index.d.ts +2 -3
  272. package/dist/domain/application/assistant/index.js +1 -1
  273. package/dist/domain/application/assistant/session-setup/apply-assistant-setup.js +1 -1
  274. package/dist/domain/application/assistant/session-setup/assistant-setup-manifest.d.ts +2 -0
  275. package/dist/domain/application/assistant/session-setup/assistant-setup-manifest.js +1 -1
  276. package/dist/domain/application/configurator-builder.d.ts +7 -1
  277. package/dist/domain/application/configurator-builder.js +1 -1
  278. package/dist/domain/application/index.d.ts +1 -1
  279. package/dist/domain/application/index.js +1 -1
  280. package/dist/domain/application/standard-properties.js +1 -1
  281. package/dist/domain/application/web-component-property.d.ts +3 -1
  282. package/dist/domain/application/web-component-property.js +1 -1
  283. package/dist/domain/available-system-components.d.ts +1 -1
  284. package/dist/domain/available-system-components.js +1 -1
  285. package/dist/domain/domain-builder.d.ts +9 -3
  286. package/dist/domain/domain-builder.js +1 -1
  287. package/dist/domain/domain-context/store-system/store-system.d.ts +5 -0
  288. package/dist/domain/domain-context/store-system/store-system.js +1 -1
  289. package/dist/domain/domain-runtime/application.d.ts +39 -0
  290. package/dist/domain/domain-runtime/application.js +1 -0
  291. package/dist/domain/domain-runtime/domain-context/selectors.d.ts +1 -0
  292. package/dist/domain/domain-runtime/domain-context/selectors.js +1 -1
  293. package/dist/domain/domain-runtime/domain-runtime.d.ts +44 -4
  294. package/dist/domain/domain-runtime/domain-runtime.js +1 -1
  295. package/dist/domain/domain-runtime/environment/initialize.d.ts +1 -1
  296. package/dist/domain/domain-runtime/environment/types.d.ts +1 -1
  297. package/dist/domain/domain-runtime/errors/implementation-error-collector.d.ts +2 -0
  298. package/dist/domain/domain-runtime/errors/implementation-error-collector.js +1 -1
  299. package/dist/domain/domain-runtime/index.d.ts +2 -1
  300. package/dist/domain/domain-runtime/index.js +1 -1
  301. package/dist/domain/domain-runtime/spec/index.d.ts +1 -1
  302. package/dist/domain/domain-runtime/spec/index.js +1 -1
  303. package/dist/domain/domain-runtime/spec/initialize.js +1 -1
  304. package/dist/domain/domain-runtime/spec/module-checks/element-registration.d.ts +15 -1
  305. package/dist/domain/domain-runtime/spec/module-checks/element-registration.js +1 -1
  306. package/dist/domain/domain-runtime/spec/module-checks/find-assistant-form-element-errors.d.ts +12 -0
  307. package/dist/domain/domain-runtime/spec/module-checks/find-assistant-form-element-errors.js +1 -0
  308. package/dist/domain/domain-runtime/spec/module-checks/index.d.ts +3 -1
  309. package/dist/domain/domain-runtime/spec/module-checks/index.js +1 -1
  310. package/dist/domain/domain-runtime/spec/module-checks/merge-expression-modules.d.ts +23 -0
  311. package/dist/domain/domain-runtime/spec/module-checks/merge-expression-modules.js +1 -0
  312. package/dist/domain/domain-runtime/spec/module-checks/resolve-expression-modules.d.ts +3 -4
  313. package/dist/domain/domain-runtime/spec/module-checks/resolve-expression-modules.js +1 -1
  314. package/dist/domain/domain-runtime/spec/selectors.d.ts +3 -1
  315. package/dist/domain/domain-runtime/spec/selectors.js +1 -1
  316. package/dist/domain/domain-runtime/web-component/actions.d.ts +3 -0
  317. package/dist/domain/domain-runtime/web-component/actions.js +1 -1
  318. package/dist/domain/domain-runtime/web-component/initialize.d.ts +1 -0
  319. package/dist/domain/domain-runtime/web-component/initialize.js +1 -1
  320. package/dist/domain/domain-runtime/web-component/selectors.d.ts +2 -0
  321. package/dist/domain/domain-runtime/web-component/selectors.js +1 -1
  322. package/dist/domain/domain-runtime/web-component/types.d.ts +6 -3
  323. package/dist/domain/extensions/export-images/agent-screenshot.d.ts +24 -0
  324. package/dist/domain/extensions/export-images/agent-screenshot.js +1 -0
  325. package/dist/domain/extensions/{client-side-image.d.ts → export-images/client-side-image.d.ts} +2 -2
  326. package/dist/domain/extensions/export-images/client-side-image.js +1 -0
  327. package/dist/domain/extensions/export-images/index.d.ts +3 -0
  328. package/dist/domain/extensions/export-images/index.js +1 -0
  329. package/dist/domain/extensions/{save-template-thumbnail.d.ts → export-images/save-template-thumbnail.d.ts} +2 -2
  330. package/dist/domain/extensions/export-images/save-template-thumbnail.js +1 -0
  331. package/dist/domain/extensions/index.d.ts +1 -2
  332. package/dist/domain/extensions/index.js +1 -1
  333. package/dist/domain/mocks/create-mock-domain-runtime.d.ts +9 -0
  334. package/dist/editor/domain-editors/DomainSpecContext.js +1 -1
  335. package/dist/editor/domain-editors/apply-spec-update.d.ts +10 -2
  336. package/dist/editor/domain-editors/apply-spec-update.js +1 -1
  337. package/dist/editor/domain-editors/use-implementation-files.d.ts +6 -14
  338. package/dist/editor/domain-editors/use-implementation-files.js +1 -1
  339. package/dist/editor/screenshot/ScreenshotHandler.js +1 -1
  340. package/dist/i18n/locales/cs/messages.js +1 -1
  341. package/dist/i18n/locales/da/messages.js +1 -1
  342. package/dist/i18n/locales/de/messages.js +1 -1
  343. package/dist/i18n/locales/de-AT/messages.js +1 -1
  344. package/dist/i18n/locales/en/messages.js +1 -1
  345. package/dist/i18n/locales/en-AU/messages.js +1 -1
  346. package/dist/i18n/locales/en-GB/messages.js +1 -1
  347. package/dist/i18n/locales/es/messages.js +1 -1
  348. package/dist/i18n/locales/fr/messages.js +1 -1
  349. package/dist/i18n/locales/hu/messages.js +1 -1
  350. package/dist/i18n/locales/it/messages.js +1 -1
  351. package/dist/i18n/locales/nl/messages.js +1 -1
  352. package/dist/i18n/locales/pl/messages.js +1 -1
  353. package/dist/i18n/locales/pt/messages.js +1 -1
  354. package/dist/i18n/locales/ro/messages.js +1 -1
  355. package/dist/i18n/locales/sk/messages.js +1 -1
  356. package/dist/i18n/locales/tr/messages.js +1 -1
  357. package/dist/legacy/domain-cli/utils/cache.d.ts +1 -1
  358. package/dist/legacy/domain-cli/utils/cache.js +1 -1
  359. package/dist/legacy/domain-spec/__generated/domain-schema.d.ts +16 -0
  360. package/dist/legacy/domain-spec/spec/domain-spec.js +1 -1
  361. package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/claude-md.d.ts +1 -1
  362. package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/cursor-rules.d.ts +1 -1
  363. package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/shared-sdk-content.d.ts +1 -1
  364. package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/shared-sdk-content.js +11 -5
  365. package/dist/react-sdk/application-runtime/common-services/analytics/analytics-service.d.ts +1 -1
  366. package/dist/react-sdk/application-runtime/common-services/initialize.d.ts +2 -2
  367. package/dist/react-sdk/application-runtime/hooks/index.d.ts +2 -0
  368. package/dist/react-sdk/application-runtime/hooks/index.js +1 -1
  369. package/dist/react-sdk/application-runtime/hooks/loading/index.d.ts +5 -0
  370. package/dist/react-sdk/application-runtime/hooks/loading/index.js +1 -0
  371. package/dist/react-sdk/application-runtime/hooks/loading/loader-host-context.d.ts +11 -0
  372. package/dist/react-sdk/application-runtime/hooks/loading/loader-host-context.js +1 -0
  373. package/dist/react-sdk/application-runtime/hooks/loading/use-is-awaiting-loading-phase.d.ts +8 -0
  374. package/dist/react-sdk/application-runtime/hooks/loading/use-is-awaiting-loading-phase.js +1 -0
  375. package/dist/react-sdk/application-runtime/hooks/loading/use-is-loader-hosted.d.ts +7 -0
  376. package/dist/react-sdk/application-runtime/hooks/loading/use-is-loader-hosted.js +1 -0
  377. package/dist/react-sdk/application-runtime/hooks/loading/use-loading-actions.d.ts +9 -0
  378. package/dist/react-sdk/application-runtime/hooks/loading/use-loading-actions.js +1 -0
  379. package/dist/react-sdk/application-runtime/hooks/loading/use-loading-application.d.ts +24 -0
  380. package/dist/react-sdk/application-runtime/hooks/loading/use-loading-application.js +1 -0
  381. package/dist/react-sdk/application-runtime/hooks/responsive/index.d.ts +3 -0
  382. package/dist/react-sdk/application-runtime/hooks/responsive/index.js +1 -0
  383. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-actions.d.ts +9 -0
  384. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-actions.js +1 -0
  385. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-application.d.ts +26 -0
  386. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-application.js +1 -0
  387. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-state.d.ts +10 -0
  388. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-state.js +1 -0
  389. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-store.d.ts +24 -0
  390. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-store.js +1 -0
  391. package/dist/react-sdk/application-runtime/hooks/use-access-service.d.ts +5 -0
  392. package/dist/react-sdk/application-runtime/hooks/use-access-service.js +1 -1
  393. package/dist/react-sdk/application-runtime/hooks/use-phoenix-element-slots.d.ts +2 -1
  394. package/dist/react-sdk/application-runtime/hooks/use-web-component-readonly-sync.d.ts +4 -11
  395. package/dist/react-sdk/application-runtime/hooks/use-web-component-readonly-sync.js +1 -1
  396. package/dist/react-sdk/application-runtime/hooks/use-web-component.d.ts +5 -0
  397. package/dist/react-sdk/application-runtime/hooks/use-web-component.js +1 -1
  398. package/dist/react-sdk/application-runtime/host-properties/actions.d.ts +15 -0
  399. package/dist/react-sdk/application-runtime/host-properties/actions.js +1 -0
  400. package/dist/react-sdk/application-runtime/host-properties/connect-host-properties.d.ts +30 -0
  401. package/dist/react-sdk/application-runtime/host-properties/connect-host-properties.js +1 -0
  402. package/dist/react-sdk/application-runtime/host-properties/domain-host-properties.d.ts +7 -0
  403. package/dist/react-sdk/application-runtime/host-properties/domain-host-properties.js +1 -0
  404. package/dist/react-sdk/application-runtime/host-properties/host-property-descriptors.d.ts +21 -0
  405. package/dist/react-sdk/application-runtime/host-properties/host-property-descriptors.js +1 -0
  406. package/dist/react-sdk/application-runtime/host-properties/host-property-scopes.d.ts +42 -0
  407. package/dist/react-sdk/application-runtime/host-properties/host-property-scopes.js +1 -0
  408. package/dist/react-sdk/application-runtime/host-properties/index.d.ts +8 -0
  409. package/dist/react-sdk/application-runtime/host-properties/index.js +1 -0
  410. package/dist/react-sdk/application-runtime/host-properties/initialize.d.ts +7 -0
  411. package/dist/react-sdk/application-runtime/host-properties/initialize.js +1 -0
  412. package/dist/react-sdk/application-runtime/host-properties/selectors.d.ts +54 -0
  413. package/dist/react-sdk/application-runtime/host-properties/selectors.js +1 -0
  414. package/dist/react-sdk/application-runtime/host-properties/types.d.ts +18 -0
  415. package/dist/react-sdk/application-runtime/index.d.ts +3 -0
  416. package/dist/react-sdk/application-runtime/integration/initialize.d.ts +7 -6
  417. package/dist/react-sdk/application-runtime/integration/initialize.js +1 -1
  418. package/dist/react-sdk/application-runtime/integration/selectors.d.ts +11 -2
  419. package/dist/react-sdk/application-runtime/integration/selectors.js +1 -1
  420. package/dist/react-sdk/application-runtime/integration/types.d.ts +5 -2
  421. package/dist/react-sdk/application-runtime/loading/actions.d.ts +45 -0
  422. package/dist/react-sdk/application-runtime/loading/actions.js +1 -0
  423. package/dist/react-sdk/application-runtime/loading/index.d.ts +4 -0
  424. package/dist/react-sdk/application-runtime/loading/index.js +1 -0
  425. package/dist/react-sdk/application-runtime/loading/initialize.d.ts +3 -0
  426. package/dist/react-sdk/application-runtime/loading/initialize.js +1 -0
  427. package/dist/react-sdk/application-runtime/loading/selectors.d.ts +18 -0
  428. package/dist/react-sdk/application-runtime/loading/selectors.js +1 -0
  429. package/dist/react-sdk/application-runtime/loading/types.d.ts +59 -0
  430. package/dist/react-sdk/application-runtime/mocks/create-mock-application-runtime.js +1 -1
  431. package/dist/react-sdk/application-runtime/provider/ApplicationRuntime.d.ts +6 -0
  432. package/dist/react-sdk/application-runtime/provider/ApplicationRuntime.js +1 -1
  433. package/dist/react-sdk/application-runtime/provider/ApplicationType.d.ts +7 -0
  434. package/dist/react-sdk/application-runtime/provider/ApplicationType.js +1 -1
  435. package/dist/react-sdk/application-runtime/responsive/actions.d.ts +47 -0
  436. package/dist/react-sdk/application-runtime/responsive/actions.js +1 -0
  437. package/dist/react-sdk/application-runtime/responsive/index.d.ts +4 -0
  438. package/dist/react-sdk/application-runtime/responsive/index.js +1 -0
  439. package/dist/react-sdk/application-runtime/responsive/initialize.d.ts +3 -0
  440. package/dist/react-sdk/application-runtime/responsive/initialize.js +1 -0
  441. package/dist/react-sdk/application-runtime/responsive/selectors.d.ts +20 -0
  442. package/dist/react-sdk/application-runtime/responsive/selectors.js +1 -0
  443. package/dist/react-sdk/application-runtime/responsive/types.d.ts +104 -0
  444. package/dist/react-sdk/application-runtime/routing/routing-service.d.ts +34 -2
  445. package/dist/react-sdk/application-runtime/routing/routing-service.js +1 -1
  446. package/dist/react-sdk/application-runtime/runtime/create-application-runtime.d.ts +313 -10
  447. package/dist/react-sdk/application-runtime/runtime/create-application-runtime.js +1 -1
  448. package/dist/react-sdk/domain-application-context/BackendDataProvider.js +1 -1
  449. package/dist/react-sdk/domain-application-context/FrontendDataProvider.js +1 -1
  450. package/dist/react-sdk/domain-application-context/PublishedDataProvider.js +1 -1
  451. package/dist/react-sdk/domain-context/common-hooks/index.d.ts +1 -0
  452. package/dist/react-sdk/domain-context/common-hooks/index.js +1 -1
  453. package/dist/react-sdk/domain-context/common-hooks/use-configuration-save-state.d.ts +23 -0
  454. package/dist/react-sdk/domain-context/common-hooks/use-configuration-save-state.js +1 -0
  455. package/dist/react-sdk/domain-context/common-hooks/use-domain-context.d.ts +1 -0
  456. package/dist/react-sdk/domain-context/index.d.ts +1 -0
  457. package/dist/react-sdk/domain-context/index.js +1 -1
  458. package/dist/react-sdk/domain-context/system-hooks/undo-redo-hooks.js +1 -1
  459. package/dist/react-sdk/elements/index.d.ts +0 -1
  460. package/dist/react-sdk/index.d.ts +2 -1
  461. package/dist/react-sdk/index.js +1 -1
  462. package/dist/react-sdk/loading-state/LoaderHost.d.ts +20 -0
  463. package/dist/react-sdk/loading-state/LoaderHost.js +1 -0
  464. package/dist/react-sdk/loading-state/built-in-loading-phase.d.ts +9 -0
  465. package/dist/react-sdk/loading-state/built-in-loading-phase.js +1 -0
  466. package/dist/react-sdk/loading-state/create-app-loader.d.ts +18 -0
  467. package/dist/react-sdk/loading-state/create-app-loader.js +1 -0
  468. package/dist/react-sdk/loading-state/index.d.ts +4 -2
  469. package/dist/react-sdk/loading-state/index.js +1 -1
  470. package/dist/react-sdk/loading-state/{LoadingStateProvider.d.ts → loading-state-hooks.d.ts} +3 -12
  471. package/dist/react-sdk/loading-state/loading-state-hooks.js +1 -0
  472. package/dist/react-sdk/page-elements/ConfigurationNotFoundPagePropsProvider.d.ts +17 -0
  473. package/dist/react-sdk/page-elements/ConfigurationNotFoundPagePropsProvider.js +1 -0
  474. package/dist/react-sdk/page-elements/ConfiguratorErrorPagePropsProvider.d.ts +17 -0
  475. package/dist/react-sdk/page-elements/ConfiguratorErrorPagePropsProvider.js +1 -0
  476. package/dist/react-sdk/page-elements/configurator-error-page-props.js +1 -0
  477. package/dist/{configurator/configuration-not-found-page → react-sdk/page-elements}/create-configuration-not-found-page.d.ts +7 -1
  478. package/dist/react-sdk/page-elements/create-configuration-not-found-page.js +1 -0
  479. package/dist/{configurator/configurator-error-page → react-sdk/page-elements}/create-configurator-error-page.d.ts +6 -1
  480. package/dist/react-sdk/page-elements/create-configurator-error-page.js +1 -0
  481. package/dist/react-sdk/page-elements/create-preset-ui.d.ts +9 -0
  482. package/dist/react-sdk/page-elements/create-preset-ui.js +1 -0
  483. package/dist/react-sdk/page-elements/index.d.ts +6 -0
  484. package/dist/react-sdk/page-elements/index.js +1 -0
  485. package/dist/react-sdk/scene-control-elements/AddEntityFabElement.d.ts +36 -0
  486. package/dist/react-sdk/scene-control-elements/AddEntityFabElement.js +1 -0
  487. package/dist/react-sdk/scene-control-elements/DeleteEntityFabElement.d.ts +44 -0
  488. package/dist/react-sdk/scene-control-elements/DeleteEntityFabElement.js +1 -0
  489. package/dist/react-sdk/scene-control-elements/NavigationHotspotElement.d.ts +38 -0
  490. package/dist/react-sdk/scene-control-elements/NavigationHotspotElement.js +1 -0
  491. package/dist/react-sdk/scene-control-elements/SelectionHotspotElement.d.ts +30 -0
  492. package/dist/react-sdk/scene-control-elements/SelectionHotspotElement.js +1 -0
  493. package/dist/react-sdk/scene-control-elements/ToolMenuFabElement.d.ts +30 -0
  494. package/dist/react-sdk/scene-control-elements/ToolMenuFabElement.js +1 -0
  495. package/dist/react-sdk/scene-control-elements/create-add-entity-fab.d.ts +9 -0
  496. package/dist/react-sdk/scene-control-elements/create-add-entity-fab.js +1 -0
  497. package/dist/react-sdk/scene-control-elements/create-delete-entity-fab.d.ts +9 -0
  498. package/dist/react-sdk/scene-control-elements/create-delete-entity-fab.js +1 -0
  499. package/dist/react-sdk/scene-control-elements/create-navigation-hotspot.d.ts +10 -0
  500. package/dist/react-sdk/scene-control-elements/create-navigation-hotspot.js +1 -0
  501. package/dist/react-sdk/scene-control-elements/create-selection-hotspot.d.ts +9 -0
  502. package/dist/react-sdk/scene-control-elements/create-selection-hotspot.js +1 -0
  503. package/dist/react-sdk/scene-control-elements/create-tool-menu-fab.d.ts +9 -0
  504. package/dist/react-sdk/scene-control-elements/create-tool-menu-fab.js +1 -0
  505. package/dist/react-sdk/scene-control-elements/index.d.ts +11 -0
  506. package/dist/react-sdk/scene-control-elements/index.js +1 -0
  507. package/dist/react-sdk/scene-control-elements/scene-control-tooltip-visibility.d.ts +5 -0
  508. package/dist/react-sdk/scene-control-elements/scene-control-tooltip-visibility.js +1 -0
  509. package/dist/react-sdk/session/SessionServiceProvider.js +1 -1
  510. package/dist/react-sdk/web-component/create-web-component.js +1 -1
  511. package/dist/react-sdk/web-component/god-mode/god-mode-registry.d.ts +30 -0
  512. package/dist/react-sdk/web-component/god-mode/god-mode-registry.js +1 -0
  513. package/dist/react-sdk/web-component/god-mode/index.d.ts +1 -0
  514. package/dist/react-sdk/web-component/god-mode/index.js +1 -0
  515. package/dist/ui-sdk/icons/__generated/Lightbulb.d.ts +3 -0
  516. package/dist/ui-sdk/icons/__generated/Lightbulb.js +1 -0
  517. package/dist/ui-sdk/icons/__generated/XCircle.d.ts +3 -0
  518. package/dist/ui-sdk/icons/__generated/XCircle.js +1 -0
  519. package/dist/ui-sdk/icons/__generated/index.d.ts +4 -0
  520. package/dist/ui-sdk/icons/__generated/index.js +1 -1
  521. package/dist/ui-sdk/icons/icons-registry.js +1 -1
  522. package/dist/ui-sdk/responsive-application/ResponsiveApplicationProvider.d.ts +2 -2
  523. package/dist/ui-sdk/responsive-application/responsive-state-store.d.ts +11 -9
  524. package/dist/ui-sdk/responsive-application/use-store-responsive-info.js +1 -1
  525. package/dist/ui-sdk/theme/types.d.ts +5 -1
  526. package/package.json +13 -3
  527. package/schema/domain-spec.json +140 -0
  528. package/dist/configurator/app-loader-content/app-loader-content-name.d.ts +0 -5
  529. package/dist/configurator/app-loader-content/app-loader-content-name.js +0 -1
  530. package/dist/configurator/configuration-not-found-page/create-configuration-not-found-page.js +0 -1
  531. package/dist/configurator/configurator-error-page/create-configurator-error-page.js +0 -1
  532. package/dist/configurator/ui/assistant/form/built-in-form-registry.d.ts +0 -10
  533. package/dist/configurator/ui/assistant/form/built-in-form-registry.js +0 -1
  534. package/dist/configurator/ui/assistant/form/form-registry.js +0 -8
  535. package/dist/domain/application/assistant/assistant-form-elements.d.ts +0 -28
  536. package/dist/domain/application/assistant/assistant-form-elements.js +0 -1
  537. package/dist/domain/extensions/client-side-image.js +0 -1
  538. package/dist/domain/extensions/save-template-thumbnail.js +0 -1
  539. package/dist/react-sdk/application-runtime/runtime/god-mode.d.ts +0 -19
  540. package/dist/react-sdk/application-runtime/runtime/god-mode.js +0 -1
  541. package/dist/react-sdk/loading-state/LoadingStateProvider.js +0 -1
  542. package/dist/react-sdk/loading-state/loading-state-manager.d.ts +0 -37
  543. package/dist/react-sdk/loading-state/loading-state-manager.js +0 -1
  544. package/dist/react-sdk/responsive-state/ResponsiveStateProvider.d.ts +0 -25
  545. package/dist/react-sdk/responsive-state/ResponsiveStateProvider.js +0 -1
  546. package/dist/react-sdk/responsive-state/canvas-padding.d.ts +0 -14
  547. package/dist/react-sdk/responsive-state/index.d.ts +0 -5
  548. package/dist/react-sdk/responsive-state/index.js +0 -1
  549. package/dist/react-sdk/responsive-state/responsive-state-manager.d.ts +0 -111
  550. package/dist/react-sdk/responsive-state/responsive-state-manager.js +0 -1
  551. package/dist/react-sdk/responsive-state/use-responsive-state-manager.d.ts +0 -10
  552. package/dist/react-sdk/responsive-state/use-responsive-state-manager.js +0 -1
  553. package/dist/react-sdk/responsive-state/use-responsive-state.d.ts +0 -11
  554. package/dist/react-sdk/responsive-state/use-responsive-state.js +0 -1
  555. /package/dist/{configurator/ui/assistant/form/form-element-definition.js → react-sdk/application-runtime/host-properties/types.js} +0 -0
  556. /package/dist/react-sdk/{elements/configurator-error-page-props.js → application-runtime/loading/types.js} +0 -0
  557. /package/dist/react-sdk/{responsive-state/canvas-padding.js → application-runtime/responsive/types.js} +0 -0
  558. /package/dist/react-sdk/{elements → page-elements}/configurator-error-page-props.d.ts +0 -0
@@ -0,0 +1,2803 @@
1
+ # Fenwick Window
2
+
3
+ Show a made-to-measure product as the drawing a workshop would build it from. A joinery, Fenwick & Lowe, sells timber casement windows to order: the customer sets the overall size, the panes across, a top light, how the sashes open, the glass and the finish, and sees the window as a shop drawing that changes with every input. The drawing has the frame, the mullions and the transom, the sashes with their opening symbols and handles, chains of dimensions in millimetres and a title block with the window's thermal and weight figures. Where a canvas would usually be, the layout draws SVG from the configuration; the spec holds the geometry, the prices and the rules.
4
+
5
+ - [Try it](#try-it)
6
+ - [What the example demonstrates](#what-the-example-demonstrates)
7
+ - [The drawing is the main view](#the-drawing-is-the-main-view)
8
+ - [Geometry from the spec](#geometry-from-the-spec)
9
+ - [Drawing the elevation](#drawing-the-elevation)
10
+ - [Questions that depend on earlier answers](#questions-that-depend-on-earlier-answers)
11
+ - [Rules the drawing shows](#rules-the-drawing-shows)
12
+ - [Add to order](#add-to-order)
13
+ - [On a phone](#on-a-phone)
14
+ - [Test cases](#test-cases)
15
+ - [What this layout leaves out](#what-this-layout-leaves-out)
16
+ - [Related pages](#related-pages)
17
+ - [Complete Implementation](#complete-implementation)
18
+
19
+ ## Try it
20
+
21
+ The phone version is under [On a phone](#on-a-phone).
22
+
23
+ ## What the example demonstrates
24
+
25
+ | Piece | Where |
26
+ | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
27
+ | A `ConfiguratorLayout` whose main view is SVG instead of `LayoutSlots.SharedCanvas` | `DrawingSheet`, [The drawing is the main view](#the-drawing-is-the-main-view) |
28
+ | A geometry expression declared in `expressions:` and read with `phx.frontend.useRootEntityState()` | `$panes`, [Geometry from the spec](#geometry-from-the-spec) |
29
+ | Dimension lines, opening symbols and a title block drawn from the configuration | `ShopDrawing`, `DimensionLine`, `OpeningSymbol`, `TitleBlock` |
30
+ | `$filter.active` with `$eval` on a `genericDimension` and an `attributeSelection` | `MillimetreField`, `SegmentedField`, [Questions that depend on earlier answers](#questions-that-depend-on-earlier-answers) |
31
+ | `warning` invariants shown in the drawing and in the inputs | [Rules the drawing shows](#rules-the-drawing-shows) |
32
+ | `phx.frontend.useFinalizeActions()` for the spec's `order` action | `OrderPanel`, [Add to order](#add-to-order) |
33
+
34
+ ## The drawing is the main view
35
+
36
+ `src/elements/ConfiguratorLayout.tsx` mounts the services and a page, never `LayoutSlots.SharedCanvas`, so no scene loads and the app loader does not wait for one. On a desktop the inputs are a column on the left and the drawing sheet fills the rest; on a phone the sheet stays at the top and the inputs scroll below it.
37
+
38
+ ```tsx
39
+ import { Box } from '@mui/material'
40
+
41
+ import { LayoutSlots } from '@salsita-ai/sdk/configurator'
42
+
43
+ import { phx } from '../../.phoenix/phx.js'
44
+ import { DesktopPage } from '../_shared/DesktopPage.js'
45
+ import { PhonePage } from '../_shared/PhonePage.js'
46
+ import { FENWICK } from '../_shared/tokens.js'
47
+
48
+ // A window configurator whose main view is a shop drawing instead of a scene. The layout never mounts
49
+ // `LayoutSlots.SharedCanvas`, so no 3D scene loads; the drawing is SVG that React renders from the configuration.
50
+ const ConfiguratorLayout = () => {
51
+ // `null` until the container has been measured
52
+ const viewports = phx.frontend.useActiveViewports()
53
+ const isDesktop = viewports?.includes('desktop') ?? false
54
+
55
+ return (
56
+ <Box component="div" sx={{ position: 'absolute', inset: 0, overflow: 'clip', bgcolor: FENWICK.paper }}>
57
+ <LayoutSlots.Services />
58
+ {viewports !== null && (isDesktop ? <DesktopPage /> : <PhonePage />)}
59
+ </Box>
60
+ )
61
+ }
62
+
63
+ export default phx.frontend.createCustomPhoenixLayoutElement('ConfiguratorLayout', ConfiguratorLayout)
64
+ ```
65
+
66
+ `DrawingSheet` is the paper: a grid, a ruled double border, the elevation, and the title block along the right edge, or as one row of figures along the bottom on a phone.
67
+
68
+ ```tsx
69
+ import { Box, Typography } from '@mui/material'
70
+
71
+ import { phx } from '../../.phoenix/phx.js'
72
+ import { ShopDrawing } from './ShopDrawing.js'
73
+ import { TitleBlock } from './TitleBlock.js'
74
+ import { SHEET } from './sheet-style.js'
75
+
76
+ // The title column gives way to the drawing on a narrow screen
77
+ const TITLE_WIDTH = 'clamp(200px, 26%, 260px)'
78
+ const PHONE_TITLE_HEIGHT = 49
79
+
80
+ interface DrawingSheetProps {
81
+ readonly compact: boolean
82
+ }
83
+
84
+ // The sheet the elevation is drawn on: drafting paper with a grid and a ruled border, the drawing, and the title
85
+ // block, along the right edge on a desktop and as a row of figures along the bottom on a phone
86
+ export const DrawingSheet = ({ compact }: DrawingSheetProps) => {
87
+ const casement = phx.frontend.useRootEntityState()
88
+ const gutter = compact ? 10 : 24
89
+
90
+ return (
91
+ <Box
92
+ component="div"
93
+ sx={{
94
+ position: 'absolute',
95
+ inset: 0,
96
+ bgcolor: SHEET.paper,
97
+ backgroundImage: `linear-gradient(${SHEET.grid} 1px, transparent 1px), linear-gradient(90deg, ${SHEET.grid} 1px, transparent 1px)`,
98
+ backgroundSize: '20px 20px',
99
+ }}
100
+ >
101
+ <Box
102
+ component="div"
103
+ sx={{
104
+ position: 'absolute',
105
+ inset: `${gutter}px`,
106
+ display: 'grid',
107
+ gridTemplateColumns: compact ? 'minmax(0, 1fr)' : `minmax(0, 1fr) ${TITLE_WIDTH}`,
108
+ gridTemplateRows: compact ? `minmax(0, 1fr) ${PHONE_TITLE_HEIGHT}px` : 'minmax(0, 1fr)',
109
+ border: `1.5px solid ${SHEET.ink}`,
110
+ outline: `1px solid ${SHEET.ink}`,
111
+ outlineOffset: 3,
112
+ }}
113
+ >
114
+ <Box
115
+ component="div"
116
+ sx={{ display: 'flex', flexDirection: 'column', gap: 1, minBlockSize: 0, margin: compact ? 0.75 : 3 }}
117
+ >
118
+ {/* The view's name and the sections' face widths, above the drawing so they never crowd it */}
119
+ <Box
120
+ component="div"
121
+ sx={{
122
+ display: 'flex',
123
+ flexWrap: 'wrap',
124
+ justifyContent: 'space-between',
125
+ columnGap: 2,
126
+ fontFamily: SHEET.font,
127
+ }}
128
+ >
129
+ <Typography
130
+ sx={{
131
+ fontFamily: SHEET.font,
132
+ fontSize: compact ? 10 : 12,
133
+ fontWeight: 600,
134
+ letterSpacing: '0.16em',
135
+ color: SHEET.ink,
136
+ }}
137
+ >
138
+ ELEVATION · VIEWED FROM OUTSIDE
139
+ </Typography>
140
+ <Typography sx={{ fontFamily: SHEET.font, fontSize: compact ? 10 : 11, color: SHEET.inkSoft }}>
141
+ FRAME {casement.$frameWidth} · MULLION {casement.$mullionWidth}
142
+ </Typography>
143
+ </Box>
144
+ <Box component="div" sx={{ position: 'relative', flex: 1, minBlockSize: 0 }}>
145
+ <ShopDrawing />
146
+ </Box>
147
+ </Box>
148
+ <Box component="div" sx={compact ? { borderBlockStart: `1px solid ${SHEET.ink}` } : undefined}>
149
+ <TitleBlock compact={compact} />
150
+ </Box>
151
+ </Box>
152
+ </Box>
153
+ )
154
+ }
155
+ ```
156
+
157
+ ## Geometry from the spec
158
+
159
+ Where each pane sits is part of the product, not of the drawing, so it is an expression. `$panes` returns the clear openings inside the frame in millimetres, from the frame's top left corner as seen from outside, with how each pane opens and whether its sash is wider than its hinges carry. Declaring it in `expressions:` gives the layout its type.
160
+
161
+ ```yaml
162
+ expressions:
163
+ Window:
164
+ $panes:
165
+ type: array
166
+ items:
167
+ type: object
168
+ properties:
169
+ key:
170
+ type: string
171
+ x:
172
+ type: number
173
+ y:
174
+ type: number
175
+ width:
176
+ type: number
177
+ height:
178
+ type: number
179
+ opening:
180
+ enum:
181
+ - Fixed
182
+ - SideLeft
183
+ - SideRight
184
+ - TiltLeft
185
+ - TiltRight
186
+ - Top
187
+ oversize:
188
+ type: boolean
189
+ required:
190
+ - key
191
+ - x
192
+ - y
193
+ - width
194
+ - height
195
+ - opening
196
+ - oversize
197
+ additionalProperties: false
198
+ $frameWidth:
199
+ type: number
200
+ $mullionWidth:
201
+ type: number
202
+ $glassArea:
203
+ type: number
204
+ $uValue:
205
+ type: number
206
+ $weightKg:
207
+ type: number
208
+ $glassBuildUp:
209
+ type: string
210
+ $price:
211
+ type: number
212
+ ```
213
+
214
+ The same file computes what the title block and the price need from the panes: the glass area, the window's U-value by the area-weighted method, its weight and its price. A single sash follows the hinge choice, a pair is hung from the outer edges like French casements, and of three panes the outer two open and the middle one is fixed.
215
+
216
+ ```ts
217
+ import { phx } from '../../.phoenix/phx.js'
218
+
219
+ // Face widths of the timber sections in millimetres, as the drawing shows them
220
+ const FRAME = 68
221
+ const MULLION = 76
222
+
223
+ const LIGHT_COUNT = { One: 1, Two: 2, Three: 3 }
224
+
225
+ // The widest sash each opening type can carry, in millimetres
226
+ const MAX_SASH_WIDTH = { Fixed: Infinity, SideHung: 900, TiltTurn: 1300, TopHung: 1600 }
227
+
228
+ const GLASS = {
229
+ Double: { buildUp: '4 / 16 Ar / 4 low-E', ug: 1.1, kgPerM2: 20, gbpPerM2: 95 },
230
+ Triple: { buildUp: '4 / 12 Ar / 4 / 12 Ar / 4, two low-E', ug: 0.6, kgPerM2: 30, gbpPerM2: 150 },
231
+ Acoustic: { buildUp: '6.8 lam / 16 Ar / 4 low-E', ug: 1.0, kgPerM2: 27, gbpPerM2: 170 },
232
+ }
233
+
234
+ const SASH_PRICE = { Fixed: 0, SideHung: 260, TiltTurn: 340, TopHung: 220 }
235
+ const FINISH_FACTOR = { Oak: 1.18, White: 1, Anthracite: 1.06 }
236
+ // Timber frame and spacer: U-value of the frame and linear loss along the glass edge
237
+ const FRAME_U = 1.3
238
+ const EDGE_PSI = 0.04
239
+
240
+ type PaneOpening = 'Fixed' | 'SideLeft' | 'SideRight' | 'TiltLeft' | 'TiltRight' | 'Top'
241
+
242
+ interface WindowChoices {
243
+ readonly lights: keyof typeof LIGHT_COUNT
244
+ readonly opening: keyof typeof MAX_SASH_WIDTH
245
+ readonly hinge: 'Left' | 'Right'
246
+ }
247
+
248
+ // How the pane at `index` of `count` opens: a single sash follows the hinge choice, a pair is hung from the outer
249
+ // edges like French casements, and of three the outer two open and the middle one is fixed
250
+ const paneOpening = (s: WindowChoices, index: number, count: number): PaneOpening => {
251
+ if (s.opening === 'Fixed') {
252
+ return 'Fixed'
253
+ }
254
+
255
+ if (s.opening === 'TopHung') {
256
+ return 'Top'
257
+ }
258
+
259
+ const prefix = s.opening === 'SideHung' ? 'Side' : 'Tilt'
260
+
261
+ if (count === 1) {
262
+ return s.hinge === 'Left' ? `${prefix}Left` : `${prefix}Right`
263
+ }
264
+
265
+ if (index === 0) {
266
+ return `${prefix}Left`
267
+ }
268
+
269
+ if (index === count - 1) {
270
+ return `${prefix}Right`
271
+ }
272
+
273
+ return 'Fixed'
274
+ }
275
+
276
+ export default phx.domain.implementCustomExpressions({
277
+ Window: {
278
+ $frameWidth: () => {
279
+ return FRAME
280
+ },
281
+ $mullionWidth: () => {
282
+ return MULLION
283
+ },
284
+ // The clear openings inside the frame, from the top left corner of the frame, viewed from outside
285
+ $panes: s => {
286
+ const count = LIGHT_COUNT[s.lights]
287
+ const innerWidth = s.width - 2 * FRAME
288
+ const topLightZone = s.topLight ? s.topLightHeight + MULLION : 0
289
+ const mainHeight = s.height - 2 * FRAME - topLightZone
290
+ const paneWidth = (innerWidth - (count - 1) * MULLION) / count
291
+ const panes = Array.from({ length: count }, (_, index) => {
292
+ const opening = paneOpening(s, index, count)
293
+
294
+ return {
295
+ key: `P${index + 1}`,
296
+ x: FRAME + index * (paneWidth + MULLION),
297
+ y: FRAME + topLightZone,
298
+ width: paneWidth,
299
+ height: mainHeight,
300
+ opening,
301
+ oversize: opening !== 'Fixed' && paneWidth > MAX_SASH_WIDTH[s.opening],
302
+ }
303
+ })
304
+
305
+ if (!s.topLight) {
306
+ return panes
307
+ }
308
+
309
+ // The top light is one fixed pane across the whole width, above the transom
310
+ return [
311
+ {
312
+ key: 'T1',
313
+ x: FRAME,
314
+ y: FRAME,
315
+ width: innerWidth,
316
+ height: s.topLightHeight,
317
+ opening: 'Fixed',
318
+ oversize: false,
319
+ },
320
+ ...panes,
321
+ ]
322
+ },
323
+ $glassArea: s => {
324
+ return s.$panes.reduce((area, pane) => area + (pane.width * pane.height) / 1e6, 0)
325
+ },
326
+ // Uw from the glass, the frame and the glass edge, by the area-weighted method of EN ISO 10077
327
+ $uValue: s => {
328
+ const total = (s.width * s.height) / 1e6
329
+ const frameArea = total - s.$glassArea
330
+ const edge = s.$panes.reduce((length, pane) => length + (2 * (pane.width + pane.height)) / 1000, 0)
331
+
332
+ return (s.$glassArea * GLASS[s.glazing].ug + frameArea * FRAME_U + edge * EDGE_PSI) / total
333
+ },
334
+ $weightKg: s => {
335
+ const frameMetres = (2 * (s.width + s.height)) / 1000
336
+
337
+ return s.$glassArea * GLASS[s.glazing].kgPerM2 + frameMetres * 4.5
338
+ },
339
+ $glassBuildUp: s => {
340
+ return GLASS[s.glazing].buildUp
341
+ },
342
+ $price: s => {
343
+ const frameMetres = (2 * (s.width + s.height)) / 1000
344
+ const sashes = s.$panes.filter(pane => pane.opening !== 'Fixed').length
345
+ const base =
346
+ 180 +
347
+ frameMetres * 85 +
348
+ s.$glassArea * GLASS[s.glazing].gbpPerM2 +
349
+ sashes * SASH_PRICE[s.opening] +
350
+ (s.topLight ? 120 : 0) +
351
+ (s.glazingBars ? 45 * s.$panes.length : 0)
352
+
353
+ return Math.round(base * FINISH_FACTOR[s.finish])
354
+ },
355
+ },
356
+ })
357
+ ```
358
+
359
+ ## Drawing the elevation
360
+
361
+ `ShopDrawing` reads the window with `phx.frontend.useRootEntityState()` and draws it in millimetres. It measures its box and fits the window with room for the dimension tiers, then sizes lines, ticks and lettering in screen pixels, so a 500 mm window and a 2,400 mm one read the same and the proportions follow the configuration.
362
+
363
+ - The frame is one rectangle in the finish's tone; the panes are drawn over it, so what is left between them is the frame, the mullions and the transom.
364
+ - A sash is drawn as a rectangle with its stiles and rails, the glass inset inside it; a fixed pane has only a glazing bead and the word "FIXED". Two short strokes in a corner are the drafting sign for glass, and glazing bars draw a cross.
365
+ - `OpeningSymbol` draws the opening symbol of an elevation viewed from outside: dashed lines whose apex points to the hinged side, a second pair pointing down for the tilt of a tilt-and-turn sash, and the handle on the side that opens.
366
+ - Below the window a chain of pane widths, measured to the mullions' centres, and the overall width; to the left the top light and the main panes, and the overall height.
367
+ - A sash that is too wide is outlined in red and labelled "SASH TOO WIDE".
368
+
369
+ ```tsx
370
+ import { Box } from '@mui/material'
371
+ import { useLayoutEffect, useRef, useState } from 'react'
372
+
373
+ import { phx } from '../../.phoenix/phx.js'
374
+ import { DimensionLine } from './DimensionLine.js'
375
+ import { OpeningSymbol } from './OpeningSymbol.js'
376
+ import { SHEET } from './sheet-style.js'
377
+
378
+ // Sash stiles and rails, and the bead that holds a fixed pane, in millimetres
379
+ const SASH = 56
380
+ const BEAD = 14
381
+ const GLAZING_BAR = 26
382
+ // Room around the window for the dimension tiers, in screen pixels
383
+ const TIER = 34
384
+ const MARGIN = { top: 16, right: 16 }
385
+
386
+ interface Size {
387
+ readonly width: number
388
+ readonly height: number
389
+ }
390
+
391
+ // The element's size, measured again whenever it changes
392
+ const useSize = () => {
393
+ const ref = useRef<HTMLDivElement>(null)
394
+ const [size, setSize] = useState<Size | null>(null)
395
+
396
+ useLayoutEffect(() => {
397
+ const element = ref.current
398
+
399
+ if (element === null) {
400
+ return
401
+ }
402
+
403
+ const observer = new ResizeObserver(([entry]) => {
404
+ if (entry) {
405
+ setSize({ width: entry.contentRect.width, height: entry.contentRect.height })
406
+ }
407
+ })
408
+ observer.observe(element)
409
+
410
+ return () => observer.disconnect()
411
+ }, [])
412
+
413
+ return { ref, size }
414
+ }
415
+
416
+ // A rectangle moved in by `by` on every side. A size the spec allows can leave no room, for example three panes across
417
+ // a 500 mm window, so the size never drops below zero; what has no size is not drawn.
418
+ const inset = (rect: { x: number; y: number; width: number; height: number }, by: number) => {
419
+ return {
420
+ x: rect.x + by,
421
+ y: rect.y + by,
422
+ width: Math.max(0, rect.width - 2 * by),
423
+ height: Math.max(0, rect.height - 2 * by),
424
+ }
425
+ }
426
+
427
+ const hasSize = (rect: { width: number; height: number }) => {
428
+ return rect.width > 0 && rect.height > 0
429
+ }
430
+
431
+ // The elevation of the window as configured, drawn in millimetres from the spec's `$panes` expression. The drawing is
432
+ // fitted to its box: lines, ticks and lettering are sized in screen pixels, so a small window and a large one read
433
+ // the same, and its proportions follow the width and height.
434
+ export const ShopDrawing = () => {
435
+ const casement = phx.frontend.useRootEntityState()
436
+ const { ref, size } = useSize()
437
+ const { width: W, height: H, $panes: panes, $mullionWidth: mullion } = casement
438
+ const mainPanes = panes.filter(pane => pane.key.startsWith('P'))
439
+ const topLight = panes.find(pane => pane.key === 'T1')
440
+ const transom = topLight ? topLight.y + topLight.height + mullion / 2 : null
441
+ // A tall top light can leave the main panes no height. They are not drawn then, so neither is a height split
442
+ // over them nor the chain of their widths
443
+ const mainPanesDrawn = mainPanes.some(hasSize)
444
+ const split = mainPanesDrawn ? transom : null
445
+ const chained = mainPanesDrawn && mainPanes.length > 1
446
+ const left = (split !== null ? 2 : 1) * TIER + 18
447
+ const bottom = (chained ? 2 : 1) * TIER + 14
448
+ const timber = SHEET.timber[casement.finish]
449
+
450
+ // Pixels per millimetre: the window and its margins fill the box in its tighter direction
451
+ const scale =
452
+ size === null
453
+ ? 0
454
+ : Math.max(0.01, Math.min((size.width - left - MARGIN.right) / W, (size.height - MARGIN.top - bottom) / H))
455
+ const px = scale > 0 ? 1 / scale : 1
456
+ const viewBox = [-left * px, -MARGIN.top * px, W + (left + MARGIN.right) * px, H + (MARGIN.top + bottom) * px]
457
+ // The chain of pane widths along the bottom, measured to the centres of the mullions
458
+ const chain = [0, ...mainPanes.slice(0, -1).map(pane => pane.x + pane.width + mullion / 2), W]
459
+
460
+ return (
461
+ <Box component="div" ref={ref} sx={{ position: 'absolute', inset: 0 }}>
462
+ {size !== null && scale > 0 && (
463
+ <svg
464
+ viewBox={viewBox.join(' ')}
465
+ width={size.width}
466
+ height={size.height}
467
+ role="img"
468
+ aria-label={`Elevation of a ${W} by ${H} mm window`}
469
+ style={{ display: 'block' }}
470
+ >
471
+ {/* The frame; the openings drawn over it leave the frame, the mullions and the transom */}
472
+ <rect x={0} y={0} width={W} height={H} fill={timber} stroke={SHEET.ink} strokeWidth={2 * px} />
473
+
474
+ {panes.map(pane => {
475
+ const isSash = pane.opening !== 'Fixed'
476
+ const opening = inset(pane, 0)
477
+ const glass = inset(pane, isSash ? SASH : BEAD)
478
+ const outline = pane.oversize ? SHEET.attention : SHEET.ink
479
+
480
+ // A top light can leave the main panes no height at all; the spec's warning says so
481
+ if (!hasSize(opening)) {
482
+ return null
483
+ }
484
+
485
+ return (
486
+ <g key={pane.key}>
487
+ <rect {...opening} fill={isSash ? timber : SHEET.glass} stroke={SHEET.ink} strokeWidth={1.2 * px} />
488
+ {hasSize(glass) && (
489
+ <>
490
+ <rect {...glass} fill={SHEET.glass} stroke={SHEET.glassStroke} strokeWidth={px} />
491
+ {/* Two short strokes in a corner, the drafting sign for glass */}
492
+ <polyline
493
+ points={`${glass.x + glass.width * 0.1},${glass.y + glass.height * 0.28} ${glass.x + glass.width * 0.28},${glass.y + glass.height * 0.1}`}
494
+ stroke={SHEET.glassStroke}
495
+ strokeWidth={1.2 * px}
496
+ fill="none"
497
+ />
498
+ <polyline
499
+ points={`${glass.x + glass.width * 0.1},${glass.y + glass.height * 0.4} ${glass.x + glass.width * 0.4},${glass.y + glass.height * 0.1}`}
500
+ stroke={SHEET.glassStroke}
501
+ strokeWidth={1.2 * px}
502
+ fill="none"
503
+ />
504
+ {casement.glazingBars && (
505
+ <g fill={timber} stroke={SHEET.ink} strokeWidth={px}>
506
+ <rect
507
+ x={glass.x + glass.width / 2 - GLAZING_BAR / 2}
508
+ y={glass.y}
509
+ width={GLAZING_BAR}
510
+ height={glass.height}
511
+ />
512
+ <rect
513
+ x={glass.x}
514
+ y={glass.y + glass.height / 2 - GLAZING_BAR / 2}
515
+ width={glass.width}
516
+ height={GLAZING_BAR}
517
+ />
518
+ </g>
519
+ )}
520
+ <OpeningSymbol
521
+ opening={pane.opening}
522
+ glass={glass}
523
+ sash={opening}
524
+ stile={SASH}
525
+ px={px}
526
+ color={outline}
527
+ />
528
+ </>
529
+ )}
530
+ {pane.oversize && (
531
+ <>
532
+ <rect
533
+ {...inset(pane, -6 * px)}
534
+ fill="none"
535
+ stroke={SHEET.attention}
536
+ strokeWidth={2 * px}
537
+ strokeDasharray={`${6 * px} ${4 * px}`}
538
+ />
539
+ <text
540
+ x={pane.x + pane.width / 2}
541
+ y={pane.y + 30 * px}
542
+ textAnchor="middle"
543
+ fill={SHEET.attention}
544
+ fontFamily={SHEET.font}
545
+ fontSize={12 * px}
546
+ fontWeight={700}
547
+ letterSpacing={px}
548
+ >
549
+ SASH TOO WIDE
550
+ </text>
551
+ </>
552
+ )}
553
+ </g>
554
+ )
555
+ })}
556
+
557
+ {chained &&
558
+ chain
559
+ .slice(1)
560
+ .map((end, index) => (
561
+ <DimensionLine
562
+ key={`chain-${end}`}
563
+ from={chain[index] ?? 0}
564
+ to={end}
565
+ at={H + TIER * px}
566
+ origin={H}
567
+ axis="horizontal"
568
+ px={px}
569
+ />
570
+ ))}
571
+ <DimensionLine from={0} to={W} at={H + (chained ? 2 : 1) * TIER * px} origin={H} axis="horizontal" px={px} />
572
+
573
+ {split !== null && (
574
+ <>
575
+ <DimensionLine from={0} to={split} at={-TIER * px} origin={0} axis="vertical" px={px} />
576
+ <DimensionLine from={split} to={H} at={-TIER * px} origin={0} axis="vertical" px={px} />
577
+ </>
578
+ )}
579
+ <DimensionLine
580
+ from={0}
581
+ to={H}
582
+ at={-(split !== null ? 2 : 1) * TIER * px}
583
+ origin={0}
584
+ axis="vertical"
585
+ px={px}
586
+ />
587
+ </svg>
588
+ )}
589
+ </Box>
590
+ )
591
+ }
592
+ ```
593
+
594
+ `DimensionLine` draws one dimension the way a shop drawing does: extension lines that stop short of the object, a dimension line with an architectural tick at each end, and the length above the line.
595
+
596
+ ```tsx
597
+ import { SHEET } from './sheet-style.js'
598
+
599
+ interface DimensionLineProps {
600
+ // Along the measured axis, in millimetres
601
+ readonly from: number
602
+ readonly to: number
603
+ // Where the dimension line runs across the measured axis, in millimetres
604
+ readonly at: number
605
+ // Where the extension lines start, at the edge of the object, in millimetres
606
+ readonly origin: number
607
+ readonly axis: 'horizontal' | 'vertical'
608
+ // Millimetres per screen pixel, so ticks, gaps and text keep their size at any scale
609
+ readonly px: number
610
+ }
611
+
612
+ // A dimension as a shop drawing draws it: extension lines that stop short of the object, a dimension line between
613
+ // them with an architectural tick at each end, and the length in millimetres above the line
614
+ export const DimensionLine = ({ from, to, at, origin, axis, px }: DimensionLineProps) => {
615
+ const gap = 4 * px
616
+ const overshoot = 5 * px
617
+ const tick = 5 * px
618
+ const direction = at > origin ? 1 : -1
619
+ const label = String(Math.round(Math.abs(to - from)))
620
+ const middle = (from + to) / 2
621
+ // Maps (along, across) to SVG coordinates for the axis
622
+ const point = (along: number, across: number) => (axis === 'horizontal' ? `${along},${across}` : `${across},${along}`)
623
+
624
+ return (
625
+ <g stroke={SHEET.dimension} strokeWidth={px} fill="none">
626
+ <polyline points={`${point(from, origin + direction * gap)} ${point(from, at + direction * overshoot)}`} />
627
+ <polyline points={`${point(to, origin + direction * gap)} ${point(to, at + direction * overshoot)}`} />
628
+ <polyline points={`${point(from - overshoot, at)} ${point(to + overshoot, at)}`} />
629
+ {[from, to].map(end => (
630
+ <polyline
631
+ key={end}
632
+ strokeWidth={1.6 * px}
633
+ points={`${point(end - tick, at + tick)} ${point(end + tick, at - tick)}`}
634
+ />
635
+ ))}
636
+ <text
637
+ x={axis === 'horizontal' ? middle : at - 5 * px}
638
+ y={axis === 'horizontal' ? at - 5 * px : middle}
639
+ transform={axis === 'vertical' ? `rotate(-90 ${at - 5 * px} ${middle})` : undefined}
640
+ textAnchor="middle"
641
+ fill={SHEET.dimension}
642
+ stroke="none"
643
+ fontFamily={SHEET.font}
644
+ fontSize={13 * px}
645
+ fontWeight={500}
646
+ >
647
+ {label}
648
+ </text>
649
+ </g>
650
+ )
651
+ }
652
+ ```
653
+
654
+ ## Questions that depend on earlier answers
655
+
656
+ The hinge side matters only for a single sash that swings sideways, and the top light's height only when there is a top light. Both are spec components with a filter:
657
+
658
+ ```yaml
659
+ components:
660
+ Window:
661
+ genericDimension:
662
+ topLightHeight:
663
+ value:
664
+ $bind: topLightHeight
665
+ min: 250
666
+ max: 700
667
+ unit: mm
668
+ decimalPlaces: 0
669
+ $filter:
670
+ active:
671
+ $eval: self.topLight
672
+ attributeSelection:
673
+ hinge:
674
+ value:
675
+ $bind: hinge
676
+ options:
677
+ - label: Left
678
+ value: Left
679
+ - label: Right
680
+ value: Right
681
+ # A single sash that swings sideways needs a hinge side; paired sashes are hung from the outer edges
682
+ $filter:
683
+ active:
684
+ $eval: self.lights === 'One' && (self.opening === 'SideHung' || self.opening === 'TiltTurn')
685
+ ```
686
+
687
+ `MillimetreField` and `SegmentedField` ask `phx.frontend.useComponent()` for their component and render nothing while it is inactive, because the control hooks throw for an inactive component; [Ardent Compressor](?path=/docs/layouts-gallery-ardent-compressor--docs#questions-that-depend-on-earlier-answers) shows the pattern in full. `MillimetreField` stages each value while the slider moves, so the drawing follows the thumb, and commits it when the thumb is let go, when a typed value is entered or after a 10 mm step.
688
+
689
+ ## Rules the drawing shows
690
+
691
+ A sash wider than its hinges carry would sag, a sash narrower than its two stiles has no room for glass, and a top light can leave too little window below it. All three rules are `warning` invariants: the change is kept, so the drawing shows the problem, the inputs say how to fix it, and the window cannot be ordered until it is fixed. The drawing never draws a negative size: a sash with no room for glass is drawn without glass or symbol, and main panes with no height are left out.
692
+
693
+ ```yaml
694
+ invariants:
695
+ Window:
696
+ sashSize:
697
+ types:
698
+ - sashTooWide
699
+ - sashTooNarrow
700
+ topLightTooTall:
701
+ type: topLightTooTall
702
+ severity: warning
703
+ message: The top light leaves less than 500 mm for the main panes. Lower the top light or raise the window.
704
+ ```
705
+
706
+ ```ts
707
+ import { phx } from '../../.phoenix/phx.js'
708
+
709
+ const MAX_SASH_WIDTH = { Fixed: Infinity, SideHung: 900, TiltTurn: 1300, TopHung: 1600 }
710
+ const OPENING_LABEL = { Fixed: 'fixed', SideHung: 'side-hung', TiltTurn: 'tilt and turn', TopHung: 'top-hung' }
711
+ const MIN_MAIN_HEIGHT = 500
712
+ // A sash's two stiles are 56 mm each, so a narrower opening leaves no room for glass
713
+ const MIN_SASH_WIDTH = 2 * 56
714
+
715
+ // Both rules are warnings: the drawing keeps the change and marks the problem, and the window cannot be ordered until
716
+ // it is resolved
717
+ export default phx.domain.implementCustomInvariants({
718
+ Window: {
719
+ // A sash wider than its hinges carry would sag, and one narrower than its two stiles has no room for glass; the
720
+ // messages name the limit
721
+ sashSize: s => {
722
+ const oversize = s.$panes.find(pane => pane.oversize)
723
+ const undersize = s.$panes.find(pane => pane.opening !== 'Fixed' && pane.width < MIN_SASH_WIDTH)
724
+
725
+ if (oversize !== undefined) {
726
+ return [
727
+ {
728
+ type: 'sashTooWide',
729
+ severity: 'warning',
730
+ message: `A ${OPENING_LABEL[s.opening]} sash can be at most ${MAX_SASH_WIDTH[s.opening].toLocaleString('en-GB')} mm wide, and these are ${Math.round(oversize.width).toLocaleString('en-GB')} mm. Add a pane across or make the window narrower.`,
731
+ },
732
+ ]
733
+ }
734
+
735
+ if (undersize !== undefined) {
736
+ return [
737
+ {
738
+ type: 'sashTooNarrow',
739
+ severity: 'warning',
740
+ message: `A sash needs at least ${MIN_SASH_WIDTH} mm for its stiles, and these are ${Math.round(undersize.width)} mm. Use fewer panes across or make the window wider.`,
741
+ },
742
+ ]
743
+ }
744
+
745
+ return []
746
+ },
747
+ topLightTooTall: s => {
748
+ return !s.topLight || s.height - 2 * s.$frameWidth - s.topLightHeight - s.$mullionWidth >= MIN_MAIN_HEIGHT
749
+ },
750
+ },
751
+ })
752
+ ```
753
+
754
+ The drawing marks the oversized sashes from `$panes`, whose `oversize` flag the same limit sets. `WindowInputs` shows each warning under the group of inputs that can resolve it, reading it from `phx.frontend.useFinalizeViolations()`, and `OrderPanel` disables **Add to order** with the warning as its hint.
755
+
756
+ ## Add to order
757
+
758
+ The spec declares one finalize action. Its handler, `src/handlers/order-window.ts`, stands in for the joinery's order service: it waits, then enters the `ordered` navigation state. In that state the panel confirms the order and the inputs are locked until **Draw another**. A viewer of a shared configuration sees the price and no button.
759
+
760
+ ```yaml
761
+ components:
762
+ Window:
763
+ price:
764
+ main:
765
+ price:
766
+ $read: $price
767
+ currency: GBP
768
+ finalizeAction:
769
+ order:
770
+ variant: primary
771
+ label: Add to order
772
+ title: Add the window as drawn to your order
773
+ icon: ShoppingBag
774
+ handler: orderWindow
775
+ ```
776
+
777
+ ```tsx
778
+ import { Alert, Box, Button, CircularProgress, Typography } from '@mui/material'
779
+
780
+ import { phx } from '../../.phoenix/phx.js'
781
+ import { FENWICK } from './tokens.js'
782
+
783
+ interface OrderPanelProps {
784
+ // On a phone the panel is a bar: the price beside the button, without the figures
785
+ readonly compact: boolean
786
+ }
787
+
788
+ // The price and the spec's `order` finalize action. While a warning holds, the action is `disabled` and its `hints`
789
+ // say why. In the `ordered` navigation state the panel confirms the order instead; a viewer sees the price only.
790
+ export const OrderPanel = ({ compact }: OrderPanelProps) => {
791
+ const casement = phx.frontend.useRootEntityState()
792
+ const price = phx.frontend.useRootEntityPrice()
793
+ const { primary, hints } = phx.frontend.useFinalizeActions()
794
+ const navigationState = phx.frontend.useActiveNavigationState()
795
+ const navigate = phx.frontend.useNavigate()
796
+ const { accessInfo } = phx.frontend.useAccessService()
797
+ const isPending = primary?.isPending ?? false
798
+
799
+ if (navigationState === 'ordered') {
800
+ return (
801
+ <Box component="div" role="status" sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
802
+ <Box component="div" sx={{ display: 'grid', flex: 1 }}>
803
+ <Typography sx={{ fontFamily: FENWICK.font, fontSize: 18, fontWeight: 600, color: FENWICK.green }}>
804
+ Added to your order
805
+ </Typography>
806
+ <Typography sx={{ fontFamily: FENWICK.font, fontSize: 14, color: FENWICK.muted }}>
807
+ {casement.width} × {casement.height} mm, {price}
808
+ </Typography>
809
+ </Box>
810
+ <Button
811
+ onClick={() => navigate('design')}
812
+ sx={{
813
+ fontFamily: FENWICK.font,
814
+ fontSize: 15,
815
+ color: FENWICK.ink,
816
+ textTransform: 'none',
817
+ textDecoration: 'underline',
818
+ }}
819
+ >
820
+ Draw another
821
+ </Button>
822
+ </Box>
823
+ )
824
+ }
825
+
826
+ const showButton = primary !== null && !accessInfo.viewMode
827
+
828
+ return (
829
+ <Box component="div" sx={{ display: 'grid', gap: 1.25 }}>
830
+ <Box component="div" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
831
+ <Box component="div" sx={{ display: 'grid' }}>
832
+ <Typography
833
+ sx={{ fontFamily: FENWICK.font, fontSize: compact ? 22 : 28, fontWeight: 700, color: FENWICK.ink }}
834
+ >
835
+ {price}
836
+ </Typography>
837
+ <Typography sx={{ fontFamily: FENWICK.font, fontSize: 13, color: FENWICK.muted, whiteSpace: 'nowrap' }}>
838
+ {compact
839
+ ? 'incl. VAT'
840
+ : `Uw ${casement.$uValue.toFixed(2)} W/m²K · ${Math.round(casement.$weightKg)} kg · incl. VAT`}
841
+ </Typography>
842
+ </Box>
843
+ {showButton && (
844
+ <Button
845
+ variant="contained"
846
+ disableElevation
847
+ disabled={primary.disabled || isPending}
848
+ onClick={() => void primary.run()}
849
+ title={primary.title}
850
+ startIcon={isPending ? <CircularProgress size={16} color="inherit" /> : undefined}
851
+ sx={{
852
+ minBlockSize: 48,
853
+ paddingInline: 3,
854
+ borderRadius: 1.5,
855
+ bgcolor: FENWICK.green,
856
+ fontFamily: FENWICK.font,
857
+ fontSize: 16,
858
+ fontWeight: 600,
859
+ textTransform: 'none',
860
+ '&:hover': { bgcolor: '#15302a' },
861
+ '&.Mui-disabled': { bgcolor: FENWICK.line, color: FENWICK.muted },
862
+ }}
863
+ >
864
+ {isPending ? 'Adding…' : primary.label}
865
+ </Button>
866
+ )}
867
+ </Box>
868
+ {!compact && showButton && primary.disabled && hints && (
869
+ <Typography sx={{ fontFamily: FENWICK.font, fontSize: 13, color: FENWICK.attention }}>
870
+ Resolve the drawing's warning to order: {hints}
871
+ </Typography>
872
+ )}
873
+ {primary?.error && <Alert severity="error">The window could not be added: {primary.error.message}</Alert>}
874
+ </Box>
875
+ )
876
+ }
877
+ ```
878
+
879
+ ## On a phone
880
+
881
+ The same layout in a frame 390 pixels wide: the drawing keeps the top of the screen with the size, the U-value and the weight below it, the inputs scroll underneath, and the price with **Add to order** is a bar at the bottom. Opened on its own from the sidebar, the **Phone** story sets Storybook's viewport to 390 × 844.
882
+
883
+ ## Test cases
884
+
885
+ - The page opens on a 1400 × 1250 mm two-light window with side-hung sashes hinged at the outer edges: two dashed symbols with their apexes at the outer sides, the handles on the meeting stiles, the pane widths 700 and 700 below, 1400 overall and 1250 at the side. The title block shows Uw 1.30 W/m²K and 50 kg, and the price is **£1,276.00**.
886
+ - A width of 2100 mm outlines both sashes in red with "SASH TOO WIDE", shows "A side-hung sash can be at most 900 mm wide, and these are 944 mm. …" under **Overall size**, and disables **Add to order**.
887
+ - Three panes across clear the warning: the middle pane reads "FIXED", the outer two open, and the chain reads 710, 680 and 710.
888
+ - Three panes across a 500 mm window show "A sash needs at least 112 mm for its stiles, and these are 71 mm. …" under **Overall size**; the outer sashes are drawn without glass or symbol.
889
+ - A 500 mm high window with the 400 mm top light shows the top light warning, and the drawing keeps the top light and leaves out the main panes.
890
+ - **Top light** adds a fixed pane above a transom, the **Top light height** input, and a second tier on the left with the two heights. A top light of 600 mm in a 1250 mm window shows the top light warning under **Panes**; 380 mm clears it.
891
+ - One pane with **Tilt and turn** shows the **Hinged on the** question; **Right** puts the symbol's apex on the right and the handle on the left, with a second pair of lines pointing down for the tilt.
892
+ - **Top-hung** removes the hinge question and points the symbol's apex to the top, with the handle on the bottom rail.
893
+ - An 800 × 1800 mm window draws tall and narrow, with its lettering at the same size as the wide one's.
894
+ - **Triple** glazing lowers the U-value in the title block and raises the price; **Glazing bars** draws a cross of bars in every pane; **Natural oak** tints the frame and changes the title block's finish.
895
+ - The **−** and **+** buttons change a size by 10 mm, a typed size is clamped to the input's range, and a size cleared and left empty gets its value back.
896
+ - **Add to order** shows a spinner and "Adding…", then "Added to your order" with the size and the price; the inputs are locked until **Draw another**.
897
+ - In the **Phone** story the drawing stays at the top while the inputs scroll, the same warning and drawing changes appear, and **Add to order** confirms the order in the bottom bar.
898
+ - The browser console shows no errors, the page has no `canvas` element, and no model or texture is requested.
899
+
900
+ ## What this layout leaves out
901
+
902
+ - **Read mode.** The inputs are inert and **Add to order** 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.
903
+ - **Sections and a drawing download.** The sheet shows the elevation only. A section through the frame, or a download of the SVG, is more of the same React code.
904
+ - **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.
905
+
906
+ ## Related pages
907
+
908
+ - [Ardent Compressor](?path=/docs/layouts-gallery-ardent-compressor--docs) is a quote form with steps, and [Sunfold Solar](?path=/docs/layouts-gallery-sunfold-solar--docs) a dashboard; neither mounts a canvas either.
909
+ - [Custom Layout](?path=/docs/layouts-custom-layout--docs) explains a project layout step by step.
910
+ - [Configurator Interface → Hooks for a Project Layout](?path=/docs/phoenix-configurator-interface--docs#hooks-for-a-project-layout) lists the hooks used here.
911
+ - [Domain Model → Expressions](?path=/docs/phoenix-domain-model--docs#expressions) describes declared expressions.
912
+
913
+ ## Complete Implementation
914
+
915
+ {/* AUTO-GENERATED SECTION: This section is automatically updated by 'phoenix story build' */}
916
+
917
+ <details>
918
+ <summary>Click to view the complete implementation files</summary>
919
+
920
+ ### ./spec/domain.yaml
921
+
922
+ ```yaml
923
+ model:
924
+ latest:
925
+ entityTypes:
926
+ - Window
927
+ aliases:
928
+ RootEntity:
929
+ - Window
930
+ commonTypes:
931
+ Lights:
932
+ enum:
933
+ - One
934
+ - Two
935
+ - Three
936
+ Opening:
937
+ enum:
938
+ - Fixed
939
+ - SideHung
940
+ - TiltTurn
941
+ - TopHung
942
+ Hinge:
943
+ enum:
944
+ - Left
945
+ - Right
946
+ Glazing:
947
+ enum:
948
+ - Double
949
+ - Triple
950
+ - Acoustic
951
+ Finish:
952
+ enum:
953
+ - Oak
954
+ - White
955
+ - Anthracite
956
+ shapes:
957
+ Window:
958
+ width:
959
+ type: number
960
+ height:
961
+ type: number
962
+ lights:
963
+ $ref: '#/model/latest/commonTypes/Lights'
964
+ topLight:
965
+ type: boolean
966
+ topLightHeight:
967
+ type: number
968
+ opening:
969
+ $ref: '#/model/latest/commonTypes/Opening'
970
+ hinge:
971
+ $ref: '#/model/latest/commonTypes/Hinge'
972
+ glazing:
973
+ $ref: '#/model/latest/commonTypes/Glazing'
974
+ glazingBars:
975
+ type: boolean
976
+ finish:
977
+ $ref: '#/model/latest/commonTypes/Finish'
978
+
979
+ presets:
980
+ - rootEntityType: Window
981
+ initialDomainEntities:
982
+ - id: Window-1
983
+ type: Window
984
+ width: 1400
985
+ height: 1250
986
+ lights: Two
987
+ topLight: false
988
+ topLightHeight: 400
989
+ opening: SideHung
990
+ hinge: Left
991
+ glazing: Double
992
+ glazingBars: false
993
+ finish: White
994
+
995
+ navigationStates:
996
+ - design
997
+ - ordered
998
+
999
+ # The geometry the drawing shows and the figures in its title block: declared here, computed in
1000
+ # src/expressions/window.ts from the size, the panes and the opening
1001
+ expressions:
1002
+ Window:
1003
+ $panes:
1004
+ type: array
1005
+ items:
1006
+ type: object
1007
+ properties:
1008
+ key:
1009
+ type: string
1010
+ x:
1011
+ type: number
1012
+ y:
1013
+ type: number
1014
+ width:
1015
+ type: number
1016
+ height:
1017
+ type: number
1018
+ opening:
1019
+ enum:
1020
+ - Fixed
1021
+ - SideLeft
1022
+ - SideRight
1023
+ - TiltLeft
1024
+ - TiltRight
1025
+ - Top
1026
+ oversize:
1027
+ type: boolean
1028
+ required:
1029
+ - key
1030
+ - x
1031
+ - y
1032
+ - width
1033
+ - height
1034
+ - opening
1035
+ - oversize
1036
+ additionalProperties: false
1037
+ $frameWidth:
1038
+ type: number
1039
+ $mullionWidth:
1040
+ type: number
1041
+ $glassArea:
1042
+ type: number
1043
+ $uValue:
1044
+ type: number
1045
+ $weightKg:
1046
+ type: number
1047
+ $glassBuildUp:
1048
+ type: string
1049
+ $price:
1050
+ type: number
1051
+
1052
+ invariants:
1053
+ Window:
1054
+ sashSize:
1055
+ types:
1056
+ - sashTooWide
1057
+ - sashTooNarrow
1058
+ topLightTooTall:
1059
+ type: topLightTooTall
1060
+ severity: warning
1061
+ message: The top light leaves less than 500 mm for the main panes. Lower the top light or raise the window.
1062
+
1063
+ components:
1064
+ Window:
1065
+ price:
1066
+ main:
1067
+ price:
1068
+ $read: $price
1069
+ currency: GBP
1070
+ finalizeAction:
1071
+ order:
1072
+ variant: primary
1073
+ label: Add to order
1074
+ title: Add the window as drawn to your order
1075
+ icon: ShoppingBag
1076
+ handler: orderWindow
1077
+ genericDimension:
1078
+ width:
1079
+ value:
1080
+ $bind: width
1081
+ min: 500
1082
+ max: 2400
1083
+ unit: mm
1084
+ decimalPlaces: 0
1085
+ height:
1086
+ value:
1087
+ $bind: height
1088
+ min: 500
1089
+ max: 2200
1090
+ unit: mm
1091
+ decimalPlaces: 0
1092
+ topLightHeight:
1093
+ value:
1094
+ $bind: topLightHeight
1095
+ min: 250
1096
+ max: 700
1097
+ unit: mm
1098
+ decimalPlaces: 0
1099
+ $filter:
1100
+ active:
1101
+ $eval: self.topLight
1102
+ attributeSelection:
1103
+ lights:
1104
+ value:
1105
+ $bind: lights
1106
+ options:
1107
+ - label: '1'
1108
+ value: One
1109
+ - label: '2'
1110
+ value: Two
1111
+ - label: '3'
1112
+ value: Three
1113
+ opening:
1114
+ value:
1115
+ $bind: opening
1116
+ options:
1117
+ - label: Fixed
1118
+ description: Glazed into the frame, does not open
1119
+ value: Fixed
1120
+ - label: Side-hung
1121
+ description: Opens outwards like a door
1122
+ value: SideHung
1123
+ - label: Tilt and turn
1124
+ description: Tilts in to vent, turns in to clean
1125
+ value: TiltTurn
1126
+ - label: Top-hung
1127
+ description: Opens outwards from the top
1128
+ value: TopHung
1129
+ hinge:
1130
+ value:
1131
+ $bind: hinge
1132
+ options:
1133
+ - label: Left
1134
+ value: Left
1135
+ - label: Right
1136
+ value: Right
1137
+ # A single sash that swings sideways needs a hinge side; paired sashes are hung from the outer edges
1138
+ $filter:
1139
+ active:
1140
+ $eval: self.lights === 'One' && (self.opening === 'SideHung' || self.opening === 'TiltTurn')
1141
+ glazing:
1142
+ value:
1143
+ $bind: glazing
1144
+ options:
1145
+ - label: Double
1146
+ description: 4-16-4 argon, low-E
1147
+ value: Double
1148
+ - label: Triple
1149
+ description: 4-12-4-12-4 argon, two low-E
1150
+ value: Triple
1151
+ - label: Acoustic
1152
+ description: 6.8 laminated, 16 argon, 4
1153
+ value: Acoustic
1154
+ finish:
1155
+ value:
1156
+ $bind: finish
1157
+ options:
1158
+ - label: Natural oak
1159
+ value: Oak
1160
+ - label: Pure white
1161
+ description: RAL 9016
1162
+ value: White
1163
+ - label: Anthracite
1164
+ description: RAL 7016
1165
+ value: Anthracite
1166
+ attributeSwitch:
1167
+ topLight:
1168
+ value:
1169
+ $bind: topLight
1170
+ glazingBars:
1171
+ value:
1172
+ $bind: glazingBars
1173
+ ```
1174
+
1175
+ ### ./src/\_drawing/DimensionLine.tsx
1176
+
1177
+ ```tsx
1178
+ import { SHEET } from './sheet-style.js'
1179
+
1180
+ interface DimensionLineProps {
1181
+ // Along the measured axis, in millimetres
1182
+ readonly from: number
1183
+ readonly to: number
1184
+ // Where the dimension line runs across the measured axis, in millimetres
1185
+ readonly at: number
1186
+ // Where the extension lines start, at the edge of the object, in millimetres
1187
+ readonly origin: number
1188
+ readonly axis: 'horizontal' | 'vertical'
1189
+ // Millimetres per screen pixel, so ticks, gaps and text keep their size at any scale
1190
+ readonly px: number
1191
+ }
1192
+
1193
+ // A dimension as a shop drawing draws it: extension lines that stop short of the object, a dimension line between
1194
+ // them with an architectural tick at each end, and the length in millimetres above the line
1195
+ export const DimensionLine = ({ from, to, at, origin, axis, px }: DimensionLineProps) => {
1196
+ const gap = 4 * px
1197
+ const overshoot = 5 * px
1198
+ const tick = 5 * px
1199
+ const direction = at > origin ? 1 : -1
1200
+ const label = String(Math.round(Math.abs(to - from)))
1201
+ const middle = (from + to) / 2
1202
+ // Maps (along, across) to SVG coordinates for the axis
1203
+ const point = (along: number, across: number) => (axis === 'horizontal' ? `${along},${across}` : `${across},${along}`)
1204
+
1205
+ return (
1206
+ <g stroke={SHEET.dimension} strokeWidth={px} fill="none">
1207
+ <polyline points={`${point(from, origin + direction * gap)} ${point(from, at + direction * overshoot)}`} />
1208
+ <polyline points={`${point(to, origin + direction * gap)} ${point(to, at + direction * overshoot)}`} />
1209
+ <polyline points={`${point(from - overshoot, at)} ${point(to + overshoot, at)}`} />
1210
+ {[from, to].map(end => (
1211
+ <polyline
1212
+ key={end}
1213
+ strokeWidth={1.6 * px}
1214
+ points={`${point(end - tick, at + tick)} ${point(end + tick, at - tick)}`}
1215
+ />
1216
+ ))}
1217
+ <text
1218
+ x={axis === 'horizontal' ? middle : at - 5 * px}
1219
+ y={axis === 'horizontal' ? at - 5 * px : middle}
1220
+ transform={axis === 'vertical' ? `rotate(-90 ${at - 5 * px} ${middle})` : undefined}
1221
+ textAnchor="middle"
1222
+ fill={SHEET.dimension}
1223
+ stroke="none"
1224
+ fontFamily={SHEET.font}
1225
+ fontSize={13 * px}
1226
+ fontWeight={500}
1227
+ >
1228
+ {label}
1229
+ </text>
1230
+ </g>
1231
+ )
1232
+ }
1233
+ ```
1234
+
1235
+ ### ./src/\_drawing/DrawingSheet.tsx
1236
+
1237
+ ```tsx
1238
+ import { Box, Typography } from '@mui/material'
1239
+
1240
+ import { phx } from '../../.phoenix/phx.js'
1241
+ import { ShopDrawing } from './ShopDrawing.js'
1242
+ import { TitleBlock } from './TitleBlock.js'
1243
+ import { SHEET } from './sheet-style.js'
1244
+
1245
+ // The title column gives way to the drawing on a narrow screen
1246
+ const TITLE_WIDTH = 'clamp(200px, 26%, 260px)'
1247
+ const PHONE_TITLE_HEIGHT = 49
1248
+
1249
+ interface DrawingSheetProps {
1250
+ readonly compact: boolean
1251
+ }
1252
+
1253
+ // The sheet the elevation is drawn on: drafting paper with a grid and a ruled border, the drawing, and the title
1254
+ // block, along the right edge on a desktop and as a row of figures along the bottom on a phone
1255
+ export const DrawingSheet = ({ compact }: DrawingSheetProps) => {
1256
+ const casement = phx.frontend.useRootEntityState()
1257
+ const gutter = compact ? 10 : 24
1258
+
1259
+ return (
1260
+ <Box
1261
+ component="div"
1262
+ sx={{
1263
+ position: 'absolute',
1264
+ inset: 0,
1265
+ bgcolor: SHEET.paper,
1266
+ backgroundImage: `linear-gradient(${SHEET.grid} 1px, transparent 1px), linear-gradient(90deg, ${SHEET.grid} 1px, transparent 1px)`,
1267
+ backgroundSize: '20px 20px',
1268
+ }}
1269
+ >
1270
+ <Box
1271
+ component="div"
1272
+ sx={{
1273
+ position: 'absolute',
1274
+ inset: `${gutter}px`,
1275
+ display: 'grid',
1276
+ gridTemplateColumns: compact ? 'minmax(0, 1fr)' : `minmax(0, 1fr) ${TITLE_WIDTH}`,
1277
+ gridTemplateRows: compact ? `minmax(0, 1fr) ${PHONE_TITLE_HEIGHT}px` : 'minmax(0, 1fr)',
1278
+ border: `1.5px solid ${SHEET.ink}`,
1279
+ outline: `1px solid ${SHEET.ink}`,
1280
+ outlineOffset: 3,
1281
+ }}
1282
+ >
1283
+ <Box
1284
+ component="div"
1285
+ sx={{ display: 'flex', flexDirection: 'column', gap: 1, minBlockSize: 0, margin: compact ? 0.75 : 3 }}
1286
+ >
1287
+ {/* The view's name and the sections' face widths, above the drawing so they never crowd it */}
1288
+ <Box
1289
+ component="div"
1290
+ sx={{
1291
+ display: 'flex',
1292
+ flexWrap: 'wrap',
1293
+ justifyContent: 'space-between',
1294
+ columnGap: 2,
1295
+ fontFamily: SHEET.font,
1296
+ }}
1297
+ >
1298
+ <Typography
1299
+ sx={{
1300
+ fontFamily: SHEET.font,
1301
+ fontSize: compact ? 10 : 12,
1302
+ fontWeight: 600,
1303
+ letterSpacing: '0.16em',
1304
+ color: SHEET.ink,
1305
+ }}
1306
+ >
1307
+ ELEVATION · VIEWED FROM OUTSIDE
1308
+ </Typography>
1309
+ <Typography sx={{ fontFamily: SHEET.font, fontSize: compact ? 10 : 11, color: SHEET.inkSoft }}>
1310
+ FRAME {casement.$frameWidth} · MULLION {casement.$mullionWidth}
1311
+ </Typography>
1312
+ </Box>
1313
+ <Box component="div" sx={{ position: 'relative', flex: 1, minBlockSize: 0 }}>
1314
+ <ShopDrawing />
1315
+ </Box>
1316
+ </Box>
1317
+ <Box component="div" sx={compact ? { borderBlockStart: `1px solid ${SHEET.ink}` } : undefined}>
1318
+ <TitleBlock compact={compact} />
1319
+ </Box>
1320
+ </Box>
1321
+ </Box>
1322
+ )
1323
+ }
1324
+ ```
1325
+
1326
+ ### ./src/\_drawing/OpeningSymbol.tsx
1327
+
1328
+ ```tsx
1329
+ import { SHEET } from './sheet-style.js'
1330
+
1331
+ type PaneOpening = 'Fixed' | 'SideLeft' | 'SideRight' | 'TiltLeft' | 'TiltRight' | 'Top'
1332
+
1333
+ interface Rect {
1334
+ readonly x: number
1335
+ readonly y: number
1336
+ readonly width: number
1337
+ readonly height: number
1338
+ }
1339
+
1340
+ interface OpeningSymbolProps {
1341
+ readonly opening: PaneOpening
1342
+ // The glass the lines are drawn across, and the sash whose stile carries the handle, in millimetres
1343
+ readonly glass: Rect
1344
+ readonly sash: Rect
1345
+ // The width of the sash's stiles and rails
1346
+ readonly stile: number
1347
+ readonly px: number
1348
+ readonly color: string
1349
+ }
1350
+
1351
+ // The opening symbol of an elevation viewed from outside: dashed lines whose apex points to the hinged side, a second
1352
+ // pair pointing down for the tilt of a tilt-and-turn sash, and the handle on the side that opens. A fixed light is
1353
+ // marked "FIXED".
1354
+ export const OpeningSymbol = ({ opening, glass, sash, stile, px, color }: OpeningSymbolProps) => {
1355
+ const { x, y, width, height } = glass
1356
+ const right = x + width
1357
+ const bottom = y + height
1358
+ const midX = x + width / 2
1359
+ const midY = y + height / 2
1360
+ const dash = `${8 * px} ${5 * px}`
1361
+ const handleLength = Math.max(0, Math.min(120, height / 3))
1362
+ const handleWidth = Math.max(0, Math.min(16, stile / 2))
1363
+
1364
+ if (opening === 'Fixed') {
1365
+ return (
1366
+ <text
1367
+ x={midX}
1368
+ y={midY}
1369
+ textAnchor="middle"
1370
+ dominantBaseline="middle"
1371
+ fill={SHEET.inkSoft}
1372
+ fontFamily={SHEET.font}
1373
+ fontSize={11 * px}
1374
+ letterSpacing={2 * px}
1375
+ >
1376
+ FIXED
1377
+ </text>
1378
+ )
1379
+ }
1380
+
1381
+ const hingeLeft = opening === 'SideLeft' || opening === 'TiltLeft'
1382
+ const sideLines =
1383
+ opening === 'Top'
1384
+ ? `${x},${bottom} ${midX},${y} ${right},${bottom}`
1385
+ : hingeLeft
1386
+ ? `${right},${y} ${x},${midY} ${right},${bottom}`
1387
+ : `${x},${y} ${right},${midY} ${x},${bottom}`
1388
+ const tiltLines =
1389
+ opening === 'TiltLeft' || opening === 'TiltRight' ? `${x},${y} ${midX},${bottom} ${right},${y}` : null
1390
+ // The handle sits opposite the hinges: on the free side, or at the bottom of a top-hung sash
1391
+ const handle =
1392
+ opening === 'Top'
1393
+ ? {
1394
+ x: midX - handleLength / 2,
1395
+ y: sash.y + sash.height - (stile + handleWidth) / 2,
1396
+ width: handleLength,
1397
+ height: handleWidth,
1398
+ }
1399
+ : {
1400
+ x: hingeLeft ? sash.x + sash.width - (stile + handleWidth) / 2 : sash.x + (stile - handleWidth) / 2,
1401
+ y: midY - handleLength / 2,
1402
+ width: handleWidth,
1403
+ height: handleLength,
1404
+ }
1405
+
1406
+ return (
1407
+ <g>
1408
+ <polyline points={sideLines} fill="none" stroke={color} strokeWidth={1.2 * px} strokeDasharray={dash} />
1409
+ {tiltLines && (
1410
+ <polyline points={tiltLines} fill="none" stroke={color} strokeWidth={1.2 * px} strokeDasharray={dash} />
1411
+ )}
1412
+ {handleLength > 0 && (
1413
+ <rect {...handle} rx={handleWidth / 2} fill={SHEET.paper} stroke={SHEET.ink} strokeWidth={1.4 * px} />
1414
+ )}
1415
+ </g>
1416
+ )
1417
+ }
1418
+ ```
1419
+
1420
+ ### ./src/\_drawing/ShopDrawing.tsx
1421
+
1422
+ ```tsx
1423
+ import { Box } from '@mui/material'
1424
+ import { useLayoutEffect, useRef, useState } from 'react'
1425
+
1426
+ import { phx } from '../../.phoenix/phx.js'
1427
+ import { DimensionLine } from './DimensionLine.js'
1428
+ import { OpeningSymbol } from './OpeningSymbol.js'
1429
+ import { SHEET } from './sheet-style.js'
1430
+
1431
+ // Sash stiles and rails, and the bead that holds a fixed pane, in millimetres
1432
+ const SASH = 56
1433
+ const BEAD = 14
1434
+ const GLAZING_BAR = 26
1435
+ // Room around the window for the dimension tiers, in screen pixels
1436
+ const TIER = 34
1437
+ const MARGIN = { top: 16, right: 16 }
1438
+
1439
+ interface Size {
1440
+ readonly width: number
1441
+ readonly height: number
1442
+ }
1443
+
1444
+ // The element's size, measured again whenever it changes
1445
+ const useSize = () => {
1446
+ const ref = useRef<HTMLDivElement>(null)
1447
+ const [size, setSize] = useState<Size | null>(null)
1448
+
1449
+ useLayoutEffect(() => {
1450
+ const element = ref.current
1451
+
1452
+ if (element === null) {
1453
+ return
1454
+ }
1455
+
1456
+ const observer = new ResizeObserver(([entry]) => {
1457
+ if (entry) {
1458
+ setSize({ width: entry.contentRect.width, height: entry.contentRect.height })
1459
+ }
1460
+ })
1461
+ observer.observe(element)
1462
+
1463
+ return () => observer.disconnect()
1464
+ }, [])
1465
+
1466
+ return { ref, size }
1467
+ }
1468
+
1469
+ // A rectangle moved in by `by` on every side. A size the spec allows can leave no room, for example three panes across
1470
+ // a 500 mm window, so the size never drops below zero; what has no size is not drawn.
1471
+ const inset = (rect: { x: number; y: number; width: number; height: number }, by: number) => {
1472
+ return {
1473
+ x: rect.x + by,
1474
+ y: rect.y + by,
1475
+ width: Math.max(0, rect.width - 2 * by),
1476
+ height: Math.max(0, rect.height - 2 * by),
1477
+ }
1478
+ }
1479
+
1480
+ const hasSize = (rect: { width: number; height: number }) => {
1481
+ return rect.width > 0 && rect.height > 0
1482
+ }
1483
+
1484
+ // The elevation of the window as configured, drawn in millimetres from the spec's `$panes` expression. The drawing is
1485
+ // fitted to its box: lines, ticks and lettering are sized in screen pixels, so a small window and a large one read
1486
+ // the same, and its proportions follow the width and height.
1487
+ export const ShopDrawing = () => {
1488
+ const casement = phx.frontend.useRootEntityState()
1489
+ const { ref, size } = useSize()
1490
+ const { width: W, height: H, $panes: panes, $mullionWidth: mullion } = casement
1491
+ const mainPanes = panes.filter(pane => pane.key.startsWith('P'))
1492
+ const topLight = panes.find(pane => pane.key === 'T1')
1493
+ const transom = topLight ? topLight.y + topLight.height + mullion / 2 : null
1494
+ // A tall top light can leave the main panes no height. They are not drawn then, so neither is a height split
1495
+ // over them nor the chain of their widths
1496
+ const mainPanesDrawn = mainPanes.some(hasSize)
1497
+ const split = mainPanesDrawn ? transom : null
1498
+ const chained = mainPanesDrawn && mainPanes.length > 1
1499
+ const left = (split !== null ? 2 : 1) * TIER + 18
1500
+ const bottom = (chained ? 2 : 1) * TIER + 14
1501
+ const timber = SHEET.timber[casement.finish]
1502
+
1503
+ // Pixels per millimetre: the window and its margins fill the box in its tighter direction
1504
+ const scale =
1505
+ size === null
1506
+ ? 0
1507
+ : Math.max(0.01, Math.min((size.width - left - MARGIN.right) / W, (size.height - MARGIN.top - bottom) / H))
1508
+ const px = scale > 0 ? 1 / scale : 1
1509
+ const viewBox = [-left * px, -MARGIN.top * px, W + (left + MARGIN.right) * px, H + (MARGIN.top + bottom) * px]
1510
+ // The chain of pane widths along the bottom, measured to the centres of the mullions
1511
+ const chain = [0, ...mainPanes.slice(0, -1).map(pane => pane.x + pane.width + mullion / 2), W]
1512
+
1513
+ return (
1514
+ <Box component="div" ref={ref} sx={{ position: 'absolute', inset: 0 }}>
1515
+ {size !== null && scale > 0 && (
1516
+ <svg
1517
+ viewBox={viewBox.join(' ')}
1518
+ width={size.width}
1519
+ height={size.height}
1520
+ role="img"
1521
+ aria-label={`Elevation of a ${W} by ${H} mm window`}
1522
+ style={{ display: 'block' }}
1523
+ >
1524
+ {/* The frame; the openings drawn over it leave the frame, the mullions and the transom */}
1525
+ <rect x={0} y={0} width={W} height={H} fill={timber} stroke={SHEET.ink} strokeWidth={2 * px} />
1526
+
1527
+ {panes.map(pane => {
1528
+ const isSash = pane.opening !== 'Fixed'
1529
+ const opening = inset(pane, 0)
1530
+ const glass = inset(pane, isSash ? SASH : BEAD)
1531
+ const outline = pane.oversize ? SHEET.attention : SHEET.ink
1532
+
1533
+ // A top light can leave the main panes no height at all; the spec's warning says so
1534
+ if (!hasSize(opening)) {
1535
+ return null
1536
+ }
1537
+
1538
+ return (
1539
+ <g key={pane.key}>
1540
+ <rect {...opening} fill={isSash ? timber : SHEET.glass} stroke={SHEET.ink} strokeWidth={1.2 * px} />
1541
+ {hasSize(glass) && (
1542
+ <>
1543
+ <rect {...glass} fill={SHEET.glass} stroke={SHEET.glassStroke} strokeWidth={px} />
1544
+ {/* Two short strokes in a corner, the drafting sign for glass */}
1545
+ <polyline
1546
+ points={`${glass.x + glass.width * 0.1},${glass.y + glass.height * 0.28} ${glass.x + glass.width * 0.28},${glass.y + glass.height * 0.1}`}
1547
+ stroke={SHEET.glassStroke}
1548
+ strokeWidth={1.2 * px}
1549
+ fill="none"
1550
+ />
1551
+ <polyline
1552
+ points={`${glass.x + glass.width * 0.1},${glass.y + glass.height * 0.4} ${glass.x + glass.width * 0.4},${glass.y + glass.height * 0.1}`}
1553
+ stroke={SHEET.glassStroke}
1554
+ strokeWidth={1.2 * px}
1555
+ fill="none"
1556
+ />
1557
+ {casement.glazingBars && (
1558
+ <g fill={timber} stroke={SHEET.ink} strokeWidth={px}>
1559
+ <rect
1560
+ x={glass.x + glass.width / 2 - GLAZING_BAR / 2}
1561
+ y={glass.y}
1562
+ width={GLAZING_BAR}
1563
+ height={glass.height}
1564
+ />
1565
+ <rect
1566
+ x={glass.x}
1567
+ y={glass.y + glass.height / 2 - GLAZING_BAR / 2}
1568
+ width={glass.width}
1569
+ height={GLAZING_BAR}
1570
+ />
1571
+ </g>
1572
+ )}
1573
+ <OpeningSymbol
1574
+ opening={pane.opening}
1575
+ glass={glass}
1576
+ sash={opening}
1577
+ stile={SASH}
1578
+ px={px}
1579
+ color={outline}
1580
+ />
1581
+ </>
1582
+ )}
1583
+ {pane.oversize && (
1584
+ <>
1585
+ <rect
1586
+ {...inset(pane, -6 * px)}
1587
+ fill="none"
1588
+ stroke={SHEET.attention}
1589
+ strokeWidth={2 * px}
1590
+ strokeDasharray={`${6 * px} ${4 * px}`}
1591
+ />
1592
+ <text
1593
+ x={pane.x + pane.width / 2}
1594
+ y={pane.y + 30 * px}
1595
+ textAnchor="middle"
1596
+ fill={SHEET.attention}
1597
+ fontFamily={SHEET.font}
1598
+ fontSize={12 * px}
1599
+ fontWeight={700}
1600
+ letterSpacing={px}
1601
+ >
1602
+ SASH TOO WIDE
1603
+ </text>
1604
+ </>
1605
+ )}
1606
+ </g>
1607
+ )
1608
+ })}
1609
+
1610
+ {chained &&
1611
+ chain
1612
+ .slice(1)
1613
+ .map((end, index) => (
1614
+ <DimensionLine
1615
+ key={`chain-${end}`}
1616
+ from={chain[index] ?? 0}
1617
+ to={end}
1618
+ at={H + TIER * px}
1619
+ origin={H}
1620
+ axis="horizontal"
1621
+ px={px}
1622
+ />
1623
+ ))}
1624
+ <DimensionLine from={0} to={W} at={H + (chained ? 2 : 1) * TIER * px} origin={H} axis="horizontal" px={px} />
1625
+
1626
+ {split !== null && (
1627
+ <>
1628
+ <DimensionLine from={0} to={split} at={-TIER * px} origin={0} axis="vertical" px={px} />
1629
+ <DimensionLine from={split} to={H} at={-TIER * px} origin={0} axis="vertical" px={px} />
1630
+ </>
1631
+ )}
1632
+ <DimensionLine
1633
+ from={0}
1634
+ to={H}
1635
+ at={-(split !== null ? 2 : 1) * TIER * px}
1636
+ origin={0}
1637
+ axis="vertical"
1638
+ px={px}
1639
+ />
1640
+ </svg>
1641
+ )}
1642
+ </Box>
1643
+ )
1644
+ }
1645
+ ```
1646
+
1647
+ ### ./src/\_drawing/TitleBlock.tsx
1648
+
1649
+ ```tsx
1650
+ import { Box, Typography } from '@mui/material'
1651
+
1652
+ import { phx } from '../../.phoenix/phx.js'
1653
+ import { SHEET } from './sheet-style.js'
1654
+
1655
+ const FINISH_LABEL = { Oak: 'Natural oak, oiled', White: 'Pure white, RAL 9016', Anthracite: 'Anthracite, RAL 7016' }
1656
+ const LIGHTS_LABEL = { One: 'single', Two: 'two-light', Three: 'three-light' }
1657
+ const NOTES = [
1658
+ 'Dimensions in millimetres, overall frame size.',
1659
+ 'Opening symbols: the apex points to the hinges.',
1660
+ 'Do not scale; work to figured dimensions.',
1661
+ ]
1662
+
1663
+ const LABEL_SX = {
1664
+ fontFamily: SHEET.font,
1665
+ fontSize: 9,
1666
+ letterSpacing: '0.14em',
1667
+ textTransform: 'uppercase',
1668
+ color: SHEET.inkSoft,
1669
+ } as const
1670
+
1671
+ interface CellProps {
1672
+ readonly label: string
1673
+ readonly value: string
1674
+ readonly span?: number
1675
+ }
1676
+
1677
+ const Cell = ({ label, value, span = 1 }: CellProps) => {
1678
+ return (
1679
+ <Box
1680
+ component="div"
1681
+ sx={{
1682
+ display: 'grid',
1683
+ alignContent: 'start',
1684
+ gap: 0.25,
1685
+ gridColumn: `span ${span}`,
1686
+ paddingInline: 1,
1687
+ paddingBlock: 0.75,
1688
+ borderInlineStart: `1px solid ${SHEET.ink}`,
1689
+ borderBlockStart: `1px solid ${SHEET.ink}`,
1690
+ }}
1691
+ >
1692
+ <Typography sx={LABEL_SX}>{label}</Typography>
1693
+ <Typography
1694
+ sx={{
1695
+ fontFamily: SHEET.font,
1696
+ fontSize: 13,
1697
+ fontWeight: 500,
1698
+ color: SHEET.ink,
1699
+ fontVariantNumeric: 'tabular-nums',
1700
+ }}
1701
+ >
1702
+ {value}
1703
+ </Typography>
1704
+ </Box>
1705
+ )
1706
+ }
1707
+
1708
+ interface TitleBlockProps {
1709
+ readonly compact: boolean
1710
+ }
1711
+
1712
+ // The drawing's title block, filled from the configuration: the size, the glass build-up and the thermal and weight
1713
+ // figures that the spec's expressions compute. On a desktop it is a column along the sheet's right edge with the
1714
+ // notes at its foot; a compact block is one row of figures, for a phone.
1715
+ export const TitleBlock = ({ compact }: TitleBlockProps) => {
1716
+ const casement = phx.frontend.useRootEntityState()
1717
+ const configurationId = phx.frontend.useConfigurationId()
1718
+ const drawingNumber = `FL-${String(configurationId)
1719
+ .replace(/[^a-z0-9]/gi, '')
1720
+ .slice(-5)
1721
+ .toUpperCase()}`
1722
+ const figures = [
1723
+ { label: 'Size', value: `${casement.width} × ${casement.height} mm` },
1724
+ { label: 'Uw', value: `${casement.$uValue.toFixed(2)} W/m²K` },
1725
+ { label: 'Weight', value: `${Math.round(casement.$weightKg)} kg` },
1726
+ ]
1727
+
1728
+ if (compact) {
1729
+ return (
1730
+ <Box component="div" sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', bgcolor: SHEET.paper }}>
1731
+ {figures.map(figure => (
1732
+ <Cell key={figure.label} label={figure.label} value={figure.value} />
1733
+ ))}
1734
+ </Box>
1735
+ )
1736
+ }
1737
+
1738
+ return (
1739
+ <Box
1740
+ component="div"
1741
+ sx={{
1742
+ display: 'grid',
1743
+ gridTemplateColumns: '1fr 1fr',
1744
+ // The notes take the rest of the column, so its ruled edge runs down to the border
1745
+ gridTemplateRows: 'repeat(8, auto) 1fr',
1746
+ blockSize: '100%',
1747
+ bgcolor: SHEET.paper,
1748
+ }}
1749
+ >
1750
+ <Box
1751
+ component="div"
1752
+ sx={{
1753
+ gridColumn: 'span 2',
1754
+ display: 'grid',
1755
+ gap: 0.25,
1756
+ padding: 1.25,
1757
+ borderInlineStart: `1px solid ${SHEET.ink}`,
1758
+ }}
1759
+ >
1760
+ <Typography
1761
+ sx={{ fontFamily: SHEET.font, fontSize: 17, fontWeight: 700, letterSpacing: '0.2em', color: SHEET.ink }}
1762
+ >
1763
+ FENWICK &amp; LOWE
1764
+ </Typography>
1765
+ <Typography sx={{ ...LABEL_SX, fontSize: 10 }}>Joinery · Frome, Somerset</Typography>
1766
+ </Box>
1767
+ <Cell label="Title" value={`Casement window, ${LIGHTS_LABEL[casement.lights]}`} span={2} />
1768
+ <Cell label="Drawing" value={drawingNumber} />
1769
+ <Cell label="Revision" value="A" />
1770
+ {figures.map(figure => (
1771
+ <Cell key={figure.label} label={figure.label} value={figure.value} span={figure.label === 'Size' ? 2 : 1} />
1772
+ ))}
1773
+ <Cell label="Glazing" value={casement.$glassBuildUp} span={2} />
1774
+ <Cell label="Finish" value={FINISH_LABEL[casement.finish]} span={2} />
1775
+ <Cell label="Scale" value="Not to scale" span={2} />
1776
+ <Box
1777
+ component="ol"
1778
+ sx={{
1779
+ gridColumn: 'span 2',
1780
+ display: 'grid',
1781
+ alignContent: 'start',
1782
+ gap: 0.5,
1783
+ margin: 0,
1784
+ padding: 1.25,
1785
+ paddingInlineStart: 3.5,
1786
+ borderInlineStart: `1px solid ${SHEET.ink}`,
1787
+ borderBlockStart: `1px solid ${SHEET.ink}`,
1788
+ }}
1789
+ >
1790
+ {NOTES.map(note => (
1791
+ <Typography
1792
+ component="li"
1793
+ key={note}
1794
+ sx={{ fontFamily: SHEET.font, fontSize: 12, lineHeight: 1.35, color: SHEET.inkSoft }}
1795
+ >
1796
+ {note}
1797
+ </Typography>
1798
+ ))}
1799
+ </Box>
1800
+ </Box>
1801
+ )
1802
+ }
1803
+ ```
1804
+
1805
+ ### ./src/\_drawing/sheet-style.ts
1806
+
1807
+ ```ts
1808
+ // The drawing's conventions: blue-black ink on drafting paper, a red for what needs attention, and the tones that
1809
+ // stand for the frame's finish and for glass
1810
+ export const SHEET = {
1811
+ paper: '#fbfaf6',
1812
+ grid: 'rgba(47, 93, 138, 0.07)',
1813
+ ink: '#1b2733',
1814
+ inkSoft: '#5d6b78',
1815
+ dimension: '#2f5d8a',
1816
+ attention: '#c0392b',
1817
+ glass: '#e3eef6',
1818
+ glassStroke: '#9db7cc',
1819
+ timber: { Oak: '#ead9bd', White: '#ffffff', Anthracite: '#c9cdd1' },
1820
+ font: "'Barlow', 'Helvetica Neue', Arial, sans-serif",
1821
+ } as const
1822
+ ```
1823
+
1824
+ ### ./src/\_shared/DesktopPage.tsx
1825
+
1826
+ ```tsx
1827
+ import { Box, Typography } from '@mui/material'
1828
+
1829
+ import { LayoutSlots } from '@salsita-ai/sdk/configurator'
1830
+
1831
+ import { phx } from '../../.phoenix/phx.js'
1832
+ import { DrawingSheet } from '../_drawing/index.js'
1833
+ import { OrderPanel } from './OrderPanel.js'
1834
+ import { WindowInputs } from './WindowInputs.js'
1835
+ import { FENWICK } from './tokens.js'
1836
+
1837
+ const PANEL_WIDTH = 380
1838
+
1839
+ // The desktop page: the brand bar, the inputs in a column on the left with the price and the order button at its
1840
+ // foot, and the shop drawing filling the rest, where a 3D configurator would show its scene
1841
+ export const DesktopPage = () => {
1842
+ const { accessInfo } = phx.frontend.useAccessService()
1843
+ const navigationState = phx.frontend.useActiveNavigationState()
1844
+ // An ordered window keeps its drawing until the customer starts another
1845
+ const readOnly = accessInfo.viewMode || navigationState === 'ordered'
1846
+
1847
+ return (
1848
+ <Box
1849
+ component="div"
1850
+ sx={{
1851
+ position: 'absolute',
1852
+ inset: 0,
1853
+ display: 'grid',
1854
+ gridTemplateRows: 'auto minmax(0, 1fr)',
1855
+ gridTemplateColumns: `${PANEL_WIDTH}px minmax(0, 1fr)`,
1856
+ bgcolor: FENWICK.paper,
1857
+ fontFamily: FENWICK.font,
1858
+ }}
1859
+ >
1860
+ <Box
1861
+ component="header"
1862
+ sx={{
1863
+ gridColumn: '1 / -1',
1864
+ display: 'flex',
1865
+ alignItems: 'baseline',
1866
+ gap: 2,
1867
+ paddingInline: 3,
1868
+ paddingBlock: 1.75,
1869
+ bgcolor: FENWICK.green,
1870
+ color: FENWICK.panel,
1871
+ }}
1872
+ >
1873
+ <Typography sx={{ fontFamily: FENWICK.font, fontSize: 20, fontWeight: 700, letterSpacing: '0.22em' }}>
1874
+ FENWICK &amp; LOWE
1875
+ </Typography>
1876
+ <Typography component="h1" sx={{ fontFamily: FENWICK.font, fontSize: 16, color: '#c8d6cf' }}>
1877
+ Made-to-measure casement window
1878
+ </Typography>
1879
+ </Box>
1880
+
1881
+ <Box
1882
+ component="aside"
1883
+ aria-label="Window"
1884
+ sx={{
1885
+ display: 'flex',
1886
+ flexDirection: 'column',
1887
+ minBlockSize: 0,
1888
+ bgcolor: FENWICK.panel,
1889
+ borderInlineEnd: `1px solid ${FENWICK.line}`,
1890
+ }}
1891
+ >
1892
+ <Box component="div" sx={{ flex: 1, minBlockSize: 0, overflowY: 'auto', paddingInline: 3 }}>
1893
+ <WindowInputs readOnly={readOnly} />
1894
+ </Box>
1895
+ <Box component="div" sx={{ padding: 3, borderBlockStart: `1px solid ${FENWICK.line}` }}>
1896
+ <OrderPanel compact={false} />
1897
+ </Box>
1898
+ </Box>
1899
+
1900
+ <Box component="main" aria-label="Shop drawing" sx={{ position: 'relative', minInlineSize: 0, minBlockSize: 0 }}>
1901
+ <DrawingSheet compact={false} />
1902
+ <Box component="div" sx={{ position: 'absolute', insetBlockStart: 40, insetInlineEnd: 40, inlineSize: 320 }}>
1903
+ <LayoutSlots.DesktopSnackbarsRoot />
1904
+ </Box>
1905
+ </Box>
1906
+ </Box>
1907
+ )
1908
+ }
1909
+ ```
1910
+
1911
+ ### ./src/\_shared/MillimetreField.tsx
1912
+
1913
+ ```tsx
1914
+ import { Box, IconButton, InputBase, Slider, Typography } from '@mui/material'
1915
+ import { useEffect, useState } from 'react'
1916
+
1917
+ import { genericDimensionComponent } from '@salsita-ai/sdk/domain'
1918
+
1919
+ import { phx } from '../../.phoenix/phx.js'
1920
+ import { FENWICK } from './tokens.js'
1921
+
1922
+ const STEP = 10
1923
+
1924
+ interface MillimetreFieldProps {
1925
+ readonly instanceName: string
1926
+ readonly label: string
1927
+ readonly readOnly: boolean
1928
+ }
1929
+
1930
+ const MillimetreInput = ({ instanceName, label, readOnly }: MillimetreFieldProps) => {
1931
+ const windowId = phx.frontend.useRootEntityId()
1932
+ const dimension = phx.frontend.useGenericDimension(windowId, instanceName)
1933
+ const [draft, setDraft] = useState(String(dimension.value))
1934
+ const min = dimension.min ?? 0
1935
+ const max = dimension.max ?? 3000
1936
+
1937
+ useEffect(() => {
1938
+ setDraft(String(dimension.value))
1939
+ }, [dimension.value])
1940
+
1941
+ // Kept inside the component's range, so a typed value can never leave it
1942
+ const commit = (value: number) => {
1943
+ dimension.update(Math.min(max, Math.max(min, Math.round(value))))
1944
+ dimension.resolve()
1945
+ }
1946
+
1947
+ const commitDraft = () => {
1948
+ const parsed = Number(draft)
1949
+
1950
+ // `Number('')` is 0, so a cleared field would become the minimum; it gets the stored value back instead
1951
+ if (draft.trim() !== '' && Number.isFinite(parsed)) {
1952
+ commit(parsed)
1953
+ } else {
1954
+ setDraft(String(dimension.value))
1955
+ }
1956
+ }
1957
+
1958
+ return (
1959
+ <Box component="div" sx={{ display: 'grid', gap: 0.5 }}>
1960
+ <Box component="div" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
1961
+ <Typography
1962
+ component="label"
1963
+ htmlFor={`fenwick-${instanceName}`}
1964
+ sx={{ flex: 1, fontFamily: FENWICK.font, fontSize: 15, fontWeight: 500, color: FENWICK.ink }}
1965
+ >
1966
+ {label}
1967
+ </Typography>
1968
+ <IconButton
1969
+ aria-label={`${label}, ${STEP} mm less`}
1970
+ disabled={readOnly || dimension.value <= min}
1971
+ onClick={() => commit(dimension.value - STEP)}
1972
+ sx={{ inlineSize: 32, blockSize: 32, border: `1px solid ${FENWICK.line}`, color: FENWICK.ink, fontSize: 18 }}
1973
+ >
1974
+ −
1975
+ </IconButton>
1976
+ <Box
1977
+ component="div"
1978
+ sx={{
1979
+ display: 'flex',
1980
+ alignItems: 'baseline',
1981
+ gap: 0.5,
1982
+ paddingInline: 1,
1983
+ paddingBlock: 0.25,
1984
+ borderRadius: 1,
1985
+ border: `1px solid ${FENWICK.line}`,
1986
+ bgcolor: FENWICK.panel,
1987
+ '&:focus-within': { borderColor: FENWICK.green },
1988
+ }}
1989
+ >
1990
+ <InputBase
1991
+ id={`fenwick-${instanceName}`}
1992
+ value={draft}
1993
+ disabled={readOnly}
1994
+ onChange={event => setDraft(event.target.value)}
1995
+ onBlur={commitDraft}
1996
+ onKeyDown={event => {
1997
+ if (event.key === 'Enter') {
1998
+ commitDraft()
1999
+ }
2000
+ }}
2001
+ inputProps={{ inputMode: 'numeric' }}
2002
+ sx={{
2003
+ inlineSize: 52,
2004
+ fontFamily: FENWICK.font,
2005
+ fontSize: 17,
2006
+ fontWeight: 600,
2007
+ color: FENWICK.ink,
2008
+ '& input': { padding: 0, textAlign: 'end', fontVariantNumeric: 'tabular-nums' },
2009
+ }}
2010
+ />
2011
+ <Typography component="span" sx={{ fontFamily: FENWICK.font, fontSize: 13, color: FENWICK.muted }}>
2012
+ mm
2013
+ </Typography>
2014
+ </Box>
2015
+ <IconButton
2016
+ aria-label={`${label}, ${STEP} mm more`}
2017
+ disabled={readOnly || dimension.value >= max}
2018
+ onClick={() => commit(dimension.value + STEP)}
2019
+ sx={{ inlineSize: 32, blockSize: 32, border: `1px solid ${FENWICK.line}`, color: FENWICK.ink, fontSize: 18 }}
2020
+ >
2021
+ +
2022
+ </IconButton>
2023
+ </Box>
2024
+ <Slider
2025
+ value={dimension.value}
2026
+ min={min}
2027
+ max={max}
2028
+ step={STEP}
2029
+ disabled={readOnly}
2030
+ onChange={(_, value) => dimension.update(Array.isArray(value) ? (value[0] ?? min) : value)}
2031
+ onChangeCommitted={() => dimension.resolve()}
2032
+ slotProps={{ input: { 'aria-label': `${label} slider` } }}
2033
+ sx={{
2034
+ color: FENWICK.green,
2035
+ paddingBlock: 1.5,
2036
+ '& .MuiSlider-rail': { bgcolor: FENWICK.line, opacity: 1 },
2037
+ '& .MuiSlider-thumb': { inlineSize: 16, blockSize: 16 },
2038
+ }}
2039
+ />
2040
+ </Box>
2041
+ )
2042
+ }
2043
+
2044
+ // One `genericDimension` of the spec in millimetres: a typed value with steps of 10 mm and a slider. Dragging stages
2045
+ // each value, so the drawing follows the thumb, and letting go commits it. The field exists only while the spec's
2046
+ // component is active.
2047
+ export const MillimetreField = (props: MillimetreFieldProps) => {
2048
+ const windowId = phx.frontend.useRootEntityId()
2049
+ const component = phx.frontend.useComponent({
2050
+ entityId: windowId,
2051
+ componentType: genericDimensionComponent,
2052
+ instanceName: props.instanceName,
2053
+ })
2054
+
2055
+ return component === null ? null : <MillimetreInput {...props} />
2056
+ }
2057
+ ```
2058
+
2059
+ ### ./src/\_shared/OrderPanel.tsx
2060
+
2061
+ ```tsx
2062
+ import { Alert, Box, Button, CircularProgress, Typography } from '@mui/material'
2063
+
2064
+ import { phx } from '../../.phoenix/phx.js'
2065
+ import { FENWICK } from './tokens.js'
2066
+
2067
+ interface OrderPanelProps {
2068
+ // On a phone the panel is a bar: the price beside the button, without the figures
2069
+ readonly compact: boolean
2070
+ }
2071
+
2072
+ // The price and the spec's `order` finalize action. While a warning holds, the action is `disabled` and its `hints`
2073
+ // say why. In the `ordered` navigation state the panel confirms the order instead; a viewer sees the price only.
2074
+ export const OrderPanel = ({ compact }: OrderPanelProps) => {
2075
+ const casement = phx.frontend.useRootEntityState()
2076
+ const price = phx.frontend.useRootEntityPrice()
2077
+ const { primary, hints } = phx.frontend.useFinalizeActions()
2078
+ const navigationState = phx.frontend.useActiveNavigationState()
2079
+ const navigate = phx.frontend.useNavigate()
2080
+ const { accessInfo } = phx.frontend.useAccessService()
2081
+ const isPending = primary?.isPending ?? false
2082
+
2083
+ if (navigationState === 'ordered') {
2084
+ return (
2085
+ <Box component="div" role="status" sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
2086
+ <Box component="div" sx={{ display: 'grid', flex: 1 }}>
2087
+ <Typography sx={{ fontFamily: FENWICK.font, fontSize: 18, fontWeight: 600, color: FENWICK.green }}>
2088
+ Added to your order
2089
+ </Typography>
2090
+ <Typography sx={{ fontFamily: FENWICK.font, fontSize: 14, color: FENWICK.muted }}>
2091
+ {casement.width} × {casement.height} mm, {price}
2092
+ </Typography>
2093
+ </Box>
2094
+ <Button
2095
+ onClick={() => navigate('design')}
2096
+ sx={{
2097
+ fontFamily: FENWICK.font,
2098
+ fontSize: 15,
2099
+ color: FENWICK.ink,
2100
+ textTransform: 'none',
2101
+ textDecoration: 'underline',
2102
+ }}
2103
+ >
2104
+ Draw another
2105
+ </Button>
2106
+ </Box>
2107
+ )
2108
+ }
2109
+
2110
+ const showButton = primary !== null && !accessInfo.viewMode
2111
+
2112
+ return (
2113
+ <Box component="div" sx={{ display: 'grid', gap: 1.25 }}>
2114
+ <Box component="div" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
2115
+ <Box component="div" sx={{ display: 'grid' }}>
2116
+ <Typography
2117
+ sx={{ fontFamily: FENWICK.font, fontSize: compact ? 22 : 28, fontWeight: 700, color: FENWICK.ink }}
2118
+ >
2119
+ {price}
2120
+ </Typography>
2121
+ <Typography sx={{ fontFamily: FENWICK.font, fontSize: 13, color: FENWICK.muted, whiteSpace: 'nowrap' }}>
2122
+ {compact
2123
+ ? 'incl. VAT'
2124
+ : `Uw ${casement.$uValue.toFixed(2)} W/m²K · ${Math.round(casement.$weightKg)} kg · incl. VAT`}
2125
+ </Typography>
2126
+ </Box>
2127
+ {showButton && (
2128
+ <Button
2129
+ variant="contained"
2130
+ disableElevation
2131
+ disabled={primary.disabled || isPending}
2132
+ onClick={() => void primary.run()}
2133
+ title={primary.title}
2134
+ startIcon={isPending ? <CircularProgress size={16} color="inherit" /> : undefined}
2135
+ sx={{
2136
+ minBlockSize: 48,
2137
+ paddingInline: 3,
2138
+ borderRadius: 1.5,
2139
+ bgcolor: FENWICK.green,
2140
+ fontFamily: FENWICK.font,
2141
+ fontSize: 16,
2142
+ fontWeight: 600,
2143
+ textTransform: 'none',
2144
+ '&:hover': { bgcolor: '#15302a' },
2145
+ '&.Mui-disabled': { bgcolor: FENWICK.line, color: FENWICK.muted },
2146
+ }}
2147
+ >
2148
+ {isPending ? 'Adding…' : primary.label}
2149
+ </Button>
2150
+ )}
2151
+ </Box>
2152
+ {!compact && showButton && primary.disabled && hints && (
2153
+ <Typography sx={{ fontFamily: FENWICK.font, fontSize: 13, color: FENWICK.attention }}>
2154
+ Resolve the drawing's warning to order: {hints}
2155
+ </Typography>
2156
+ )}
2157
+ {primary?.error && <Alert severity="error">The window could not be added: {primary.error.message}</Alert>}
2158
+ </Box>
2159
+ )
2160
+ }
2161
+ ```
2162
+
2163
+ ### ./src/\_shared/PhonePage.tsx
2164
+
2165
+ ```tsx
2166
+ import { Box, Typography } from '@mui/material'
2167
+
2168
+ import { LayoutSlots } from '@salsita-ai/sdk/configurator'
2169
+
2170
+ import { phx } from '../../.phoenix/phx.js'
2171
+ import { DrawingSheet } from '../_drawing/index.js'
2172
+ import { OrderPanel } from './OrderPanel.js'
2173
+ import { WindowInputs } from './WindowInputs.js'
2174
+ import { FENWICK } from './tokens.js'
2175
+
2176
+ // The phone page: the drawing stays at the top while the inputs scroll below it, and the price with the order button
2177
+ // is a bar at the bottom
2178
+ export const PhonePage = () => {
2179
+ const { accessInfo } = phx.frontend.useAccessService()
2180
+ const navigationState = phx.frontend.useActiveNavigationState()
2181
+ const readOnly = accessInfo.viewMode || navigationState === 'ordered'
2182
+
2183
+ return (
2184
+ <Box
2185
+ component="div"
2186
+ sx={{
2187
+ position: 'absolute',
2188
+ inset: 0,
2189
+ display: 'grid',
2190
+ gridTemplateRows: 'auto minmax(260px, 46%) minmax(0, 1fr) auto',
2191
+ bgcolor: FENWICK.panel,
2192
+ fontFamily: FENWICK.font,
2193
+ }}
2194
+ >
2195
+ <Box
2196
+ component="header"
2197
+ sx={{
2198
+ display: 'flex',
2199
+ alignItems: 'baseline',
2200
+ gap: 1.5,
2201
+ paddingInline: 2,
2202
+ paddingBlock: 1.5,
2203
+ bgcolor: FENWICK.green,
2204
+ color: FENWICK.panel,
2205
+ }}
2206
+ >
2207
+ <Typography sx={{ fontFamily: FENWICK.font, fontSize: 16, fontWeight: 700, letterSpacing: '0.2em' }}>
2208
+ FENWICK &amp; LOWE
2209
+ </Typography>
2210
+ <Typography component="h1" sx={{ fontFamily: FENWICK.font, fontSize: 14, color: '#c8d6cf' }}>
2211
+ Casement window
2212
+ </Typography>
2213
+ </Box>
2214
+
2215
+ <Box
2216
+ component="div"
2217
+ aria-label="Shop drawing"
2218
+ sx={{ position: 'relative', borderBlockEnd: `1px solid ${FENWICK.line}` }}
2219
+ >
2220
+ <DrawingSheet compact />
2221
+ </Box>
2222
+
2223
+ <Box component="main" sx={{ minBlockSize: 0, overflowY: 'auto', paddingInline: 2 }}>
2224
+ <WindowInputs readOnly={readOnly} />
2225
+ </Box>
2226
+
2227
+ <Box
2228
+ component="footer"
2229
+ sx={{
2230
+ padding: 2,
2231
+ bgcolor: FENWICK.panel,
2232
+ borderBlockStart: `1px solid ${FENWICK.line}`,
2233
+ boxShadow: '0 -8px 24px rgba(27, 39, 51, 0.08)',
2234
+ }}
2235
+ >
2236
+ <OrderPanel compact />
2237
+ </Box>
2238
+
2239
+ <LayoutSlots.MobileSnackbarsRoot />
2240
+ </Box>
2241
+ )
2242
+ }
2243
+ ```
2244
+
2245
+ ### ./src/\_shared/SegmentedField.tsx
2246
+
2247
+ ```tsx
2248
+ import { Box, ButtonBase, Typography } from '@mui/material'
2249
+ import type { KeyboardEvent } from 'react'
2250
+
2251
+ import { attributeSelectionComponent } from '@salsita-ai/sdk/domain'
2252
+
2253
+ import { phx } from '../../.phoenix/phx.js'
2254
+ import { FENWICK } from './tokens.js'
2255
+
2256
+ // Arrow keys move the selection between the segments, as in a native radio group
2257
+ const ARROW_STEPS: Readonly<Record<string, number | undefined>> = {
2258
+ ArrowRight: 1,
2259
+ ArrowDown: 1,
2260
+ ArrowLeft: -1,
2261
+ ArrowUp: -1,
2262
+ }
2263
+
2264
+ interface SegmentedFieldProps {
2265
+ readonly instanceName: string
2266
+ readonly label: string
2267
+ readonly readOnly: boolean
2268
+ // A colour chip before each option's title, keyed by the spec's option values
2269
+ readonly chips?: Readonly<Record<string, string>>
2270
+ readonly columns?: number
2271
+ }
2272
+
2273
+ const Segments = ({ instanceName, label, readOnly, chips, columns }: SegmentedFieldProps) => {
2274
+ const resolve = phx.frontend.useResolveLocalizable()
2275
+ const windowId = phx.frontend.useRootEntityId()
2276
+ const { value, options } = phx.frontend.useAttributeSelection(windowId, instanceName)
2277
+ const selected = options.find(option => option.value === value)
2278
+ const labelId = `fenwick-${instanceName}-label`
2279
+
2280
+ const handleKeyDown = (event: KeyboardEvent<HTMLElement>, index: number) => {
2281
+ const step = ARROW_STEPS[event.key]
2282
+
2283
+ if (step === undefined) {
2284
+ return
2285
+ }
2286
+
2287
+ event.preventDefault()
2288
+ const next = (index + step + options.length) % options.length
2289
+ options[next]?.onSelect()
2290
+ event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="radio"]')[next]?.focus()
2291
+ }
2292
+
2293
+ return (
2294
+ <Box component="div" sx={{ display: 'grid', gap: 0.75 }}>
2295
+ <Typography id={labelId} sx={{ fontFamily: FENWICK.font, fontSize: 15, fontWeight: 500, color: FENWICK.ink }}>
2296
+ {label}
2297
+ </Typography>
2298
+ <Box
2299
+ component="div"
2300
+ role="radiogroup"
2301
+ aria-labelledby={labelId}
2302
+ sx={{
2303
+ display: 'grid',
2304
+ gridTemplateColumns: `repeat(${columns ?? options.length}, minmax(0, 1fr))`,
2305
+ gap: '1px',
2306
+ padding: '1px',
2307
+ borderRadius: 1.5,
2308
+ bgcolor: FENWICK.line,
2309
+ overflow: 'hidden',
2310
+ }}
2311
+ >
2312
+ {options.map((option, index) => {
2313
+ const isSelected = option.value === value
2314
+ const chip = chips?.[String(option.value)]
2315
+
2316
+ return (
2317
+ <ButtonBase
2318
+ key={String(option.value)}
2319
+ role="radio"
2320
+ aria-checked={isSelected}
2321
+ tabIndex={isSelected ? 0 : -1}
2322
+ disabled={readOnly && !isSelected}
2323
+ onClick={readOnly ? undefined : option.onSelect}
2324
+ onKeyDown={readOnly ? undefined : event => handleKeyDown(event, index)}
2325
+ sx={{
2326
+ display: 'flex',
2327
+ alignItems: 'center',
2328
+ justifyContent: 'center',
2329
+ gap: 0.75,
2330
+ minBlockSize: 40,
2331
+ paddingInline: 1,
2332
+ fontFamily: FENWICK.font,
2333
+ fontSize: 14,
2334
+ fontWeight: isSelected ? 600 : 500,
2335
+ color: isSelected ? FENWICK.panel : FENWICK.ink,
2336
+ bgcolor: isSelected ? FENWICK.green : FENWICK.panel,
2337
+ transition: 'background-color 120ms',
2338
+ '&:hover': { bgcolor: isSelected ? FENWICK.green : FENWICK.greenSoft },
2339
+ '&.Mui-focusVisible': { outline: `2px solid ${FENWICK.brass}`, outlineOffset: -3 },
2340
+ }}
2341
+ >
2342
+ {chip && (
2343
+ <Box
2344
+ component="span"
2345
+ aria-hidden
2346
+ sx={{
2347
+ inlineSize: 14,
2348
+ blockSize: 14,
2349
+ borderRadius: '50%',
2350
+ bgcolor: chip,
2351
+ border: `1px solid ${FENWICK.line}`,
2352
+ }}
2353
+ />
2354
+ )}
2355
+ {resolve(option.title)}
2356
+ </ButtonBase>
2357
+ )
2358
+ })}
2359
+ </Box>
2360
+ {selected?.description && (
2361
+ <Typography sx={{ fontFamily: FENWICK.font, fontSize: 13, color: FENWICK.muted }}>
2362
+ {resolve(selected.description)}
2363
+ </Typography>
2364
+ )}
2365
+ </Box>
2366
+ )
2367
+ }
2368
+
2369
+ // One `attributeSelection` of the spec as a segmented control, with the chosen option's description below it. The
2370
+ // field exists only while the spec's component is active, so a `$filter` in the spec decides when it is asked.
2371
+ export const SegmentedField = (props: SegmentedFieldProps) => {
2372
+ const windowId = phx.frontend.useRootEntityId()
2373
+ const component = phx.frontend.useComponent({
2374
+ entityId: windowId,
2375
+ componentType: attributeSelectionComponent,
2376
+ instanceName: props.instanceName,
2377
+ })
2378
+
2379
+ return component === null ? null : <Segments {...props} />
2380
+ }
2381
+ ```
2382
+
2383
+ ### ./src/\_shared/ToggleRow.tsx
2384
+
2385
+ ```tsx
2386
+ import { Box, Switch, Typography } from '@mui/material'
2387
+
2388
+ import { phx } from '../../.phoenix/phx.js'
2389
+ import { FENWICK } from './tokens.js'
2390
+
2391
+ interface ToggleRowProps {
2392
+ readonly instanceName: string
2393
+ readonly label: string
2394
+ readonly description: string
2395
+ readonly readOnly: boolean
2396
+ }
2397
+
2398
+ // One `attributeSwitch` of the spec as a labelled switch
2399
+ export const ToggleRow = ({ instanceName, label, description, readOnly }: ToggleRowProps) => {
2400
+ const windowId = phx.frontend.useRootEntityId()
2401
+ const toggle = phx.frontend.useAttributeSwitch(windowId, instanceName)
2402
+ const labelId = `fenwick-${instanceName}-label`
2403
+
2404
+ return (
2405
+ <Box component="div" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
2406
+ <Box component="div" sx={{ display: 'grid' }}>
2407
+ <Typography id={labelId} sx={{ fontFamily: FENWICK.font, fontSize: 15, fontWeight: 500, color: FENWICK.ink }}>
2408
+ {label}
2409
+ </Typography>
2410
+ <Typography sx={{ fontFamily: FENWICK.font, fontSize: 13, color: FENWICK.muted }}>{description}</Typography>
2411
+ </Box>
2412
+ <Switch
2413
+ checked={toggle.state.value}
2414
+ disabled={readOnly || toggle.disabled}
2415
+ onChange={toggle.onChange}
2416
+ inputProps={{ 'aria-labelledby': labelId }}
2417
+ sx={{
2418
+ '& .MuiSwitch-switchBase.Mui-checked': { color: FENWICK.panel },
2419
+ '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: FENWICK.green, opacity: 1 },
2420
+ }}
2421
+ />
2422
+ </Box>
2423
+ )
2424
+ }
2425
+ ```
2426
+
2427
+ ### ./src/\_shared/WindowInputs.tsx
2428
+
2429
+ ```tsx
2430
+ import { Box, Typography } from '@mui/material'
2431
+ import type { ReactNode } from 'react'
2432
+
2433
+ import { phx } from '../../.phoenix/phx.js'
2434
+ import { MillimetreField } from './MillimetreField.js'
2435
+ import { SegmentedField } from './SegmentedField.js'
2436
+ import { ToggleRow } from './ToggleRow.js'
2437
+ import { FENWICK, SECTION_SX } from './tokens.js'
2438
+
2439
+ const FINISH_CHIPS = { Oak: '#c9a46d', White: '#ffffff', Anthracite: '#3b4045' }
2440
+
2441
+ interface SectionProps {
2442
+ readonly title: string
2443
+ // The invariant types whose warning this section can resolve
2444
+ readonly warningTypes?: readonly string[]
2445
+ readonly children: ReactNode
2446
+ }
2447
+
2448
+ // A group of inputs, with the warning of an invariant the group can resolve below it. The warnings come from
2449
+ // `useFinalizeViolations`, which lists the committed configuration's violations.
2450
+ const Section = ({ title, warningTypes = [], children }: SectionProps) => {
2451
+ const { violations } = phx.frontend.useFinalizeViolations()
2452
+ const resolve = phx.frontend.useResolveLocalizable()
2453
+ const warning = violations?.find(({ violation }) => warningTypes.includes(violation.type))
2454
+
2455
+ return (
2456
+ <Box
2457
+ component="section"
2458
+ sx={{ display: 'grid', gap: 2, paddingBlock: 2.5, borderBlockEnd: `1px solid ${FENWICK.line}` }}
2459
+ >
2460
+ <Typography component="h3" sx={SECTION_SX}>
2461
+ {title}
2462
+ </Typography>
2463
+ {children}
2464
+ {warning && (
2465
+ <Typography
2466
+ role="alert"
2467
+ sx={{
2468
+ paddingInline: 1.5,
2469
+ paddingBlock: 1,
2470
+ borderRadius: 1,
2471
+ borderInlineStart: `3px solid ${FENWICK.attention}`,
2472
+ bgcolor: FENWICK.attentionTint,
2473
+ color: FENWICK.attention,
2474
+ fontFamily: FENWICK.font,
2475
+ fontSize: 14,
2476
+ lineHeight: 1.4,
2477
+ }}
2478
+ >
2479
+ {resolve(warning.violation.message)}
2480
+ </Typography>
2481
+ )}
2482
+ </Box>
2483
+ )
2484
+ }
2485
+
2486
+ interface WindowInputsProps {
2487
+ readonly readOnly: boolean
2488
+ }
2489
+
2490
+ // Every input of the window, grouped as a joiner asks for them. The hinge side and the top light's height are asked
2491
+ // only when the spec's `$filter` makes their component active.
2492
+ export const WindowInputs = ({ readOnly }: WindowInputsProps) => {
2493
+ return (
2494
+ <Box component="div" sx={{ display: 'grid' }}>
2495
+ <Section title="Overall size" warningTypes={['sashTooWide', 'sashTooNarrow']}>
2496
+ <MillimetreField instanceName="width" label="Width" readOnly={readOnly} />
2497
+ <MillimetreField instanceName="height" label="Height" readOnly={readOnly} />
2498
+ </Section>
2499
+ <Section title="Panes" warningTypes={['topLightTooTall']}>
2500
+ <SegmentedField instanceName="lights" label="Panes across" readOnly={readOnly} />
2501
+ <ToggleRow
2502
+ instanceName="topLight"
2503
+ label="Top light"
2504
+ description="A fixed pane above a transom"
2505
+ readOnly={readOnly}
2506
+ />
2507
+ <MillimetreField instanceName="topLightHeight" label="Top light height" readOnly={readOnly} />
2508
+ </Section>
2509
+ <Section title="Opening">
2510
+ <SegmentedField instanceName="opening" label="Sashes" readOnly={readOnly} columns={2} />
2511
+ <SegmentedField instanceName="hinge" label="Hinged on the" readOnly={readOnly} />
2512
+ </Section>
2513
+ <Section title="Glass and finish">
2514
+ <SegmentedField instanceName="glazing" label="Glazing" readOnly={readOnly} />
2515
+ <ToggleRow
2516
+ instanceName="glazingBars"
2517
+ label="Glazing bars"
2518
+ description="A cross of bars in every pane"
2519
+ readOnly={readOnly}
2520
+ />
2521
+ <SegmentedField instanceName="finish" label="Finish" readOnly={readOnly} chips={FINISH_CHIPS} columns={3} />
2522
+ </Section>
2523
+ </Box>
2524
+ )
2525
+ }
2526
+ ```
2527
+
2528
+ ### ./src/\_shared/tokens.ts
2529
+
2530
+ ```ts
2531
+ // Fenwick & Lowe's palette and type: a workshop green, brass and warm paper. Barlow is the brand's typeface; the
2532
+ // stack falls back to system fonts where it is not loaded.
2533
+ export const FENWICK = {
2534
+ paper: '#f3f0e8',
2535
+ panel: '#fffdf8',
2536
+ ink: '#1b2733',
2537
+ muted: '#5d6b78',
2538
+ line: '#ddd6c8',
2539
+ green: '#1f3d33',
2540
+ greenSoft: '#e4ece7',
2541
+ brass: '#a8844b',
2542
+ attention: '#b23a2b',
2543
+ attentionTint: '#fbecea',
2544
+ font: "'Barlow', 'Helvetica Neue', Arial, sans-serif",
2545
+ } as const
2546
+
2547
+ // Small capitals with wide tracking, for section labels
2548
+ export const SECTION_SX = {
2549
+ fontFamily: FENWICK.font,
2550
+ fontSize: 12,
2551
+ fontWeight: 600,
2552
+ letterSpacing: '0.16em',
2553
+ textTransform: 'uppercase',
2554
+ color: FENWICK.muted,
2555
+ } as const
2556
+ ```
2557
+
2558
+ ### ./src/elements/ConfiguratorLayout.tsx
2559
+
2560
+ ```tsx
2561
+ import { Box } from '@mui/material'
2562
+
2563
+ import { LayoutSlots } from '@salsita-ai/sdk/configurator'
2564
+
2565
+ import { phx } from '../../.phoenix/phx.js'
2566
+ import { DesktopPage } from '../_shared/DesktopPage.js'
2567
+ import { PhonePage } from '../_shared/PhonePage.js'
2568
+ import { FENWICK } from '../_shared/tokens.js'
2569
+
2570
+ // A window configurator whose main view is a shop drawing instead of a scene. The layout never mounts
2571
+ // `LayoutSlots.SharedCanvas`, so no 3D scene loads; the drawing is SVG that React renders from the configuration.
2572
+ const ConfiguratorLayout = () => {
2573
+ // `null` until the container has been measured
2574
+ const viewports = phx.frontend.useActiveViewports()
2575
+ const isDesktop = viewports?.includes('desktop') ?? false
2576
+
2577
+ return (
2578
+ <Box component="div" sx={{ position: 'absolute', inset: 0, overflow: 'clip', bgcolor: FENWICK.paper }}>
2579
+ <LayoutSlots.Services />
2580
+ {viewports !== null && (isDesktop ? <DesktopPage /> : <PhonePage />)}
2581
+ </Box>
2582
+ )
2583
+ }
2584
+
2585
+ export default phx.frontend.createCustomPhoenixLayoutElement('ConfiguratorLayout', ConfiguratorLayout)
2586
+ ```
2587
+
2588
+ ### ./src/expressions/window.ts
2589
+
2590
+ ```ts
2591
+ import { phx } from '../../.phoenix/phx.js'
2592
+
2593
+ // Face widths of the timber sections in millimetres, as the drawing shows them
2594
+ const FRAME = 68
2595
+ const MULLION = 76
2596
+
2597
+ const LIGHT_COUNT = { One: 1, Two: 2, Three: 3 }
2598
+
2599
+ // The widest sash each opening type can carry, in millimetres
2600
+ const MAX_SASH_WIDTH = { Fixed: Infinity, SideHung: 900, TiltTurn: 1300, TopHung: 1600 }
2601
+
2602
+ const GLASS = {
2603
+ Double: { buildUp: '4 / 16 Ar / 4 low-E', ug: 1.1, kgPerM2: 20, gbpPerM2: 95 },
2604
+ Triple: { buildUp: '4 / 12 Ar / 4 / 12 Ar / 4, two low-E', ug: 0.6, kgPerM2: 30, gbpPerM2: 150 },
2605
+ Acoustic: { buildUp: '6.8 lam / 16 Ar / 4 low-E', ug: 1.0, kgPerM2: 27, gbpPerM2: 170 },
2606
+ }
2607
+
2608
+ const SASH_PRICE = { Fixed: 0, SideHung: 260, TiltTurn: 340, TopHung: 220 }
2609
+ const FINISH_FACTOR = { Oak: 1.18, White: 1, Anthracite: 1.06 }
2610
+ // Timber frame and spacer: U-value of the frame and linear loss along the glass edge
2611
+ const FRAME_U = 1.3
2612
+ const EDGE_PSI = 0.04
2613
+
2614
+ type PaneOpening = 'Fixed' | 'SideLeft' | 'SideRight' | 'TiltLeft' | 'TiltRight' | 'Top'
2615
+
2616
+ interface WindowChoices {
2617
+ readonly lights: keyof typeof LIGHT_COUNT
2618
+ readonly opening: keyof typeof MAX_SASH_WIDTH
2619
+ readonly hinge: 'Left' | 'Right'
2620
+ }
2621
+
2622
+ // How the pane at `index` of `count` opens: a single sash follows the hinge choice, a pair is hung from the outer
2623
+ // edges like French casements, and of three the outer two open and the middle one is fixed
2624
+ const paneOpening = (s: WindowChoices, index: number, count: number): PaneOpening => {
2625
+ if (s.opening === 'Fixed') {
2626
+ return 'Fixed'
2627
+ }
2628
+
2629
+ if (s.opening === 'TopHung') {
2630
+ return 'Top'
2631
+ }
2632
+
2633
+ const prefix = s.opening === 'SideHung' ? 'Side' : 'Tilt'
2634
+
2635
+ if (count === 1) {
2636
+ return s.hinge === 'Left' ? `${prefix}Left` : `${prefix}Right`
2637
+ }
2638
+
2639
+ if (index === 0) {
2640
+ return `${prefix}Left`
2641
+ }
2642
+
2643
+ if (index === count - 1) {
2644
+ return `${prefix}Right`
2645
+ }
2646
+
2647
+ return 'Fixed'
2648
+ }
2649
+
2650
+ export default phx.domain.implementCustomExpressions({
2651
+ Window: {
2652
+ $frameWidth: () => {
2653
+ return FRAME
2654
+ },
2655
+ $mullionWidth: () => {
2656
+ return MULLION
2657
+ },
2658
+ // The clear openings inside the frame, from the top left corner of the frame, viewed from outside
2659
+ $panes: s => {
2660
+ const count = LIGHT_COUNT[s.lights]
2661
+ const innerWidth = s.width - 2 * FRAME
2662
+ const topLightZone = s.topLight ? s.topLightHeight + MULLION : 0
2663
+ const mainHeight = s.height - 2 * FRAME - topLightZone
2664
+ const paneWidth = (innerWidth - (count - 1) * MULLION) / count
2665
+ const panes = Array.from({ length: count }, (_, index) => {
2666
+ const opening = paneOpening(s, index, count)
2667
+
2668
+ return {
2669
+ key: `P${index + 1}`,
2670
+ x: FRAME + index * (paneWidth + MULLION),
2671
+ y: FRAME + topLightZone,
2672
+ width: paneWidth,
2673
+ height: mainHeight,
2674
+ opening,
2675
+ oversize: opening !== 'Fixed' && paneWidth > MAX_SASH_WIDTH[s.opening],
2676
+ }
2677
+ })
2678
+
2679
+ if (!s.topLight) {
2680
+ return panes
2681
+ }
2682
+
2683
+ // The top light is one fixed pane across the whole width, above the transom
2684
+ return [
2685
+ {
2686
+ key: 'T1',
2687
+ x: FRAME,
2688
+ y: FRAME,
2689
+ width: innerWidth,
2690
+ height: s.topLightHeight,
2691
+ opening: 'Fixed',
2692
+ oversize: false,
2693
+ },
2694
+ ...panes,
2695
+ ]
2696
+ },
2697
+ $glassArea: s => {
2698
+ return s.$panes.reduce((area, pane) => area + (pane.width * pane.height) / 1e6, 0)
2699
+ },
2700
+ // Uw from the glass, the frame and the glass edge, by the area-weighted method of EN ISO 10077
2701
+ $uValue: s => {
2702
+ const total = (s.width * s.height) / 1e6
2703
+ const frameArea = total - s.$glassArea
2704
+ const edge = s.$panes.reduce((length, pane) => length + (2 * (pane.width + pane.height)) / 1000, 0)
2705
+
2706
+ return (s.$glassArea * GLASS[s.glazing].ug + frameArea * FRAME_U + edge * EDGE_PSI) / total
2707
+ },
2708
+ $weightKg: s => {
2709
+ const frameMetres = (2 * (s.width + s.height)) / 1000
2710
+
2711
+ return s.$glassArea * GLASS[s.glazing].kgPerM2 + frameMetres * 4.5
2712
+ },
2713
+ $glassBuildUp: s => {
2714
+ return GLASS[s.glazing].buildUp
2715
+ },
2716
+ $price: s => {
2717
+ const frameMetres = (2 * (s.width + s.height)) / 1000
2718
+ const sashes = s.$panes.filter(pane => pane.opening !== 'Fixed').length
2719
+ const base =
2720
+ 180 +
2721
+ frameMetres * 85 +
2722
+ s.$glassArea * GLASS[s.glazing].gbpPerM2 +
2723
+ sashes * SASH_PRICE[s.opening] +
2724
+ (s.topLight ? 120 : 0) +
2725
+ (s.glazingBars ? 45 * s.$panes.length : 0)
2726
+
2727
+ return Math.round(base * FINISH_FACTOR[s.finish])
2728
+ },
2729
+ },
2730
+ })
2731
+ ```
2732
+
2733
+ ### ./src/handlers/order-window.ts
2734
+
2735
+ ```ts
2736
+ import { useCallback } from 'react'
2737
+
2738
+ import { phx } from '../../.phoenix/phx.js'
2739
+
2740
+ // Stands in for the joinery's order service: adding takes a moment, then the page shows the confirmation
2741
+ export default phx.frontend.createPhoenixHandler('orderWindow', () => {
2742
+ const navigate = phx.frontend.useNavigate()
2743
+
2744
+ return {
2745
+ handler: useCallback(async () => {
2746
+ await new Promise(resolve => setTimeout(resolve, 1200))
2747
+ navigate('ordered')
2748
+ }, [navigate]),
2749
+ }
2750
+ })
2751
+ ```
2752
+
2753
+ ### ./src/invariants/window.ts
2754
+
2755
+ ```ts
2756
+ import { phx } from '../../.phoenix/phx.js'
2757
+
2758
+ const MAX_SASH_WIDTH = { Fixed: Infinity, SideHung: 900, TiltTurn: 1300, TopHung: 1600 }
2759
+ const OPENING_LABEL = { Fixed: 'fixed', SideHung: 'side-hung', TiltTurn: 'tilt and turn', TopHung: 'top-hung' }
2760
+ const MIN_MAIN_HEIGHT = 500
2761
+ // A sash's two stiles are 56 mm each, so a narrower opening leaves no room for glass
2762
+ const MIN_SASH_WIDTH = 2 * 56
2763
+
2764
+ // Both rules are warnings: the drawing keeps the change and marks the problem, and the window cannot be ordered until
2765
+ // it is resolved
2766
+ export default phx.domain.implementCustomInvariants({
2767
+ Window: {
2768
+ // A sash wider than its hinges carry would sag, and one narrower than its two stiles has no room for glass; the
2769
+ // messages name the limit
2770
+ sashSize: s => {
2771
+ const oversize = s.$panes.find(pane => pane.oversize)
2772
+ const undersize = s.$panes.find(pane => pane.opening !== 'Fixed' && pane.width < MIN_SASH_WIDTH)
2773
+
2774
+ if (oversize !== undefined) {
2775
+ return [
2776
+ {
2777
+ type: 'sashTooWide',
2778
+ severity: 'warning',
2779
+ message: `A ${OPENING_LABEL[s.opening]} sash can be at most ${MAX_SASH_WIDTH[s.opening].toLocaleString('en-GB')} mm wide, and these are ${Math.round(oversize.width).toLocaleString('en-GB')} mm. Add a pane across or make the window narrower.`,
2780
+ },
2781
+ ]
2782
+ }
2783
+
2784
+ if (undersize !== undefined) {
2785
+ return [
2786
+ {
2787
+ type: 'sashTooNarrow',
2788
+ severity: 'warning',
2789
+ message: `A sash needs at least ${MIN_SASH_WIDTH} mm for its stiles, and these are ${Math.round(undersize.width)} mm. Use fewer panes across or make the window wider.`,
2790
+ },
2791
+ ]
2792
+ }
2793
+
2794
+ return []
2795
+ },
2796
+ topLightTooTall: s => {
2797
+ return !s.topLight || s.height - 2 * s.$frameWidth - s.topLightHeight - s.$mullionWidth >= MIN_MAIN_HEIGHT
2798
+ },
2799
+ },
2800
+ })
2801
+ ```
2802
+
2803
+ </details>