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

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