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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (617) hide show
  1. package/dist/application/index.d.ts +1 -0
  2. package/dist/application/index.js +1 -1
  3. package/dist/application/mount-application.d.ts +1 -1
  4. package/dist/application/mount-application.js +1 -1
  5. package/dist/application/mount-configurator.d.ts +19 -0
  6. package/dist/application/mount-configurator.js +1 -0
  7. package/dist/application/mounted-application-runtime.d.ts +4 -0
  8. package/dist/application/mounted-application-runtime.js +1 -0
  9. package/dist/application/register-web-component.d.ts +2 -0
  10. package/dist/application/register-web-component.js +1 -0
  11. package/dist/application-spec/errors/diagnostic-codes.d.ts +9 -0
  12. package/dist/application-spec/errors/diagnostic-codes.js +1 -1
  13. package/dist/application-spec/errors/diagnostic-messages.d.ts +10 -1
  14. package/dist/application-spec/errors/diagnostic-messages.js +1 -1
  15. package/dist/application-spec/generate-application/code/domain-runtime-files.js +1 -1
  16. package/dist/application-spec/generate-application/code/templates/application-types.js +6 -6
  17. package/dist/application-spec/generate-application/code/templates/common/doc-comment.d.ts +2 -0
  18. package/dist/application-spec/generate-application/code/templates/common/doc-comment.js +1 -0
  19. package/dist/application-spec/generate-application/code/templates/configurator-globals.d.ts +2 -1
  20. package/dist/application-spec/generate-application/code/templates/configurator-globals.js +19 -6
  21. package/dist/application-spec/generate-application/code/templates/global-definition/generate-action-types.js +7 -7
  22. package/dist/application-spec/generate-application/code/templates/global-definition/generate-assistant-form-element-types.d.ts +12 -0
  23. package/dist/application-spec/generate-application/code/templates/global-definition/generate-assistant-form-element-types.js +7 -0
  24. package/dist/application-spec/generate-application/code/templates/global-definition/generate-phx-types.js +24 -22
  25. package/dist/application-spec/generate-application/code/templates/global-definition/index.d.ts +1 -0
  26. package/dist/application-spec/generate-application/code/templates/global-definition/index.js +1 -1
  27. package/dist/application-spec/generate-application/code/templates/skills/sdk-content.d.ts +13 -6
  28. package/dist/application-spec/generate-application/code/templates/skills/sdk-content.js +2 -2
  29. package/dist/application-spec/generate-application/implementations/phx-application-types.d.ts +25 -0
  30. package/dist/application-spec/generate-application/implementations/phx-application-types.js +1 -0
  31. package/dist/application-spec/generate-application/implementations/phx-domain.d.ts +36 -0
  32. package/dist/application-spec/generate-application/implementations/phx-domain.js +1 -0
  33. package/dist/application-spec/generate-application/implementations/phx-model.d.ts +24 -0
  34. package/dist/application-spec/generate-application/implementations/phx-model.js +1 -0
  35. package/dist/application-spec/generate-application/implementations/phx.d.ts +22 -0
  36. package/dist/application-spec/generate-application/implementations/phx.js +1 -0
  37. package/dist/application-spec/generate-application/index.d.ts +2 -0
  38. package/dist/application-spec/generate-application/index.js +1 -1
  39. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-model-runtime.d.ts +3 -3
  40. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-runtime.d.ts +7 -4
  41. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-runtime.js +1 -1
  42. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-spec-runtime.d.ts +16 -0
  43. package/dist/application-spec/generate-application/runtime/admin-runtime/admin-spec-runtime.js +1 -0
  44. package/dist/application-spec/generate-application/runtime/application-runtime.d.ts +17 -4
  45. package/dist/application-spec/generate-application/runtime/application-runtime.js +1 -1
  46. package/dist/application-spec/generate-application/runtime/application-spec-slice.d.ts +71 -0
  47. package/dist/application-spec/generate-application/runtime/application-spec-slice.js +1 -0
  48. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-model-runtime.d.ts +3 -3
  49. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-runtime.d.ts +7 -5
  50. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-runtime.js +1 -1
  51. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-spec-runtime.d.ts +16 -0
  52. package/dist/application-spec/generate-application/runtime/configurator-runtime/configurator-spec-runtime.js +1 -0
  53. package/dist/application-spec/generate-application/runtime/create-application.d.ts +57 -0
  54. package/dist/application-spec/generate-application/runtime/create-application.js +1 -0
  55. package/dist/application-spec/generate-application/runtime/index.d.ts +8 -0
  56. package/dist/application-spec/generate-application/runtime/index.js +1 -0
  57. package/dist/application-spec/generate-application/runtime/sections/expression-runtime.d.ts +9 -3
  58. package/dist/application-spec/generate-application/runtime/sections/expression-runtime.js +1 -1
  59. package/dist/application-spec/generate-application/runtime/spec-runtime.d.ts +21 -0
  60. package/dist/application-spec/generate-application/runtime/spec-runtime.js +1 -0
  61. package/dist/application-spec/generate-application/runtime-spec/encoded-runtime-spec.d.ts +3 -2
  62. package/dist/application-spec/generate-application/runtime-spec/runtime-spec.d.ts +44 -9
  63. package/dist/application-spec/generate-application/runtime-spec/runtime-spec.js +1 -1
  64. package/dist/application-spec/parse-application-files/raw-spec/raw-assistant.d.ts +33 -3
  65. package/dist/application-spec/parse-application-files/raw-spec/raw-assistant.js +1 -1
  66. package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-form-elements.d.ts +9 -0
  67. package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-form-elements.js +1 -0
  68. package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-schema.d.ts +4 -1
  69. package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-schema.js +1 -1
  70. package/dist/configurator/ConfiguratorApp.js +1 -1
  71. package/dist/configurator/app-loader-content/DefaultAppLoaderContent.d.ts +2 -0
  72. package/dist/configurator/app-loader-content/DefaultAppLoaderContent.js +5 -0
  73. package/dist/configurator/app-loader-content/index.d.ts +1 -0
  74. package/dist/configurator/app-loader-content/index.js +1 -0
  75. package/dist/configurator/application-runtime/extensions/selectors.js +1 -1
  76. package/dist/configurator/application-runtime/extensions/systems/assistant-system/assistant-system.d.ts +4 -3
  77. package/dist/configurator/application-runtime/extensions/systems/assistant-system/assistant-system.js +1 -1
  78. package/dist/configurator/application-runtime/extensions/systems/assistant-system/graphql-tools.d.ts +33 -7
  79. package/dist/configurator/application-runtime/extensions/systems/assistant-system/graphql-tools.js +1 -1
  80. package/dist/configurator/application-runtime/extensions/systems/camera-control-system/camera-control-system.d.ts +1 -1
  81. package/dist/configurator/application-runtime/extensions/systems/canvas-system.d.ts +8 -0
  82. package/dist/configurator/application-runtime/extensions/systems/canvas-system.js +1 -1
  83. package/dist/configurator/application-runtime/extensions/systems/export-system/export-busy-error.d.ts +8 -0
  84. package/dist/configurator/application-runtime/extensions/systems/export-system/export-busy-error.js +1 -0
  85. package/dist/configurator/application-runtime/extensions/systems/export-system/export-system.d.ts +23 -1
  86. package/dist/configurator/application-runtime/extensions/systems/export-system/export-system.js +1 -1
  87. package/dist/configurator/application-runtime/extensions/systems/graphql-system/entity-meta.d.ts +14 -1
  88. package/dist/configurator/application-runtime/extensions/systems/graphql-system/entity-meta.js +1 -1
  89. package/dist/configurator/application-runtime/extensions/systems/graphql-system/graphql-system.d.ts +45 -4
  90. package/dist/configurator/application-runtime/extensions/systems/graphql-system/graphql-system.js +1 -1
  91. package/dist/configurator/application-runtime/extensions/systems/image-rendering-system/image-rendering-system.d.ts +14 -5
  92. package/dist/configurator/application-runtime/extensions/systems/image-rendering-system/image-rendering-system.js +1 -1
  93. package/dist/configurator/application-runtime/extensions/systems/index.d.ts +1 -1
  94. package/dist/configurator/common/configurator-interface.d.ts +111 -5
  95. package/dist/configurator/common/configurator-interface.js +1 -1
  96. package/dist/configurator/configuration-not-found-page/BuiltInConfigurationNotFoundPage.d.ts +5 -0
  97. package/dist/configurator/configuration-not-found-page/BuiltInConfigurationNotFoundPage.js +1 -0
  98. package/dist/configurator/configuration-not-found-page/ConfigurationNotFoundPageElement.d.ts +0 -6
  99. package/dist/configurator/configuration-not-found-page/ConfigurationNotFoundPageElement.js +1 -1
  100. package/dist/configurator/configuration-not-found-page/index.d.ts +2 -1
  101. package/dist/configurator/configuration-not-found-page/index.js +1 -1
  102. package/dist/configurator/configurator-context/ConfiguratorResponsiveProvider.d.ts +12 -0
  103. package/dist/configurator/configurator-context/ConfiguratorResponsiveProvider.js +1 -0
  104. package/dist/configurator/configurator-context/PreviewContextProvider.js +1 -1
  105. package/dist/configurator/configurator-context/ViewportStateSync.d.ts +2 -2
  106. package/dist/configurator/configurator-context/ViewportStateSync.js +1 -1
  107. package/dist/configurator/configurator-context/configurator-hooks/layout-hooks.d.ts +10 -0
  108. package/dist/configurator/configurator-context/configurator-routing/ConfiguratorRoutingProvider.js +1 -1
  109. package/dist/configurator/configurator-error-page/BuiltInConfiguratorErrorPage.d.ts +6 -0
  110. package/dist/configurator/configurator-error-page/BuiltInConfiguratorErrorPage.js +1 -0
  111. package/dist/configurator/configurator-error-page/ConfiguratorErrorPageElement.d.ts +7 -0
  112. package/dist/configurator/configurator-error-page/ConfiguratorErrorPageElement.js +1 -0
  113. package/dist/configurator/configurator-error-page/index.d.ts +2 -0
  114. package/dist/configurator/configurator-error-page/index.js +1 -0
  115. package/dist/configurator/index.d.ts +10 -0
  116. package/dist/configurator/index.js +1 -1
  117. package/dist/configurator/pages/ConfiguratorErrorPage.d.ts +0 -1
  118. package/dist/configurator/pages/ConfiguratorErrorPage.js +1 -1
  119. package/dist/configurator/pages/LoadedConfiguratorDetailPage.js +1 -1
  120. package/dist/configurator/pages/PresetCreationErrorPage.d.ts +1 -1
  121. package/dist/configurator/pages/PresetCreationErrorPage.js +1 -1
  122. package/dist/configurator/pages/default-configurator-elements.d.ts +4 -2
  123. package/dist/configurator/pages/default-configurator-elements.js +1 -1
  124. package/dist/configurator/pages/default-elements/index.d.ts +1 -0
  125. package/dist/configurator/pages/default-elements/index.js +1 -1
  126. package/dist/configurator/pages/default-elements/scene-controls/BuiltInAddEntityFab.d.ts +3 -0
  127. package/dist/configurator/pages/default-elements/scene-controls/BuiltInAddEntityFab.js +1 -0
  128. package/dist/configurator/pages/default-elements/scene-controls/BuiltInDeleteEntityFab.d.ts +3 -0
  129. package/dist/configurator/pages/default-elements/scene-controls/BuiltInDeleteEntityFab.js +1 -0
  130. package/dist/configurator/pages/default-elements/scene-controls/BuiltInNavigationHotspot.d.ts +3 -0
  131. package/dist/configurator/pages/default-elements/scene-controls/BuiltInNavigationHotspot.js +1 -0
  132. package/dist/configurator/pages/default-elements/scene-controls/BuiltInSelectionHotspot.d.ts +3 -0
  133. package/dist/configurator/pages/default-elements/scene-controls/BuiltInSelectionHotspot.js +1 -0
  134. package/dist/configurator/pages/default-elements/scene-controls/BuiltInToolMenuFab.d.ts +3 -0
  135. package/dist/configurator/pages/default-elements/scene-controls/BuiltInToolMenuFab.js +1 -0
  136. package/dist/configurator/pages/default-elements/scene-controls/index.d.ts +6 -0
  137. package/dist/configurator/pages/default-elements/scene-controls/index.js +1 -0
  138. package/dist/configurator/pages/default-elements/scene-controls/scene-control-elements.d.ts +6 -0
  139. package/dist/configurator/pages/default-elements/scene-controls/scene-control-elements.js +1 -0
  140. package/dist/configurator/pages/error-boundaries/GeneralErrorBoundary.js +1 -1
  141. package/dist/configurator/pages/error-boundaries/LoadingErrorBoundary.js +1 -1
  142. package/dist/configurator/scene/index.d.ts +1 -0
  143. package/dist/configurator/scene/index.js +1 -1
  144. package/dist/configurator/scene/scene-controls/ToolFab.d.ts +4 -0
  145. package/dist/configurator/scene/scene-controls/ToolFab.js +1 -1
  146. package/dist/configurator/scene/scene-controls/use-scene-control-tooltip.d.ts +2 -1
  147. package/dist/configurator/scene/scene-entity/scene-controls/SceneEntityHtmlHotspots.js +1 -1
  148. package/dist/configurator/toolbar-actions/index.d.ts +3 -0
  149. package/dist/configurator/toolbar-actions/index.js +1 -0
  150. package/dist/configurator/toolbar-actions/use-assistant-panel.d.ts +18 -0
  151. package/dist/configurator/toolbar-actions/use-assistant-panel.js +1 -0
  152. package/dist/configurator/toolbar-actions/use-measurements.d.ts +35 -0
  153. package/dist/configurator/toolbar-actions/use-measurements.js +1 -0
  154. package/dist/configurator/toolbar-actions/use-toolbar-actions.d.ts +100 -0
  155. package/dist/configurator/toolbar-actions/use-toolbar-actions.js +1 -0
  156. package/dist/configurator/toolbar-actions/use-toolbar-help.d.ts +7 -0
  157. package/dist/configurator/toolbar-actions/use-toolbar-help.js +1 -0
  158. package/dist/configurator/ui/assistant/AssistantChips.d.ts +3 -2
  159. package/dist/configurator/ui/assistant/AssistantChips.js +1 -1
  160. package/dist/configurator/ui/assistant/AssistantMarkdown.d.ts +1 -1
  161. package/dist/configurator/ui/assistant/AssistantMarkdown.js +1 -1
  162. package/dist/configurator/ui/assistant/alert/AssistantAlert.d.ts +8 -0
  163. package/dist/configurator/ui/assistant/alert/AssistantAlert.js +1 -0
  164. package/dist/configurator/ui/assistant/alert/index.d.ts +2 -0
  165. package/dist/configurator/ui/assistant/alert/index.js +1 -0
  166. package/dist/configurator/ui/assistant/alert/remark-alerts.d.ts +10 -0
  167. package/dist/configurator/ui/assistant/alert/remark-alerts.js +1 -0
  168. package/dist/configurator/ui/assistant/form/AssistantForm.js +1 -1
  169. package/dist/configurator/ui/assistant/form/compose-answer.d.ts +1 -1
  170. package/dist/configurator/ui/assistant/form/compose-answer.js +1 -1
  171. package/dist/configurator/ui/assistant/form/form-model.d.ts +3 -4
  172. package/dist/configurator/ui/assistant/form/form-state.d.ts +1 -1
  173. package/dist/configurator/ui/assistant/form/index.d.ts +2 -0
  174. package/dist/configurator/ui/assistant/form/index.js +1 -1
  175. package/dist/configurator/ui/assistant/form/parse-form-block.d.ts +3 -2
  176. package/dist/configurator/ui/assistant/form/parse-form-block.js +1 -1
  177. package/dist/configurator/ui/assistant/form/read-form-attributes.d.ts +1 -1
  178. package/dist/configurator/ui/assistant/form/read-form-attributes.js +1 -1
  179. package/dist/configurator/ui/assistant/form/registry/built-in-form-elements.d.ts +3 -0
  180. package/dist/configurator/ui/assistant/form/registry/built-in-form-elements.js +1 -0
  181. package/dist/configurator/ui/assistant/form/registry/create-assistant-form-element.d.ts +31 -0
  182. package/dist/configurator/ui/assistant/form/registry/create-assistant-form-element.js +1 -0
  183. package/dist/configurator/ui/assistant/form/{form-element-definition.d.ts → registry/form-element-definition.d.ts} +3 -1
  184. package/dist/configurator/ui/assistant/form/registry/form-element-definition.js +1 -0
  185. package/dist/configurator/ui/assistant/form/{form-registry.d.ts → registry/form-registry.d.ts} +2 -2
  186. package/dist/configurator/ui/assistant/form/registry/form-registry.js +1 -0
  187. package/dist/configurator/ui/assistant/form/registry/index.d.ts +6 -0
  188. package/dist/configurator/ui/assistant/form/registry/index.js +1 -0
  189. package/dist/configurator/ui/assistant/form/registry/use-form-registry.d.ts +8 -0
  190. package/dist/configurator/ui/assistant/form/registry/use-form-registry.js +1 -0
  191. package/dist/configurator/ui/assistant/form-elements/FormCard.d.ts +9 -0
  192. package/dist/configurator/ui/assistant/form-elements/FormCard.js +1 -0
  193. package/dist/configurator/ui/assistant/form-elements/FormConfirm.d.ts +9 -0
  194. package/dist/configurator/ui/assistant/form-elements/FormConfirm.js +1 -0
  195. package/dist/configurator/ui/assistant/form-elements/FormGroup.d.ts +4 -1
  196. package/dist/configurator/ui/assistant/form-elements/FormNumber.d.ts +9 -0
  197. package/dist/configurator/ui/assistant/form-elements/FormNumber.js +1 -0
  198. package/dist/configurator/ui/assistant/form-elements/FormOptions.d.ts +4 -1
  199. package/dist/configurator/ui/assistant/form-elements/FormReply.d.ts +4 -1
  200. package/dist/configurator/ui/assistant/form-elements/FormReply.js +1 -1
  201. package/dist/configurator/ui/assistant/form-elements/FormSwatch.d.ts +10 -0
  202. package/dist/configurator/ui/assistant/form-elements/FormSwatch.js +1 -0
  203. package/dist/configurator/ui/assistant/form-elements/FormText.d.ts +4 -1
  204. package/dist/configurator/ui/assistant/form-elements/FormText.js +1 -1
  205. package/dist/configurator/ui/assistant/form-elements/clamp-form-number.d.ts +10 -0
  206. package/dist/configurator/ui/assistant/form-elements/clamp-form-number.js +1 -0
  207. package/dist/configurator/ui/assistant/form-elements/form-element-props.d.ts +20 -16
  208. package/dist/configurator/ui/assistant/form-elements/index.d.ts +4 -0
  209. package/dist/configurator/ui/assistant/form-elements/index.js +1 -1
  210. package/dist/configurator/ui/assistant/index.d.ts +3 -0
  211. package/dist/configurator/ui/assistant/index.js +1 -1
  212. package/dist/configurator/ui/assistant/resource-image/index.d.ts +1 -1
  213. package/dist/configurator/ui/assistant/resource-image/index.js +1 -1
  214. package/dist/configurator/ui/assistant/resource-image/resource-path.d.ts +0 -9
  215. package/dist/configurator/ui/assistant/resource-image/resource-path.js +1 -1
  216. package/dist/configurator/ui/assistant/screenshots/ScreenshotThumbnails.d.ts +9 -0
  217. package/dist/configurator/ui/assistant/screenshots/ScreenshotThumbnails.js +1 -0
  218. package/dist/configurator/ui/assistant/screenshots/index.d.ts +2 -0
  219. package/dist/configurator/ui/assistant/screenshots/index.js +1 -0
  220. package/dist/configurator/ui/assistant/screenshots/tool-screenshots.d.ts +14 -0
  221. package/dist/configurator/ui/assistant/screenshots/tool-screenshots.js +1 -0
  222. package/dist/configurator/ui/assistant/streamed-markdown.d.ts +2 -2
  223. package/dist/configurator/ui/assistant/streamed-markdown.js +2 -2
  224. package/dist/configurator/ui/assistant/tool-chip.js +1 -1
  225. package/dist/configurator/ui/configurator-layout/slots/canvas/LayoutCanvas.js +1 -1
  226. package/dist/configurator/ui/configurator-layout/slots/canvas/SharedCanvas.js +1 -1
  227. package/dist/configurator/ui/configurator-layout/universal/ExportingOverlay.d.ts +5 -0
  228. package/dist/configurator/ui/configurator-layout/universal/ExportingOverlay.js +1 -0
  229. package/dist/configurator/ui/configurator-layout/universal/Layout.js +1 -1
  230. package/dist/configurator/ui/configurator-layout/universal/use-layout-scene-safe-area.d.ts +1 -1
  231. package/dist/configurator/ui/configurator-layout/universal/use-layout-scene-safe-area.js +1 -1
  232. package/dist/configurator/ui/configurator-layout/universal/use-scene-safe-area.d.ts +1 -1
  233. package/dist/configurator/ui/index.d.ts +1 -0
  234. package/dist/configurator/ui/index.js +1 -1
  235. package/dist/configurator/ui/legacy/layout/common/ReactiveInterfaceLayout.js +1 -1
  236. package/dist/configurator/ui/phoenix-layout-elements/AppLoader.d.ts +0 -7
  237. package/dist/configurator/ui/phoenix-layout-elements/AppLoader.js +2 -6
  238. package/dist/configurator/ui/phoenix-layout-elements/AssistantToolbarButton.js +1 -1
  239. package/dist/configurator/ui/phoenix-layout-elements/LanguageMenuAction.js +1 -1
  240. package/dist/configurator/ui/phoenix-layout-elements/LanguageToolbarButton.js +1 -1
  241. package/dist/configurator/ui/phoenix-layout-elements/MeasurementsMenuAction.js +1 -1
  242. package/dist/configurator/ui/phoenix-layout-elements/MeasurementsToolbarButton.js +1 -1
  243. package/dist/configurator/ui/phoenix-layout-elements/ShareToolbarButton.js +1 -1
  244. package/dist/configurator/ui/phoenix-layout-elements/header/common/step-navigation/DesktopNavigationSteps.d.ts +1 -1
  245. package/dist/configurator/ui/phoenix-layout-elements/header/common/step-navigation/DesktopNavigationSteps.js +1 -1
  246. package/dist/configurator/ui/phoenix-layout-elements/header/common/step-navigation/StepsNavigation.d.ts +2 -8
  247. package/dist/configurator/ui/phoenix-layout-elements/header/common/step-navigation/StepsNavigation.js +1 -1
  248. package/dist/configurator/ui/phoenix-layout-elements/index.d.ts +8 -1
  249. package/dist/configurator/ui/phoenix-layout-elements/index.js +1 -1
  250. package/dist/configurator/ui/phoenix-layout-elements/measurements/DimensionUnitSelector.js +1 -1
  251. package/dist/configurator/ui/phoenix-layout-elements/measurements/MeasurementsIndicatorsSwitch.js +1 -1
  252. package/dist/configurator/ui/phoenix-layout-elements/share/ShareModal.js +1 -1
  253. package/dist/configurator/ui/phoenix-layout-elements/share/use-share.d.ts +7 -8
  254. package/dist/configurator/ui/phoenix-layout-elements/share/use-share.js +1 -1
  255. package/dist/configurator/ui/phoenix-layout-elements/sidebar-footer/common/FinalizeActions.js +1 -1
  256. package/dist/configurator/ui/phoenix-layout-elements/sidebar-footer/common/NextStepButton.js +1 -1
  257. package/dist/configurator/ui/phoenix-layout-elements/sidebar-footer/common/PreviousStepButton.js +1 -1
  258. package/dist/configurator/ui/preset-layout/PresetUi.d.ts +0 -2
  259. package/dist/configurator/ui/preset-layout/PresetUi.js +1 -1
  260. package/dist/configurator/ui/preset-layout/index.d.ts +6 -1
  261. package/dist/configurator/ui/preset-layout/index.js +1 -1
  262. package/dist/configurator/ui/save-message-snackbars/SaveMessageSnackbars.js +1 -1
  263. package/dist/docs/complex-use-cases/focus-mode.mdx +2 -0
  264. package/dist/docs/fabs/basic.mdx +4 -0
  265. package/dist/docs/index.json +95 -1
  266. package/dist/docs/integrations/webcomponent-api/basics.mdx +2 -0
  267. package/dist/docs/layouts/custom-layout.mdx +321 -159
  268. package/dist/docs/layouts/gallery/ardent-compressor.mdx +3516 -0
  269. package/dist/docs/layouts/gallery/corvid-drone.mdx +3428 -0
  270. package/dist/docs/layouts/gallery/fenwick-window.mdx +2803 -0
  271. package/dist/docs/layouts/gallery/ferrule-bar-station.mdx +6567 -0
  272. package/dist/docs/layouts/gallery/kestrel-kettle.mdx +1858 -0
  273. package/dist/docs/layouts/gallery/nightjar-guitar.mdx +3698 -0
  274. package/dist/docs/layouts/gallery/sunfold-solar.mdx +2683 -0
  275. package/dist/docs/layouts/gallery/tidepool-aquascape.mdx +5203 -0
  276. package/dist/docs/other-features/client-side-rendering/declarative.mdx +1 -1
  277. package/dist/docs/other-features/error-5xx.mdx +1 -1
  278. package/dist/docs/other-features/navigation-hotspot.mdx +2 -0
  279. package/dist/docs/other-features/not-found-404.mdx +1 -1
  280. package/dist/docs/phoenix/configurator-interface.mdx +202 -67
  281. package/dist/docs/phoenix/finalization.mdx +1 -1
  282. package/dist/docs/phoenix/layout-slots.mdx +5 -5
  283. package/dist/docs/phoenix/sdk-builder-workflow.mdx +1 -1
  284. package/dist/docs/phoenix/sessions-and-access.mdx +2 -0
  285. package/dist/docs/phoenix/testing.mdx +2 -2
  286. package/dist/docs/selection-and-highlight/hotspots.mdx +2 -0
  287. package/dist/domain/application/assistant/assistant-manifest.d.ts +16 -1
  288. package/dist/domain/application/assistant/assistant-resolved-manifest.d.ts +8 -1
  289. package/dist/domain/application/assistant/build-assistant-manifest.js +1 -1
  290. package/dist/domain/application/assistant/form/assistant-form-element-name.d.ts +11 -0
  291. package/dist/domain/application/assistant/form/assistant-form-element-name.js +8 -0
  292. package/dist/domain/application/assistant/form/assistant-form-elements.d.ts +174 -0
  293. package/dist/domain/application/assistant/form/assistant-form-elements.js +1 -0
  294. package/dist/domain/application/assistant/form/assistant-resource-path.d.ts +10 -0
  295. package/dist/domain/application/assistant/form/assistant-resource-path.js +1 -0
  296. package/dist/domain/application/assistant/form/index.d.ts +4 -0
  297. package/dist/domain/application/assistant/form/index.js +1 -0
  298. package/dist/domain/application/assistant/generate-assistant-prompt.d.ts +3 -0
  299. package/dist/domain/application/assistant/generate-assistant-prompt.js +1 -1
  300. package/dist/domain/application/assistant/index.d.ts +2 -3
  301. package/dist/domain/application/assistant/index.js +1 -1
  302. package/dist/domain/application/assistant/session-setup/apply-assistant-setup.js +1 -1
  303. package/dist/domain/application/assistant/session-setup/assistant-setup-manifest.d.ts +2 -0
  304. package/dist/domain/application/assistant/session-setup/assistant-setup-manifest.js +1 -1
  305. package/dist/domain/application/configurator-builder.d.ts +7 -1
  306. package/dist/domain/application/configurator-builder.js +1 -1
  307. package/dist/domain/application/index.d.ts +1 -1
  308. package/dist/domain/application/index.js +1 -1
  309. package/dist/domain/application/standard-properties.js +1 -1
  310. package/dist/domain/application/web-component-property.d.ts +3 -1
  311. package/dist/domain/application/web-component-property.js +1 -1
  312. package/dist/domain/available-system-components.d.ts +1 -1
  313. package/dist/domain/available-system-components.js +1 -1
  314. package/dist/domain/domain-builder.d.ts +9 -3
  315. package/dist/domain/domain-builder.js +1 -1
  316. package/dist/domain/domain-context/store-system/store-system.d.ts +5 -0
  317. package/dist/domain/domain-context/store-system/store-system.js +1 -1
  318. package/dist/domain/domain-runtime/application.d.ts +39 -0
  319. package/dist/domain/domain-runtime/application.js +1 -0
  320. package/dist/domain/domain-runtime/domain-context/selectors.d.ts +1 -0
  321. package/dist/domain/domain-runtime/domain-context/selectors.js +1 -1
  322. package/dist/domain/domain-runtime/domain-runtime.d.ts +44 -4
  323. package/dist/domain/domain-runtime/domain-runtime.js +1 -1
  324. package/dist/domain/domain-runtime/environment/initialize.d.ts +1 -1
  325. package/dist/domain/domain-runtime/environment/types.d.ts +1 -1
  326. package/dist/domain/domain-runtime/errors/implementation-error-collector.d.ts +2 -0
  327. package/dist/domain/domain-runtime/errors/implementation-error-collector.js +1 -1
  328. package/dist/domain/domain-runtime/index.d.ts +2 -1
  329. package/dist/domain/domain-runtime/index.js +1 -1
  330. package/dist/domain/domain-runtime/spec/index.d.ts +1 -1
  331. package/dist/domain/domain-runtime/spec/index.js +1 -1
  332. package/dist/domain/domain-runtime/spec/initialize.js +1 -1
  333. package/dist/domain/domain-runtime/spec/module-checks/element-registration.d.ts +15 -1
  334. package/dist/domain/domain-runtime/spec/module-checks/element-registration.js +1 -1
  335. package/dist/domain/domain-runtime/spec/module-checks/find-assistant-form-element-errors.d.ts +12 -0
  336. package/dist/domain/domain-runtime/spec/module-checks/find-assistant-form-element-errors.js +1 -0
  337. package/dist/domain/domain-runtime/spec/module-checks/index.d.ts +3 -1
  338. package/dist/domain/domain-runtime/spec/module-checks/index.js +1 -1
  339. package/dist/domain/domain-runtime/spec/module-checks/merge-expression-modules.d.ts +23 -0
  340. package/dist/domain/domain-runtime/spec/module-checks/merge-expression-modules.js +1 -0
  341. package/dist/domain/domain-runtime/spec/module-checks/resolve-expression-modules.d.ts +3 -4
  342. package/dist/domain/domain-runtime/spec/module-checks/resolve-expression-modules.js +1 -1
  343. package/dist/domain/domain-runtime/spec/selectors.d.ts +3 -1
  344. package/dist/domain/domain-runtime/spec/selectors.js +1 -1
  345. package/dist/domain/domain-runtime/web-component/actions.d.ts +3 -0
  346. package/dist/domain/domain-runtime/web-component/actions.js +1 -1
  347. package/dist/domain/domain-runtime/web-component/initialize.d.ts +1 -0
  348. package/dist/domain/domain-runtime/web-component/initialize.js +1 -1
  349. package/dist/domain/domain-runtime/web-component/selectors.d.ts +2 -0
  350. package/dist/domain/domain-runtime/web-component/selectors.js +1 -1
  351. package/dist/domain/domain-runtime/web-component/types.d.ts +6 -3
  352. package/dist/domain/extensions/export-images/agent-screenshot.d.ts +24 -0
  353. package/dist/domain/extensions/export-images/agent-screenshot.js +1 -0
  354. package/dist/domain/extensions/{client-side-image.d.ts → export-images/client-side-image.d.ts} +2 -2
  355. package/dist/domain/extensions/export-images/client-side-image.js +1 -0
  356. package/dist/domain/extensions/export-images/index.d.ts +3 -0
  357. package/dist/domain/extensions/export-images/index.js +1 -0
  358. package/dist/domain/extensions/{save-template-thumbnail.d.ts → export-images/save-template-thumbnail.d.ts} +2 -2
  359. package/dist/domain/extensions/export-images/save-template-thumbnail.js +1 -0
  360. package/dist/domain/extensions/index.d.ts +1 -2
  361. package/dist/domain/extensions/index.js +1 -1
  362. package/dist/domain/mocks/create-mock-domain-runtime.d.ts +9 -0
  363. package/dist/editor/domain-editors/DomainSpecContext.js +1 -1
  364. package/dist/editor/domain-editors/apply-spec-update.d.ts +10 -2
  365. package/dist/editor/domain-editors/apply-spec-update.js +1 -1
  366. package/dist/editor/domain-editors/use-implementation-files.d.ts +6 -14
  367. package/dist/editor/domain-editors/use-implementation-files.js +1 -1
  368. package/dist/editor/screenshot/ScreenshotHandler.js +1 -1
  369. package/dist/i18n/locales/cs/messages.js +1 -1
  370. package/dist/i18n/locales/da/messages.js +1 -1
  371. package/dist/i18n/locales/de/messages.js +1 -1
  372. package/dist/i18n/locales/de-AT/messages.js +1 -1
  373. package/dist/i18n/locales/en/messages.js +1 -1
  374. package/dist/i18n/locales/en-AU/messages.js +1 -1
  375. package/dist/i18n/locales/en-GB/messages.js +1 -1
  376. package/dist/i18n/locales/es/messages.js +1 -1
  377. package/dist/i18n/locales/fr/messages.js +1 -1
  378. package/dist/i18n/locales/hu/messages.js +1 -1
  379. package/dist/i18n/locales/it/messages.js +1 -1
  380. package/dist/i18n/locales/nl/messages.js +1 -1
  381. package/dist/i18n/locales/pl/messages.js +1 -1
  382. package/dist/i18n/locales/pt/messages.js +1 -1
  383. package/dist/i18n/locales/ro/messages.js +1 -1
  384. package/dist/i18n/locales/sk/messages.js +1 -1
  385. package/dist/i18n/locales/tr/messages.js +1 -1
  386. package/dist/legacy/domain-cli/utils/cache.d.ts +1 -1
  387. package/dist/legacy/domain-cli/utils/cache.js +1 -1
  388. package/dist/legacy/domain-spec/__generated/domain-schema.d.ts +16 -0
  389. package/dist/legacy/domain-spec/spec/domain-spec.js +1 -1
  390. package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/claude-md.d.ts +1 -1
  391. package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/cursor-rules.d.ts +1 -1
  392. package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/shared-sdk-content.d.ts +1 -1
  393. package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/shared-sdk-content.js +11 -5
  394. package/dist/react-sdk/application-runtime/common-services/analytics/analytics-service.d.ts +1 -1
  395. package/dist/react-sdk/application-runtime/common-services/analytics/frontend-analytics.d.ts +4 -0
  396. package/dist/react-sdk/application-runtime/common-services/initialize.d.ts +2 -2
  397. package/dist/react-sdk/application-runtime/hooks/index.d.ts +2 -0
  398. package/dist/react-sdk/application-runtime/hooks/index.js +1 -1
  399. package/dist/react-sdk/application-runtime/hooks/loading/index.d.ts +5 -0
  400. package/dist/react-sdk/application-runtime/hooks/loading/index.js +1 -0
  401. package/dist/react-sdk/application-runtime/hooks/loading/loader-host-context.d.ts +11 -0
  402. package/dist/react-sdk/application-runtime/hooks/loading/loader-host-context.js +1 -0
  403. package/dist/react-sdk/application-runtime/hooks/loading/use-is-awaiting-loading-phase.d.ts +8 -0
  404. package/dist/react-sdk/application-runtime/hooks/loading/use-is-awaiting-loading-phase.js +1 -0
  405. package/dist/react-sdk/application-runtime/hooks/loading/use-is-loader-hosted.d.ts +7 -0
  406. package/dist/react-sdk/application-runtime/hooks/loading/use-is-loader-hosted.js +1 -0
  407. package/dist/react-sdk/application-runtime/hooks/loading/use-loading-actions.d.ts +9 -0
  408. package/dist/react-sdk/application-runtime/hooks/loading/use-loading-actions.js +1 -0
  409. package/dist/react-sdk/application-runtime/hooks/loading/use-loading-application.d.ts +24 -0
  410. package/dist/react-sdk/application-runtime/hooks/loading/use-loading-application.js +1 -0
  411. package/dist/react-sdk/application-runtime/hooks/responsive/index.d.ts +3 -0
  412. package/dist/react-sdk/application-runtime/hooks/responsive/index.js +1 -0
  413. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-actions.d.ts +9 -0
  414. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-actions.js +1 -0
  415. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-application.d.ts +26 -0
  416. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-application.js +1 -0
  417. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-state.d.ts +10 -0
  418. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-state.js +1 -0
  419. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-store.d.ts +24 -0
  420. package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-store.js +1 -0
  421. package/dist/react-sdk/application-runtime/hooks/use-access-service.d.ts +5 -0
  422. package/dist/react-sdk/application-runtime/hooks/use-access-service.js +1 -1
  423. package/dist/react-sdk/application-runtime/hooks/use-phoenix-element-slots.d.ts +2 -1
  424. package/dist/react-sdk/application-runtime/hooks/use-web-component-readonly-sync.d.ts +4 -11
  425. package/dist/react-sdk/application-runtime/hooks/use-web-component-readonly-sync.js +1 -1
  426. package/dist/react-sdk/application-runtime/hooks/use-web-component.d.ts +5 -0
  427. package/dist/react-sdk/application-runtime/hooks/use-web-component.js +1 -1
  428. package/dist/react-sdk/application-runtime/host-properties/actions.d.ts +15 -0
  429. package/dist/react-sdk/application-runtime/host-properties/actions.js +1 -0
  430. package/dist/react-sdk/application-runtime/host-properties/connect-host-properties.d.ts +30 -0
  431. package/dist/react-sdk/application-runtime/host-properties/connect-host-properties.js +1 -0
  432. package/dist/react-sdk/application-runtime/host-properties/domain-host-properties.d.ts +7 -0
  433. package/dist/react-sdk/application-runtime/host-properties/domain-host-properties.js +1 -0
  434. package/dist/react-sdk/application-runtime/host-properties/host-property-descriptors.d.ts +21 -0
  435. package/dist/react-sdk/application-runtime/host-properties/host-property-descriptors.js +1 -0
  436. package/dist/react-sdk/application-runtime/host-properties/host-property-scopes.d.ts +42 -0
  437. package/dist/react-sdk/application-runtime/host-properties/host-property-scopes.js +1 -0
  438. package/dist/react-sdk/application-runtime/host-properties/index.d.ts +8 -0
  439. package/dist/react-sdk/application-runtime/host-properties/index.js +1 -0
  440. package/dist/react-sdk/application-runtime/host-properties/initialize.d.ts +7 -0
  441. package/dist/react-sdk/application-runtime/host-properties/initialize.js +1 -0
  442. package/dist/react-sdk/application-runtime/host-properties/selectors.d.ts +54 -0
  443. package/dist/react-sdk/application-runtime/host-properties/selectors.js +1 -0
  444. package/dist/react-sdk/application-runtime/host-properties/types.d.ts +18 -0
  445. package/dist/react-sdk/application-runtime/index.d.ts +3 -0
  446. package/dist/react-sdk/application-runtime/integration/initialize.d.ts +7 -6
  447. package/dist/react-sdk/application-runtime/integration/initialize.js +1 -1
  448. package/dist/react-sdk/application-runtime/integration/selectors.d.ts +11 -2
  449. package/dist/react-sdk/application-runtime/integration/selectors.js +1 -1
  450. package/dist/react-sdk/application-runtime/integration/types.d.ts +5 -2
  451. package/dist/react-sdk/application-runtime/loading/actions.d.ts +45 -0
  452. package/dist/react-sdk/application-runtime/loading/actions.js +1 -0
  453. package/dist/react-sdk/application-runtime/loading/index.d.ts +4 -0
  454. package/dist/react-sdk/application-runtime/loading/index.js +1 -0
  455. package/dist/react-sdk/application-runtime/loading/initialize.d.ts +3 -0
  456. package/dist/react-sdk/application-runtime/loading/initialize.js +1 -0
  457. package/dist/react-sdk/application-runtime/loading/selectors.d.ts +18 -0
  458. package/dist/react-sdk/application-runtime/loading/selectors.js +1 -0
  459. package/dist/react-sdk/application-runtime/loading/types.d.ts +59 -0
  460. package/dist/react-sdk/application-runtime/loading/types.js +1 -0
  461. package/dist/react-sdk/application-runtime/mocks/create-mock-application-runtime.js +1 -1
  462. package/dist/react-sdk/application-runtime/provider/ApplicationRuntime.d.ts +6 -0
  463. package/dist/react-sdk/application-runtime/provider/ApplicationRuntime.js +1 -1
  464. package/dist/react-sdk/application-runtime/provider/ApplicationType.d.ts +7 -0
  465. package/dist/react-sdk/application-runtime/provider/ApplicationType.js +1 -1
  466. package/dist/react-sdk/application-runtime/responsive/actions.d.ts +47 -0
  467. package/dist/react-sdk/application-runtime/responsive/actions.js +1 -0
  468. package/dist/react-sdk/application-runtime/responsive/index.d.ts +4 -0
  469. package/dist/react-sdk/application-runtime/responsive/index.js +1 -0
  470. package/dist/react-sdk/application-runtime/responsive/initialize.d.ts +3 -0
  471. package/dist/react-sdk/application-runtime/responsive/initialize.js +1 -0
  472. package/dist/react-sdk/application-runtime/responsive/selectors.d.ts +20 -0
  473. package/dist/react-sdk/application-runtime/responsive/selectors.js +1 -0
  474. package/dist/react-sdk/application-runtime/responsive/types.d.ts +104 -0
  475. package/dist/react-sdk/application-runtime/responsive/types.js +1 -0
  476. package/dist/react-sdk/application-runtime/routing/routing-service.d.ts +34 -2
  477. package/dist/react-sdk/application-runtime/routing/routing-service.js +1 -1
  478. package/dist/react-sdk/application-runtime/runtime/create-application-runtime.d.ts +313 -10
  479. package/dist/react-sdk/application-runtime/runtime/create-application-runtime.js +1 -1
  480. package/dist/react-sdk/domain-application-context/BackendDataProvider.js +1 -1
  481. package/dist/react-sdk/domain-application-context/FrontendDataProvider.js +1 -1
  482. package/dist/react-sdk/domain-application-context/PublishedDataProvider.js +1 -1
  483. package/dist/react-sdk/domain-context/common-hooks/index.d.ts +9 -0
  484. package/dist/react-sdk/domain-context/common-hooks/index.js +1 -0
  485. package/dist/react-sdk/domain-context/common-hooks/use-configuration-save-state.d.ts +23 -0
  486. package/dist/react-sdk/domain-context/common-hooks/use-configuration-save-state.js +1 -0
  487. package/dist/react-sdk/domain-context/common-hooks/use-domain-context.d.ts +1 -0
  488. package/dist/react-sdk/domain-context/component-hooks/index.d.ts +1 -0
  489. package/dist/react-sdk/domain-context/component-hooks/index.js +1 -0
  490. package/dist/react-sdk/domain-context/component-hooks/use-component.js +1 -1
  491. package/dist/react-sdk/domain-context/entity-hooks/use-domain-entity-state.js +1 -1
  492. package/dist/react-sdk/domain-context/entity-hooks/use-entity-price.js +1 -1
  493. package/dist/react-sdk/domain-context/entity-hooks/use-root-entity-id.js +1 -1
  494. package/dist/react-sdk/domain-context/entity-hooks/use-root-entity-state.js +1 -1
  495. package/dist/react-sdk/domain-context/index.d.ts +5 -0
  496. package/dist/react-sdk/domain-context/index.js +1 -1
  497. package/dist/react-sdk/domain-context/system-hooks/composition-hooks.js +1 -1
  498. package/dist/react-sdk/domain-context/system-hooks/dimension-hooks.js +1 -1
  499. package/dist/react-sdk/domain-context/system-hooks/generate-share-link-hooks.d.ts +8 -0
  500. package/dist/react-sdk/domain-context/system-hooks/generate-share-link-hooks.js +1 -0
  501. package/dist/react-sdk/domain-context/system-hooks/hover-hooks.js +1 -1
  502. package/dist/react-sdk/domain-context/system-hooks/locale-switch-hooks.d.ts +24 -0
  503. package/dist/react-sdk/domain-context/system-hooks/locale-switch-hooks.js +1 -0
  504. package/dist/react-sdk/domain-context/system-hooks/navigation-hooks.js +1 -1
  505. package/dist/react-sdk/domain-context/system-hooks/selection-hooks.js +1 -1
  506. package/dist/react-sdk/domain-context/system-hooks/share-links-hooks.d.ts +32 -0
  507. package/dist/react-sdk/domain-context/system-hooks/share-links-hooks.js +1 -0
  508. package/dist/react-sdk/domain-context/system-hooks/step-navigation-hooks.d.ts +57 -0
  509. package/dist/react-sdk/domain-context/system-hooks/step-navigation-hooks.js +1 -0
  510. package/dist/react-sdk/domain-context/system-hooks/tab-navigation-hooks.js +1 -1
  511. package/dist/react-sdk/domain-context/system-hooks/undo-redo-hooks.js +1 -1
  512. package/dist/react-sdk/handlers/FinalizeActionRunner.d.ts +13 -0
  513. package/dist/react-sdk/handlers/FinalizeActionRunner.js +1 -0
  514. package/dist/react-sdk/handlers/FinalizeActionsHost.d.ts +10 -0
  515. package/dist/react-sdk/handlers/FinalizeActionsHost.js +1 -0
  516. package/dist/react-sdk/handlers/FinalizeActionsProvider.d.ts +11 -0
  517. package/dist/react-sdk/handlers/FinalizeActionsProvider.js +1 -0
  518. package/dist/react-sdk/handlers/finalize-actions-context.d.ts +6 -0
  519. package/dist/react-sdk/handlers/finalize-actions-context.js +1 -0
  520. package/dist/react-sdk/handlers/finalize-actions-store.d.ts +23 -0
  521. package/dist/react-sdk/handlers/finalize-actions-store.js +1 -0
  522. package/dist/react-sdk/handlers/index.d.ts +5 -0
  523. package/dist/react-sdk/handlers/index.js +1 -1
  524. package/dist/react-sdk/handlers/use-finalize-action.d.ts +3 -2
  525. package/dist/react-sdk/handlers/use-finalize-action.js +1 -1
  526. package/dist/react-sdk/handlers/use-finalize-actions.d.ts +53 -0
  527. package/dist/react-sdk/handlers/use-finalize-actions.js +1 -0
  528. package/dist/react-sdk/index.d.ts +2 -0
  529. package/dist/react-sdk/index.js +1 -1
  530. package/dist/react-sdk/loading-state/LoaderHost.d.ts +20 -0
  531. package/dist/react-sdk/loading-state/LoaderHost.js +1 -0
  532. package/dist/react-sdk/loading-state/built-in-loading-phase.d.ts +9 -0
  533. package/dist/react-sdk/loading-state/built-in-loading-phase.js +1 -0
  534. package/dist/react-sdk/loading-state/create-app-loader.d.ts +18 -0
  535. package/dist/react-sdk/loading-state/create-app-loader.js +1 -0
  536. package/dist/react-sdk/loading-state/index.d.ts +4 -2
  537. package/dist/react-sdk/loading-state/index.js +1 -1
  538. package/dist/react-sdk/loading-state/{LoadingStateProvider.d.ts → loading-state-hooks.d.ts} +3 -12
  539. package/dist/react-sdk/loading-state/loading-state-hooks.js +1 -0
  540. package/dist/react-sdk/page-elements/ConfigurationNotFoundPagePropsProvider.d.ts +17 -0
  541. package/dist/react-sdk/page-elements/ConfigurationNotFoundPagePropsProvider.js +1 -0
  542. package/dist/react-sdk/page-elements/ConfiguratorErrorPagePropsProvider.d.ts +17 -0
  543. package/dist/react-sdk/page-elements/ConfiguratorErrorPagePropsProvider.js +1 -0
  544. package/dist/react-sdk/page-elements/configurator-error-page-props.d.ts +20 -0
  545. package/dist/react-sdk/page-elements/configurator-error-page-props.js +1 -0
  546. package/dist/react-sdk/page-elements/create-configuration-not-found-page.d.ts +15 -0
  547. package/dist/react-sdk/page-elements/create-configuration-not-found-page.js +1 -0
  548. package/dist/react-sdk/page-elements/create-configurator-error-page.d.ts +14 -0
  549. package/dist/react-sdk/page-elements/create-configurator-error-page.js +1 -0
  550. package/dist/react-sdk/page-elements/create-preset-ui.d.ts +9 -0
  551. package/dist/react-sdk/page-elements/create-preset-ui.js +1 -0
  552. package/dist/react-sdk/page-elements/index.d.ts +6 -0
  553. package/dist/react-sdk/page-elements/index.js +1 -0
  554. package/dist/react-sdk/scene-control-elements/AddEntityFabElement.d.ts +36 -0
  555. package/dist/react-sdk/scene-control-elements/AddEntityFabElement.js +1 -0
  556. package/dist/react-sdk/scene-control-elements/DeleteEntityFabElement.d.ts +44 -0
  557. package/dist/react-sdk/scene-control-elements/DeleteEntityFabElement.js +1 -0
  558. package/dist/react-sdk/scene-control-elements/NavigationHotspotElement.d.ts +38 -0
  559. package/dist/react-sdk/scene-control-elements/NavigationHotspotElement.js +1 -0
  560. package/dist/react-sdk/scene-control-elements/SelectionHotspotElement.d.ts +30 -0
  561. package/dist/react-sdk/scene-control-elements/SelectionHotspotElement.js +1 -0
  562. package/dist/react-sdk/scene-control-elements/ToolMenuFabElement.d.ts +30 -0
  563. package/dist/react-sdk/scene-control-elements/ToolMenuFabElement.js +1 -0
  564. package/dist/react-sdk/scene-control-elements/create-add-entity-fab.d.ts +9 -0
  565. package/dist/react-sdk/scene-control-elements/create-add-entity-fab.js +1 -0
  566. package/dist/react-sdk/scene-control-elements/create-delete-entity-fab.d.ts +9 -0
  567. package/dist/react-sdk/scene-control-elements/create-delete-entity-fab.js +1 -0
  568. package/dist/react-sdk/scene-control-elements/create-navigation-hotspot.d.ts +10 -0
  569. package/dist/react-sdk/scene-control-elements/create-navigation-hotspot.js +1 -0
  570. package/dist/react-sdk/scene-control-elements/create-selection-hotspot.d.ts +9 -0
  571. package/dist/react-sdk/scene-control-elements/create-selection-hotspot.js +1 -0
  572. package/dist/react-sdk/scene-control-elements/create-tool-menu-fab.d.ts +9 -0
  573. package/dist/react-sdk/scene-control-elements/create-tool-menu-fab.js +1 -0
  574. package/dist/react-sdk/scene-control-elements/index.d.ts +11 -0
  575. package/dist/react-sdk/scene-control-elements/index.js +1 -0
  576. package/dist/react-sdk/scene-control-elements/scene-control-tooltip-visibility.d.ts +5 -0
  577. package/dist/react-sdk/scene-control-elements/scene-control-tooltip-visibility.js +1 -0
  578. package/dist/react-sdk/session/SessionServiceProvider.js +1 -1
  579. package/dist/react-sdk/web-component/create-web-component.js +1 -1
  580. package/dist/react-sdk/web-component/god-mode/god-mode-registry.d.ts +30 -0
  581. package/dist/react-sdk/web-component/god-mode/god-mode-registry.js +1 -0
  582. package/dist/react-sdk/web-component/god-mode/index.d.ts +1 -0
  583. package/dist/react-sdk/web-component/god-mode/index.js +1 -0
  584. package/dist/ui-sdk/icons/__generated/Lightbulb.d.ts +3 -0
  585. package/dist/ui-sdk/icons/__generated/Lightbulb.js +1 -0
  586. package/dist/ui-sdk/icons/__generated/XCircle.d.ts +3 -0
  587. package/dist/ui-sdk/icons/__generated/XCircle.js +1 -0
  588. package/dist/ui-sdk/icons/__generated/index.d.ts +4 -0
  589. package/dist/ui-sdk/icons/__generated/index.js +1 -1
  590. package/dist/ui-sdk/icons/icons-registry.js +1 -1
  591. package/dist/ui-sdk/responsive-application/ResponsiveApplicationProvider.d.ts +9 -0
  592. package/dist/ui-sdk/responsive-application/ResponsiveApplicationProvider.js +1 -1
  593. package/dist/ui-sdk/responsive-application/index.d.ts +1 -0
  594. package/dist/ui-sdk/responsive-application/responsive-state-store.d.ts +59 -0
  595. package/dist/ui-sdk/responsive-application/responsive-state-store.js +1 -0
  596. package/dist/ui-sdk/responsive-application/use-store-responsive-info.d.ts +7 -0
  597. package/dist/ui-sdk/responsive-application/use-store-responsive-info.js +1 -0
  598. package/dist/ui-sdk/theme/types.d.ts +5 -1
  599. package/package.json +13 -3
  600. package/schema/domain-spec.json +140 -0
  601. package/dist/configurator/ui/assistant/form/built-in-form-registry.d.ts +0 -10
  602. package/dist/configurator/ui/assistant/form/built-in-form-registry.js +0 -1
  603. package/dist/configurator/ui/assistant/form/form-registry.js +0 -8
  604. package/dist/configurator/ui/phoenix-layout-elements/language/use-locale-options.d.ts +0 -4
  605. package/dist/configurator/ui/phoenix-layout-elements/language/use-locale-options.js +0 -1
  606. package/dist/configurator/ui/phoenix-layout-elements/measurements/use-measurements-visibility.d.ts +0 -4
  607. package/dist/configurator/ui/phoenix-layout-elements/measurements/use-measurements-visibility.js +0 -1
  608. package/dist/domain/application/assistant/assistant-form-elements.d.ts +0 -28
  609. package/dist/domain/application/assistant/assistant-form-elements.js +0 -1
  610. package/dist/domain/extensions/client-side-image.js +0 -1
  611. package/dist/domain/extensions/save-template-thumbnail.js +0 -1
  612. package/dist/react-sdk/application-runtime/runtime/god-mode.d.ts +0 -19
  613. package/dist/react-sdk/application-runtime/runtime/god-mode.js +0 -1
  614. package/dist/react-sdk/loading-state/LoadingStateProvider.js +0 -1
  615. package/dist/react-sdk/loading-state/loading-state-manager.d.ts +0 -32
  616. package/dist/react-sdk/loading-state/loading-state-manager.js +0 -1
  617. /package/dist/{configurator/ui/assistant/form/form-element-definition.js → react-sdk/application-runtime/host-properties/types.js} +0 -0
@@ -0,0 +1,3428 @@
1
+ # Corvid Drone
2
+
3
+ Build a pro tool in which an engineer configures a product and a client reads the result. A drone maker, Corvid Aero, has its engineers configure the K6 survey drone for each customer: the frame, the arm length, the landing gear, the propellers, the motors' anodising, the payload with its camera tilt, and the livery. The layout is a flight deck over a full-bleed scene: a rail of the spec's tabs, a translucent panel with the active tab's controls, a HUD with the price, the all-up weight and the mode, the spec's view controls and a log line for the configurator's messages. The client opens the same configuration from a page that sets the configurator read-only, and gets the same tabs as a spec sheet. The project writes the layout as one element; the spec holds the tabs, the controls and the tab each belongs to, the views, the looks and the rule between the winch and the frame.
4
+
5
+ - [Try it](#try-it)
6
+ - [What the example demonstrates](#what-the-example-demonstrates)
7
+ - [A deck over the scene](#a-deck-over-the-scene)
8
+ - [Tabs from the spec](#tabs-from-the-spec)
9
+ - [Controls that follow the tab](#controls-that-follow-the-tab)
10
+ - [Arm length and camera tilt](#arm-length-and-camera-tilt)
11
+ - [A target on the drone that opens a tab](#a-target-on-the-drone-that-opens-a-tab)
12
+ - [Messages in the HUD](#messages-in-the-hud)
13
+ - [The spec's views](#the-specs-views)
14
+ - [The client's spec sheet](#the-clients-spec-sheet)
15
+ - [The stage and the model](#the-stage-and-the-model)
16
+ - [On a phone](#on-a-phone)
17
+ - [Test cases](#test-cases)
18
+ - [What this layout leaves out](#what-this-layout-leaves-out)
19
+ - [Related pages](#related-pages)
20
+ - [Complete Implementation](#complete-implementation)
21
+
22
+ ## Try it
23
+
24
+ The client's view is under [The client's spec sheet](#the-clients-spec-sheet), the phone version under [On a phone](#on-a-phone).
25
+
26
+ ## What the example demonstrates
27
+
28
+ [Custom Layout](?path=/docs/layouts-custom-layout--docs) explains how a project layout works: the element name, the scene, the safe area and the hooks. This page is a finished design built the same way, for a product with tabs and two audiences, and it shows the pieces Custom Layout does not use:
29
+
30
+ | Piece | Where |
31
+ | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
32
+ | `phx.frontend.useTabNavigation()` for the spec's `sidebarTabs`, as a rail and as a bottom bar | `DroneTabs`, [Tabs from the spec](#tabs-from-the-spec) |
33
+ | Controls filtered to a tab with `$filter.navigationStates`, each checked with `phx.frontend.useComponent()` | `DroneFields`, [Controls that follow the tab](#controls-that-follow-the-tab) |
34
+ | `phx.frontend.useDistanceDimension()` and `useAngleDimension()`, staging with `update` and committing with `resolve` | `ArmLengthField`, `GimbalTiltField`, [Arm length and camera tilt](#arm-length-and-camera-tilt) |
35
+ | `phx.frontend.useAttributeSwitch()` for the landing gear | `GearField` |
36
+ | A `sceneEntityObjectHtmlHotspot` whose entity element switches the tab, hidden from a viewer with `$filter.modes` | `PayloadHotspot`, [A target on the drone](#a-target-on-the-drone-that-opens-a-tab) |
37
+ | `phx.frontend.useConfiguratorMessages()` as a HUD log line | `MessageLog`, [Messages in the HUD](#messages-in-the-hud) |
38
+ | `LayoutSlots.DesktopViewControls` and `MobileViewControls` for the spec's `viewControls`, with `useResetCamera()` | `ViewDock`, [The spec's views](#the-specs-views) |
39
+ | Read mode from the host's `readonly` property, read with `phx.frontend.useAccessService()`, in a **Read Mode** story | `mocks/viewer.yaml`, [The client's spec sheet](#the-clients-spec-sheet) |
40
+ | A morph target, an object group and `sceneEntityObjectLocalMaterialOverride`, on a `canvasCustomEnvironment` stage | `DroneHangar`, [The stage and the model](#the-stage-and-the-model) |
41
+
42
+ ## A deck over the scene
43
+
44
+ `src/elements/ConfiguratorLayout.tsx` keeps the scene mounted and lays the desktop or the phone deck over it. `phx.frontend.useActiveViewports()` tells them apart by the width of the configurator's container, not the window.
45
+
46
+ ```tsx
47
+ import { Box } from '@mui/material'
48
+
49
+ import { LayoutSlots } from '@salsita-ai/sdk/configurator'
50
+
51
+ import { phx } from '../../.phoenix/phx.js'
52
+ import { DesktopDeck } from '../_shared/DesktopDeck.js'
53
+ import { PhoneDeck } from '../_shared/PhoneDeck.js'
54
+ import { CORVID } from '../_shared/tokens.js'
55
+
56
+ // A pro tool over a full-bleed scene: the spec's tabs, a panel with the active tab's controls, a HUD with the
57
+ // readouts, and the spec's view controls. The canvas stays mounted; what lies over it is the desktop or the phone
58
+ // deck, and each calls `useSceneSafeArea` itself, so only the one on screen tells the camera where the drone goes.
59
+ // The layout shows messages in its own log line, so it does not mount `LayoutSlots.Services`.
60
+ const ConfiguratorLayout = () => {
61
+ // `null` until the container has been measured; only the scene shows until then
62
+ const viewports = phx.frontend.useActiveViewports()
63
+ const isDesktop = viewports?.includes('desktop') ?? false
64
+
65
+ return (
66
+ <Box
67
+ component="div"
68
+ sx={{ position: 'absolute', inset: 0, overflow: 'clip', bgcolor: CORVID.graphite, color: CORVID.ink }}
69
+ >
70
+ <LayoutSlots.SharedCanvas />
71
+ {viewports !== null && (isDesktop ? <DesktopDeck /> : <PhoneDeck />)}
72
+ </Box>
73
+ )
74
+ }
75
+
76
+ export default phx.frontend.createCustomPhoenixLayoutElement('ConfiguratorLayout', ConfiguratorLayout)
77
+ ```
78
+
79
+ - **The scene is full-bleed.** The canvas fills the configurator, and the rail, the panel and the HUD lie over it. The panel is translucent with a blur, so the hangar shows through it.
80
+ - **Each deck measures its own safe area.** `DesktopDeck` attaches `safeAreaRef` to a box in the free area right of the panel, below the HUD and above the dock; `PhoneDeck` to the scene above the sheet. The camera frames the drone in that box, so the drone never sits under the panel.
81
+ - **Fullscreen is a preview.** `phx.frontend.useIsFullscreenActive()` removes the rail, the panel, the HUD and the log; the dock with the camera reset and the exit stays, and on a desktop the view controls with it, as [Interaction Modes → Full-screen preview](?path=/docs/ux-guidelines-interaction-modes--docs#full-screen-preview) recommends. The fullscreen button records `FrontendAnalyticsFactory.FullScreenModeToggled()` through `phx.frontend.useAnalytics()`, like the built-in one.
82
+
83
+ ## Tabs from the spec
84
+
85
+ The tabs are product structure, so they are in the spec: one `sidebarTabs` component on the drone, a navigation state per tab and an icon from the PhxIcon set.
86
+
87
+ ```yaml
88
+ navigationStates:
89
+ - airframe
90
+ - propulsion
91
+ - payload
92
+ - livery
93
+ ```
94
+
95
+ ```yaml
96
+ components:
97
+ Drone:
98
+ sidebarTabs:
99
+ main:
100
+ tabs:
101
+ - label: Airframe
102
+ icon: Cube
103
+ navigationStates: [airframe]
104
+ - label: Propulsion
105
+ icon: ArrowClockwise
106
+ navigationStates: [propulsion]
107
+ - label: Payload
108
+ icon: Scan
109
+ navigationStates: [payload]
110
+ - label: Livery
111
+ icon: PaletteBold
112
+ navigationStates: [livery]
113
+ ```
114
+
115
+ `DroneTabs` draws them with `phx.frontend.useTabNavigation()`: a rail of icons on a desktop, a segmented bar on a phone. Each tab comes with its resolved `label`, the spec's `icon`, which `PhxIcon` from `@salsita-ai/sdk/ui` renders, and `isActive`; `select(index)` enters the tab's first navigation state, as the built-in tabs do. The list is a `tablist` whose tabs control the panel, and the arrow keys move between them.
116
+
117
+ ```tsx
118
+ import { Box, ButtonBase, Typography } from '@mui/material'
119
+ import type { KeyboardEvent } from 'react'
120
+
121
+ import { PhxIcon } from '@salsita-ai/sdk/ui'
122
+
123
+ import { phx } from '../../.phoenix/phx.js'
124
+ import { CORVID } from './tokens.js'
125
+
126
+ // The panel the tabs control; `TabPanel` carries this id
127
+ export const TAB_PANEL_ID = 'corvid-tab-panel'
128
+
129
+ interface DroneTabsProps {
130
+ readonly orientation: 'vertical' | 'horizontal'
131
+ }
132
+
133
+ // The spec's `sidebarTabs` as a tab list: a rail of icons on a desktop, a segmented bar at the bottom of a phone.
134
+ // `useTabNavigation` gives each tab its resolved label, the PhxIcon name the spec sets and `isActive`; `select` enters
135
+ // the tab's navigation state, and the controls the spec filters to that state become active. Switching tabs is
136
+ // navigation, not editing, so a viewer switches too.
137
+ export const DroneTabs = ({ orientation }: DroneTabsProps) => {
138
+ const { tabs, select } = phx.frontend.useTabNavigation()
139
+ const vertical = orientation === 'vertical'
140
+ const nextKey = vertical ? 'ArrowDown' : 'ArrowRight'
141
+ const previousKey = vertical ? 'ArrowUp' : 'ArrowLeft'
142
+
143
+ // The arrow keys move between the tabs and enter the one they reach, as in a native tab list
144
+ const handleKeyDown = (event: KeyboardEvent<HTMLElement>, index: number) => {
145
+ const step = event.key === nextKey ? 1 : event.key === previousKey ? -1 : 0
146
+
147
+ if (step === 0) {
148
+ return
149
+ }
150
+
151
+ event.preventDefault()
152
+ const next = (index + step + tabs.length) % tabs.length
153
+ select(next)
154
+ event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')[next]?.focus()
155
+ }
156
+
157
+ return (
158
+ <Box
159
+ component="div"
160
+ role="tablist"
161
+ aria-label="Configuration"
162
+ aria-orientation={orientation}
163
+ sx={{ display: 'flex', flexDirection: vertical ? 'column' : 'row', gap: vertical ? 0.5 : 0, blockSize: '100%' }}
164
+ >
165
+ {tabs.map(tab => (
166
+ <ButtonBase
167
+ key={tab.state}
168
+ role="tab"
169
+ id={`corvid-tab-${tab.index}`}
170
+ aria-selected={tab.isActive}
171
+ aria-controls={TAB_PANEL_ID}
172
+ tabIndex={tab.isActive ? 0 : -1}
173
+ onClick={() => select(tab.index)}
174
+ onKeyDown={event => handleKeyDown(event, tab.index)}
175
+ sx={{
176
+ position: 'relative',
177
+ flex: vertical ? 'none' : '1 1 0',
178
+ display: 'flex',
179
+ flexDirection: 'column',
180
+ alignItems: 'center',
181
+ justifyContent: 'center',
182
+ gap: 0.75,
183
+ minBlockSize: vertical ? 68 : 'auto',
184
+ color: tab.isActive ? CORVID.lime : CORVID.muted,
185
+ bgcolor: tab.isActive ? 'rgba(198, 244, 50, 0.07)' : 'transparent',
186
+ transition: 'color 160ms, background-color 160ms',
187
+ // The active tab's marker: a bar on the rail's outer edge, or along the top of the bar's segment
188
+ '&::before': {
189
+ content: '""',
190
+ position: 'absolute',
191
+ insetBlock: vertical ? '14px' : '0 auto',
192
+ insetInlineStart: vertical ? 0 : '18%',
193
+ insetInlineEnd: vertical ? 'auto' : '18%',
194
+ inlineSize: vertical ? 2 : 'auto',
195
+ blockSize: vertical ? 'auto' : 2,
196
+ bgcolor: CORVID.lime,
197
+ opacity: tab.isActive ? 1 : 0,
198
+ transition: 'opacity 160ms',
199
+ },
200
+ '&:hover': { color: tab.isActive ? CORVID.lime : CORVID.ink },
201
+ '&.Mui-focusVisible': { outline: `1px solid ${CORVID.lime}`, outlineOffset: -4 },
202
+ }}
203
+ >
204
+ {tab.icon !== undefined && <PhxIcon name={tab.icon} fontSize="medium" />}
205
+ <Typography
206
+ component="span"
207
+ sx={{ fontFamily: CORVID.mono, fontSize: 9.5, letterSpacing: '0.14em', textTransform: 'uppercase' }}
208
+ >
209
+ {tab.label}
210
+ </Typography>
211
+ </ButtonBase>
212
+ ))}
213
+ </Box>
214
+ )
215
+ }
216
+ ```
217
+
218
+ The panel's heading is the active tab's label, from the same hook in `TabPanel`. Use `useTabNavigation()` rather than `LayoutSlots.useSidebarTabs()` in a layout of your own: it is a member of `phx.frontend`, reads `horizontalTabs` too, and gives each tab its navigation states.
219
+
220
+ ## Controls that follow the tab
221
+
222
+ Each control component carries the tab it belongs to as a filter, so it is active only while its tab is:
223
+
224
+ ```yaml
225
+ components:
226
+ Drone:
227
+ attributeSelection:
228
+ frame:
229
+ value:
230
+ $bind: frame
231
+ options:
232
+ - label: Quad
233
+ description: Four motors, the lightest and longest-flying
234
+ value: Quad
235
+ - label: Hexa
236
+ description: Six motors; lands safely with one motor out
237
+ value: Hexa
238
+ $filter:
239
+ navigationStates: [airframe]
240
+ ```
241
+
242
+ The layout lists every control once, in the order the panel shows them, and each renders only while its component is active. Which tab shows which control is then decided by the spec alone: moving `props` to the Airframe tab is a change of its filter, not of the layout.
243
+
244
+ ```tsx
245
+ import { Box } from '@mui/material'
246
+
247
+ import { ArmLengthField } from './ArmLengthField.js'
248
+ import { GearField } from './GearField.js'
249
+ import { GimbalTiltField } from './GimbalTiltField.js'
250
+ import { OptionField } from './OptionField.js'
251
+ import { FRAME_VISUALS, LIVERY_VISUALS, MOTOR_VISUALS, PAYLOAD_VISUALS, PROP_VISUALS } from './visuals.js'
252
+
253
+ interface DroneFieldsProps {
254
+ readonly readOnly: boolean
255
+ }
256
+
257
+ // Every control of the spec, in the order the panel shows them. Each one renders only while its spec component is
258
+ // active, so the spec's `$filter.navigationStates` decides which tab shows it and `$filter.active` hides the tilt
259
+ // without the camera. The labels are the layout's: `attributeSelection` and the dimensions carry none.
260
+ export const DroneFields = ({ readOnly }: DroneFieldsProps) => {
261
+ return (
262
+ <Box component="div" sx={{ display: 'grid', gap: readOnly ? 1.5 : 3 }}>
263
+ <OptionField instanceName="frame" label="Frame" visuals={FRAME_VISUALS} readOnly={readOnly} />
264
+ <ArmLengthField readOnly={readOnly} />
265
+ <GearField readOnly={readOnly} />
266
+ <OptionField instanceName="props" label="Propellers" visuals={PROP_VISUALS} readOnly={readOnly} />
267
+ <OptionField instanceName="motorColor" label="Motor anodising" visuals={MOTOR_VISUALS} readOnly={readOnly} />
268
+ <OptionField instanceName="payload" label="Payload" visuals={PAYLOAD_VISUALS} readOnly={readOnly} />
269
+ <GimbalTiltField readOnly={readOnly} />
270
+ <OptionField instanceName="livery" label="Livery" visuals={LIVERY_VISUALS} readOnly={readOnly} />
271
+ </Box>
272
+ )
273
+ }
274
+ ```
275
+
276
+ The control hooks throw for a component that a `$filter` makes inactive, so every field first asks `phx.frontend.useComponent({ entityId, componentType, instanceName })` for its component, with the component type from `@salsita-ai/sdk/domain`, and renders nothing while it gets `null`. `OptionField` does this for each `attributeSelection`, then draws its options as tiles: the layout's drawing or colour chip per option `value`, the spec's `label` as the title and the chosen option's `description` below. An option an invariant rules out is hatched; see [Messages in the HUD](#messages-in-the-hud).
277
+
278
+ The landing gear is an `attributeSwitch`: `GearField` calls `phx.frontend.useAttributeSwitch(droneId, 'retractableGear')` and gives `state.value` and `onChange()` to a switch.
279
+
280
+ ## Arm length and camera tilt
281
+
282
+ The arm length is a `distanceDimension` in millimetres, the data unit, and the camera's tilt an `angleDimension`, which the spec stores in radians and shows in degrees. The tilt is filtered twice: to the Payload tab, and to a drone that carries the camera.
283
+
284
+ ```yaml
285
+ components:
286
+ Drone:
287
+ distanceDimension:
288
+ armLength:
289
+ value:
290
+ $bind: armLength
291
+ min: 280
292
+ max: 380
293
+ $filter:
294
+ navigationStates: [airframe]
295
+ angleDimension:
296
+ gimbalTilt:
297
+ value:
298
+ $bind: gimbalTilt
299
+ min: 0
300
+ max: 1.5707963268
301
+ unit: degree
302
+ decimalPlaces: 0
303
+ # Only the camera tilts
304
+ $filter:
305
+ navigationStates: [payload]
306
+ active:
307
+ $eval: self.payload === 'Camera'
308
+ ```
309
+
310
+ `phx.frontend.useDistanceDimension(droneId, 'armLength')` returns the value in the display unit with `valueWithUnit`, the range, `formatValue()` for any other length, and two functions: `update(value)` stages a value, which the scene shows at once, and `resolve()` commits it, or rolls it back when it breaks an invariant. `HudSlider` calls `update` from the slider's `onChange` and `resolve` from `onChangeCommitted`, so the arms grow under the thumb and the change is committed, and saved, once the thumb is let go.
311
+
312
+ ```tsx
313
+ import { distanceDimensionComponent } from '@salsita-ai/sdk/domain'
314
+
315
+ import { phx } from '../../.phoenix/phx.js'
316
+ import { HudSlider } from './HudSlider.js'
317
+ import { SpecRow } from './SpecRow.js'
318
+
319
+ const STEP = 10
320
+
321
+ interface ArmLengthFieldProps {
322
+ readonly readOnly: boolean
323
+ }
324
+
325
+ const ArmLengthControl = ({ readOnly }: ArmLengthFieldProps) => {
326
+ const droneId = phx.frontend.useRootEntityId()
327
+ const arm = phx.frontend.useDistanceDimension(droneId, 'armLength')
328
+ // Motor to motor across the drone: twice the arm, which is measured from the centre
329
+ const span = `Motor to motor ${arm.formatValue(arm.value * 2, true)}`
330
+
331
+ if (readOnly) {
332
+ return <SpecRow label="Arm length" value={arm.valueWithUnit} detail={span} />
333
+ }
334
+
335
+ return (
336
+ <HudSlider
337
+ id="corvid-arm-length"
338
+ label="Arm length"
339
+ valueText={arm.valueWithUnit}
340
+ value={arm.value}
341
+ min={arm.min}
342
+ max={arm.max}
343
+ step={STEP}
344
+ minLabel={arm.formatValue(arm.min, true)}
345
+ maxLabel={arm.formatValue(arm.max, true)}
346
+ detail={span}
347
+ onChange={arm.update}
348
+ onCommit={arm.resolve}
349
+ />
350
+ )
351
+ }
352
+
353
+ // The spec's `distanceDimension` for the arm length, in the display unit. Moving the slider stages each value with
354
+ // `update`, so the arms grow under the thumb through the spec's morph target, and letting go commits it with
355
+ // `resolve`. The field exists only while the spec's component is active.
356
+ export const ArmLengthField = (props: ArmLengthFieldProps) => {
357
+ const droneId = phx.frontend.useRootEntityId()
358
+ const component = phx.frontend.useComponent({
359
+ entityId: droneId,
360
+ componentType: distanceDimensionComponent,
361
+ instanceName: 'armLength',
362
+ })
363
+
364
+ return component === null ? null : <ArmLengthControl {...props} />
365
+ }
366
+ ```
367
+
368
+ `phx.frontend.useAngleDimension(droneId, 'gimbalTilt')` works the same way in the spec's `unit`: `value`, `min` and `max` in degrees, `unitLabel` "°", and `update` in degrees. `GimbalTiltField` adds a quarter dial that shows the camera's line of sight.
369
+
370
+ ```tsx
371
+ import { angleDimensionComponent } from '@salsita-ai/sdk/domain'
372
+
373
+ import { phx } from '../../.phoenix/phx.js'
374
+ import { CORVID } from '../_shared/tokens.js'
375
+ import { HudSlider } from './HudSlider.js'
376
+ import { SpecRow } from './SpecRow.js'
377
+
378
+ interface TiltDialProps {
379
+ readonly degrees: number
380
+ }
381
+
382
+ // A quarter dial from the horizon down to the ground, with the camera's line of sight on it
383
+ const TiltDial = ({ degrees }: TiltDialProps) => {
384
+ const radians = (degrees * Math.PI) / 180
385
+ const x = 6 + 40 * Math.cos(radians)
386
+ const y = 6 + 40 * Math.sin(radians)
387
+
388
+ return (
389
+ <svg viewBox="0 0 52 52" width={52} height={52} aria-hidden fill="none">
390
+ <path d="M6 6h44M6 6v44" stroke={CORVID.line} />
391
+ <path d="M46 6A40 40 0 0 1 6 46" stroke={CORVID.faint} strokeDasharray="2 3" />
392
+ <path d={`M6 6L${x} ${y}`} stroke={CORVID.lime} strokeWidth={1.6} />
393
+ <circle cx={x} cy={y} r={2.5} fill={CORVID.lime} />
394
+ <rect x={2} y={2} width={8} height={8} fill={CORVID.graphite} stroke={CORVID.lime} />
395
+ </svg>
396
+ )
397
+ }
398
+
399
+ const describe = (degrees: number) => {
400
+ if (degrees <= 0) {
401
+ return 'Level with the horizon, for inspection'
402
+ }
403
+
404
+ if (degrees >= 90) {
405
+ return 'Straight down, for mapping'
406
+ }
407
+
408
+ return `${degrees}° below the horizon`
409
+ }
410
+
411
+ interface GimbalTiltFieldProps {
412
+ readonly readOnly: boolean
413
+ }
414
+
415
+ const GimbalTiltControl = ({ readOnly }: GimbalTiltFieldProps) => {
416
+ const droneId = phx.frontend.useRootEntityId()
417
+ const tilt = phx.frontend.useAngleDimension(droneId, 'gimbalTilt')
418
+ const valueText = `${tilt.value}${tilt.unitLabel}`
419
+ const min = tilt.min ?? 0
420
+ const max = tilt.max ?? 90
421
+
422
+ if (readOnly) {
423
+ return <SpecRow label="Gimbal tilt" value={valueText} detail={describe(tilt.value)} />
424
+ }
425
+
426
+ return (
427
+ <HudSlider
428
+ id="corvid-gimbal-tilt"
429
+ label="Gimbal tilt"
430
+ valueText={valueText}
431
+ value={tilt.value}
432
+ min={min}
433
+ max={max}
434
+ step={1}
435
+ minLabel={`${min}${tilt.unitLabel} HORIZON`}
436
+ maxLabel={`${max}${tilt.unitLabel} NADIR`}
437
+ detail={describe(tilt.value)}
438
+ adornment={<TiltDial degrees={tilt.value} />}
439
+ onChange={tilt.update}
440
+ onCommit={tilt.resolve}
441
+ />
442
+ )
443
+ }
444
+
445
+ // The spec's `angleDimension` for the camera's tilt, in degrees as the spec's `unit` asks; the spec stores radians and
446
+ // turns the gimbal's pivot group by them. The slider stages with `update` and commits with `resolve`. The spec makes
447
+ // the component active only on the Payload tab and only while the camera is fitted, so the field checks first.
448
+ export const GimbalTiltField = (props: GimbalTiltFieldProps) => {
449
+ const droneId = phx.frontend.useRootEntityId()
450
+ const component = phx.frontend.useComponent({
451
+ entityId: droneId,
452
+ componentType: angleDimensionComponent,
453
+ instanceName: 'gimbalTilt',
454
+ })
455
+
456
+ return component === null ? null : <GimbalTiltControl {...props} />
457
+ }
458
+ ```
459
+
460
+ ## A target on the drone that opens a tab
461
+
462
+ A `sceneEntityObjectHtmlHotspot` pins an element to one of the entity's objects, here the payload mount, and moves with it. Its `name` is an entity element of the project. The filter shows it only to the engineer, only outside the Payload tab and not in fullscreen.
463
+
464
+ ```yaml
465
+ components:
466
+ Drone:
467
+ sceneEntityObjectHtmlHotspot:
468
+ payloadMount:
469
+ target: payloadMount
470
+ name: PayloadHotspot
471
+ $filter:
472
+ modes: [owner]
473
+ navigationStates: [airframe, propulsion, livery]
474
+ fullscreen: false
475
+ ```
476
+
477
+ `PayloadHotspot` renders a target and a label, and calls `select('payload')` of `phx.frontend.useTabNavigation()`. The tab then becomes active, the panel shows the payload controls, and the target disappears, because its filter leaves out the Payload tab.
478
+
479
+ ```tsx
480
+ import { Box, ButtonBase } from '@mui/material'
481
+
482
+ import { phx } from '../../.phoenix/phx.js'
483
+ import { CORVID } from '../_shared/tokens.js'
484
+
485
+ // Rendered by the spec's `sceneEntityObjectHtmlHotspot` on the payload mount: a target on the drone that opens the
486
+ // Payload tab. The spec's `$filter` shows it only to the engineer and only outside that tab.
487
+ export default phx.frontend.createCustomPhoenixEntityElement('PayloadHotspot', () => {
488
+ const { select } = phx.frontend.useTabNavigation()
489
+
490
+ return (
491
+ <ButtonBase
492
+ aria-label="Payload: open the Payload tab"
493
+ onClick={() => select('payload')}
494
+ sx={{
495
+ display: 'flex',
496
+ alignItems: 'center',
497
+ gap: 1,
498
+ // The target's centre sits on the mount; the label hangs to its right
499
+ transform: 'translate(-14px, 0)',
500
+ color: CORVID.lime,
501
+ '&:hover .corvid-hotspot-label, &.Mui-focusVisible .corvid-hotspot-label': {
502
+ bgcolor: CORVID.lime,
503
+ color: CORVID.graphite,
504
+ },
505
+ }}
506
+ >
507
+ <Box
508
+ component="span"
509
+ aria-hidden
510
+ sx={{
511
+ position: 'relative',
512
+ display: 'grid',
513
+ placeItems: 'center',
514
+ inlineSize: 28,
515
+ blockSize: 28,
516
+ // A ring that keeps widening and fading, so the target reads as live
517
+ '&::after': {
518
+ content: '""',
519
+ position: 'absolute',
520
+ inset: 0,
521
+ borderRadius: '50%',
522
+ border: `1px solid ${CORVID.lime}`,
523
+ animation: 'corvid-ping 2s ease-out infinite',
524
+ },
525
+ '@keyframes corvid-ping': {
526
+ from: { transform: 'scale(0.6)', opacity: 0.9 },
527
+ to: { transform: 'scale(1.6)', opacity: 0 },
528
+ },
529
+ }}
530
+ >
531
+ <svg viewBox="0 0 28 28" width={28} height={28} fill="none" stroke="currentColor" strokeWidth={1.4}>
532
+ <circle cx={14} cy={14} r={8} fill="rgba(20, 23, 28, 0.6)" />
533
+ <path d="M14 2v6M14 20v6M2 14h6M20 14h6" />
534
+ <circle cx={14} cy={14} r={1.6} fill="currentColor" />
535
+ </svg>
536
+ </Box>
537
+ <Box
538
+ component="span"
539
+ className="corvid-hotspot-label"
540
+ sx={{
541
+ paddingInline: 1,
542
+ paddingBlock: 0.5,
543
+ fontFamily: CORVID.mono,
544
+ fontSize: 10.5,
545
+ letterSpacing: '0.16em',
546
+ whiteSpace: 'nowrap',
547
+ color: CORVID.lime,
548
+ bgcolor: 'rgba(20, 23, 28, 0.8)',
549
+ border: `1px solid ${CORVID.lime}`,
550
+ transition: 'background-color 160ms, color 160ms',
551
+ }}
552
+ >
553
+ PAYLOAD ›
554
+ </Box>
555
+ </ButtonBase>
556
+ )
557
+ })
558
+ ```
559
+
560
+ The hotspot sits at the origin of the object named by `target`. The model gives the mount's node its origin on the mount, and the loader places every part at the scene's origin, so the spec moves the mount back with `offsetZ: 90` in `sceneEntityObjectProperties`; see [The stage and the model](#the-stage-and-the-model).
561
+
562
+ The hotspot's own `offsetX`, `offsetY` and `offsetZ` are applied inside the object, in the unit of its model: on this model in metres, so `offsetY: 0.15` lifts the target 150 mm and `offsetY: 150` sends it 150 m away. Giving the node its origin where the target belongs, as here, needs no offset.
563
+
564
+ ## Messages in the HUD
565
+
566
+ The delivery winch needs six motors. The rule is a `fixable` invariant, whose condition is in `src/invariants/drone.ts`:
567
+
568
+ ```yaml
569
+ invariants:
570
+ Drone:
571
+ winchNeedsHexa:
572
+ type: winchNeedsHexa
573
+ severity: fixable
574
+ message: The delivery winch needs the hexa frame to lift its load.
575
+ ```
576
+
577
+ On a quad the winch option comes back `disabled` with the message as its `hint`, and on a winch drone the quad option does. `OptionField` hatches the option and shows the hint below the tiles, in orange. The tile stays clickable: choosing it anyway makes the change, the invariant rejects it, and the configurator reports the rejection as a message. Choosing the LiDAR carries a hint from the `lidar-base-station` transformer, which is a message too.
578
+
579
+ `MessageLog` shows them as the HUD's log line. `phx.frontend.useConfiguratorMessages(onMessage)` calls it once per message with its text resolved: a `violation` with the invariant's `message` and its `severity`, a `sideEffect` with the transformer's hint, and the errors and save failures. The line tags each by kind and returns to **READY** after a few seconds. The layout mounts no `LayoutSlots.Services`, so the messages are not shown twice as snackbars; see [Custom Layout → Show messages](?path=/docs/layouts-custom-layout--docs#show-messages).
580
+
581
+ ```tsx
582
+ import { Box } from '@mui/material'
583
+ import { useEffect, useState } from 'react'
584
+
585
+ import type { ConfiguratorMessage } from '@salsita-ai/sdk/react'
586
+
587
+ import { phx } from '../../.phoenix/phx.js'
588
+ import { CORVID, GLASS_SX } from './tokens.js'
589
+
590
+ const VISIBLE_MS = 9000
591
+
592
+ interface LogEntry {
593
+ readonly message: ConfiguratorMessage
594
+ readonly time: string
595
+ }
596
+
597
+ // The tag before each line, by the kind of message and, for a violation, its severity
598
+ const tagOf = (message: ConfiguratorMessage) => {
599
+ if (message.kind === 'violation') {
600
+ return message.violation.severity === 'fixable' || message.violation.severity === 'fatal'
601
+ ? { label: 'REJECTED', color: CORVID.orange }
602
+ : { label: message.violation.severity.toUpperCase(), color: CORVID.cyan }
603
+ }
604
+
605
+ if (message.kind === 'sideEffect') {
606
+ return { label: 'NOTE', color: CORVID.cyan }
607
+ }
608
+
609
+ if (message.kind === 'saveRecovery') {
610
+ return { label: 'SAVED', color: CORVID.lime }
611
+ }
612
+
613
+ return { label: 'ERROR', color: CORVID.orange }
614
+ }
615
+
616
+ interface MessageLogProps {
617
+ readonly compact: boolean
618
+ }
619
+
620
+ // The configurator's messages as the HUD's log line: a rejected change, the hint a change carries, a failed save.
621
+ // `useConfiguratorMessages` hands each one over with its text resolved; the line shows the latest for a few seconds,
622
+ // then returns to its idle state.
623
+ export const MessageLog = ({ compact }: MessageLogProps) => {
624
+ const [entry, setEntry] = useState<LogEntry | null>(null)
625
+ const { accessInfo } = phx.frontend.useAccessService()
626
+
627
+ phx.frontend.useConfiguratorMessages(message => {
628
+ setEntry({ message, time: new Date().toLocaleTimeString('en-GB') })
629
+ })
630
+
631
+ useEffect(() => {
632
+ if (entry === null) {
633
+ return
634
+ }
635
+
636
+ const timeout = setTimeout(() => setEntry(null), VISIBLE_MS)
637
+
638
+ return () => clearTimeout(timeout)
639
+ }, [entry])
640
+
641
+ const tag = entry ? tagOf(entry.message) : { label: 'READY', color: CORVID.faint }
642
+ const idle = accessInfo.viewMode ? 'Viewing a shared configuration' : 'Every change saves on its own'
643
+
644
+ return (
645
+ <Box
646
+ component="div"
647
+ role="status"
648
+ aria-live="polite"
649
+ sx={{
650
+ ...GLASS_SX,
651
+ display: 'flex',
652
+ alignItems: 'baseline',
653
+ gap: compact ? 1 : 1.5,
654
+ minInlineSize: 0,
655
+ maxInlineSize: compact ? 'none' : 560,
656
+ paddingInline: 1.5,
657
+ paddingBlock: 1,
658
+ fontFamily: CORVID.mono,
659
+ fontSize: compact ? 11 : 12,
660
+ lineHeight: 1.5,
661
+ }}
662
+ >
663
+ <Box component="span" aria-hidden sx={{ color: CORVID.lime }}>
664
+ &gt;
665
+ </Box>
666
+ {entry && !compact && (
667
+ <Box component="span" sx={{ color: CORVID.faint }}>
668
+ {entry.time}
669
+ </Box>
670
+ )}
671
+ <Box component="span" sx={{ flex: 'none', color: tag.color, letterSpacing: '0.12em' }}>
672
+ {tag.label}
673
+ </Box>
674
+ <Box component="span" sx={{ minInlineSize: 0, color: entry ? CORVID.ink : CORVID.muted }}>
675
+ {entry ? entry.message.text : idle}
676
+ </Box>
677
+ </Box>
678
+ )
679
+ }
680
+ ```
681
+
682
+ ## The spec's views
683
+
684
+ Three views, each with its own camera filtered by `viewStates`: an orbit around the drone, a top view and a low view of the payload. The spec's `viewControls` list them with a label and an icon.
685
+
686
+ ```yaml
687
+ viewStates:
688
+ - orbit
689
+ - top
690
+ - payloadView
691
+ ```
692
+
693
+ ```yaml
694
+ components:
695
+ Drone:
696
+ viewControls:
697
+ main:
698
+ items:
699
+ - label: Orbit
700
+ value: orbit
701
+ icon: CameraRotate
702
+ - label: Top
703
+ value: top
704
+ icon: GridFour
705
+ - label: Payload
706
+ value: payloadView
707
+ icon: Scan
708
+ ```
709
+
710
+ `ViewDock` mounts the built-in controls for them: `LayoutSlots.DesktopViewControls`, a row of toggles, on a desktop, and `LayoutSlots.MobileViewControls`, one button that opens the list, on a phone. Both read the component and set the view state themselves, so the dock needs no state of its own. The built-in controls draw with the MUI theme, so the dock wraps them in a `ThemeProvider` with a dark palette, which also reaches the list a phone opens in a popper, and restyles the toggles through their MUI classes, `toggleButtonGroupClasses` and `toggleButtonClasses` from `@mui/material`. It adds the camera reset from `phx.frontend.useResetCamera()` and the fullscreen toggle. The HUD names the active view with `phx.frontend.useActiveViewState()` and the label of the matching item.
711
+
712
+ ```tsx
713
+ import {
714
+ Box,
715
+ Divider,
716
+ IconButton,
717
+ ThemeProvider,
718
+ Tooltip,
719
+ createTheme,
720
+ toggleButtonClasses,
721
+ toggleButtonGroupClasses,
722
+ } from '@mui/material'
723
+ import type { Theme } from '@mui/material'
724
+
725
+ import { FrontendAnalyticsFactory, LayoutSlots } from '@salsita-ai/sdk/configurator'
726
+ import { PhxIcon } from '@salsita-ai/sdk/ui'
727
+
728
+ import { phx } from '../../.phoenix/phx.js'
729
+ import { CORVID, GLASS_SX } from './tokens.js'
730
+
731
+ // The built-in view controls draw with the MUI theme's palette. A dark palette makes them part of the deck, and with
732
+ // them the list of views a phone opens, which renders in a popper outside the dock.
733
+ const deckTheme = (outer: Theme) =>
734
+ createTheme(outer, {
735
+ palette: {
736
+ mode: 'dark',
737
+ primary: { main: CORVID.lime },
738
+ text: { primary: CORVID.ink, secondary: CORVID.muted },
739
+ background: { paper: CORVID.panelSolid },
740
+ divider: CORVID.line,
741
+ action: { hover: 'rgba(232, 237, 242, 0.06)' },
742
+ },
743
+ })
744
+
745
+ interface DockButtonProps {
746
+ readonly label: string
747
+ readonly icon: string
748
+ readonly onClick: () => void
749
+ }
750
+
751
+ const DockButton = ({ label, icon, onClick }: DockButtonProps) => {
752
+ return (
753
+ <Tooltip title={label}>
754
+ <IconButton
755
+ aria-label={label}
756
+ onClick={onClick}
757
+ sx={{
758
+ inlineSize: 36,
759
+ blockSize: 36,
760
+ borderRadius: 0,
761
+ color: CORVID.muted,
762
+ '&:hover': { color: CORVID.lime, bgcolor: 'rgba(198, 244, 50, 0.08)' },
763
+ }}
764
+ >
765
+ <PhxIcon name={icon} />
766
+ </IconButton>
767
+ </Tooltip>
768
+ )
769
+ }
770
+
771
+ interface ViewDockProps {
772
+ readonly placement: 'desktop' | 'phone'
773
+ }
774
+
775
+ // The spec's view controls, mounted from `LayoutSlots`, with the camera reset and fullscreen beside them. On a
776
+ // desktop `DesktopViewControls` is a row of toggles; on a phone `MobileViewControls` is one button that opens the
777
+ // list of views, left out in fullscreen as the UX guidelines ask. The dock gives the built-in controls a dark theme
778
+ // and restyles the toggles through their MUI classes.
779
+ export const ViewDock = ({ placement }: ViewDockProps) => {
780
+ const resetCamera = phx.frontend.useResetCamera()
781
+ const isFullscreen = phx.frontend.useIsFullscreenActive()
782
+ const setFullscreen = phx.frontend.useSetFullscreen()
783
+ const analytics = phx.frontend.useAnalytics()
784
+
785
+ // The same event the built-in fullscreen button records, for entering and for leaving
786
+ const toggleFullscreen = () => {
787
+ analytics.push(FrontendAnalyticsFactory.FullScreenModeToggled())
788
+ setFullscreen(!isFullscreen)
789
+ }
790
+
791
+ return (
792
+ <ThemeProvider theme={deckTheme}>
793
+ <Box
794
+ component="div"
795
+ role="toolbar"
796
+ aria-label="View"
797
+ sx={{
798
+ ...GLASS_SX,
799
+ display: 'flex',
800
+ alignItems: 'center',
801
+ gap: 0.5,
802
+ padding: 0.5,
803
+ color: CORVID.ink,
804
+ [`& .${toggleButtonGroupClasses.root}`]: {
805
+ gap: 0.5,
806
+ padding: 0,
807
+ bgcolor: 'transparent',
808
+ boxShadow: 'none',
809
+ borderRadius: 0,
810
+ },
811
+ [`& .${toggleButtonClasses.root}`]: {
812
+ gap: 1,
813
+ minBlockSize: 36,
814
+ paddingInline: 1.5,
815
+ border: 0,
816
+ borderRadius: 0,
817
+ color: CORVID.muted,
818
+ '& .MuiTypography-root': {
819
+ fontFamily: CORVID.mono,
820
+ fontSize: 11,
821
+ letterSpacing: '0.14em',
822
+ textTransform: 'uppercase',
823
+ },
824
+ '&:hover': { color: CORVID.ink, bgcolor: 'rgba(232, 237, 242, 0.06)' },
825
+ },
826
+ [`& .${toggleButtonClasses.root}.${toggleButtonClasses.selected}`]: {
827
+ color: CORVID.graphite,
828
+ bgcolor: CORVID.lime,
829
+ '&:hover': { bgcolor: CORVID.lime },
830
+ },
831
+ // The phone's view button sits on the dock, not on a paper of its own
832
+ '& .MuiPaper-root': { bgcolor: 'transparent', boxShadow: 'none' },
833
+ }}
834
+ >
835
+ {placement === 'desktop' && <LayoutSlots.DesktopViewControls />}
836
+ {placement === 'phone' && !isFullscreen && <LayoutSlots.MobileViewControls />}
837
+ {(placement === 'desktop' || !isFullscreen) && (
838
+ <Divider orientation="vertical" flexItem sx={{ borderColor: CORVID.line, marginBlock: 0.5 }} />
839
+ )}
840
+ <DockButton label="Reset camera" icon="ArrowCounterClockwise" onClick={resetCamera} />
841
+ <DockButton
842
+ label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
843
+ icon={isFullscreen ? 'ArrowsIn' : 'ArrowsOut'}
844
+ onClick={toggleFullscreen}
845
+ />
846
+ </Box>
847
+ </ThemeProvider>
848
+ )
849
+ }
850
+ ```
851
+
852
+ ## The client's spec sheet
853
+
854
+ The client opens the configuration from a page that sets the web component's `readonly` property. The mock template `mocks/viewer.yaml`, **Client viewer** in the editor's **Mocks** panel, keeps `access: owner`, sets the property with `webComponentProperties: { readonly: true }` and opens a finished `configuration`: a hexa with 340 mm arms, three-blade propellers, cyan motors, the LiDAR, retractable gear and the Arctic livery. The **Read Mode** story pins that template.
855
+
856
+ The `readonly` property sets `phx.frontend.useAccessService().accessInfo.viewMode` to `true`, as a configuration shared for viewing does, and switches the spec's filter mode to read. `TabPanel` reads it and passes `readOnly` to the fields:
857
+
858
+ ```tsx
859
+ import { Box, Typography } from '@mui/material'
860
+
861
+ import { phx } from '../../.phoenix/phx.js'
862
+ import { DroneFields } from '../_controls/DroneFields.js'
863
+ import { TAB_PANEL_ID } from './DroneTabs.js'
864
+ import { CORVID, LABEL_SX } from './tokens.js'
865
+
866
+ interface TabPanelProps {
867
+ readonly compact: boolean
868
+ }
869
+
870
+ // The active tab's heading and controls. The heading is the tab's label from the spec; the controls are whichever
871
+ // the spec activates for the tab. A viewer gets the same tabs with every control as a line of the spec sheet.
872
+ export const TabPanel = ({ compact }: TabPanelProps) => {
873
+ const { tabs, activeTab } = phx.frontend.useTabNavigation()
874
+ const { accessInfo } = phx.frontend.useAccessService()
875
+ const readOnly = accessInfo.viewMode
876
+ const position = `${String((activeTab?.index ?? 0) + 1).padStart(2, '0')} / ${String(tabs.length).padStart(2, '0')}`
877
+
878
+ return (
879
+ <Box
880
+ component="section"
881
+ role="tabpanel"
882
+ id={TAB_PANEL_ID}
883
+ aria-labelledby={activeTab ? `corvid-tab-${activeTab.index}` : undefined}
884
+ sx={{ display: 'grid', gap: compact ? 2 : 3 }}
885
+ >
886
+ <Box component="header" sx={{ display: 'grid', gap: 0.5 }}>
887
+ <Typography sx={{ ...LABEL_SX, color: readOnly ? CORVID.cyan : CORVID.lime }}>
888
+ {position} · {readOnly ? 'SPEC SHEET' : 'CONFIGURE'}
889
+ </Typography>
890
+ <Typography
891
+ component="h2"
892
+ sx={{ fontFamily: CORVID.heading, fontWeight: 600, fontSize: compact ? 20 : 26, lineHeight: 1.15 }}
893
+ >
894
+ {activeTab?.label}
895
+ </Typography>
896
+ </Box>
897
+ <DroneFields readOnly={readOnly} />
898
+ </Box>
899
+ )
900
+ }
901
+ ```
902
+
903
+ - **Every control becomes a line of the spec sheet.** Each field still reads its value through its hook, but renders `SpecRow`, with the chosen option, the length or the angle and a line of detail, instead of the control. The hooks do not check read mode themselves.
904
+ - **The tabs stay.** The spec sheet is long, so the client browses it by tab; switching tabs is navigation, not editing. This departs on purpose from [Interaction Modes → View-only mode](?path=/docs/ux-guidelines-interaction-modes--docs#view-only-mode), which hides the editing navigation.
905
+ - **The HUD says SPEC SHEET · VIEW ONLY**, in cyan, where the engineer sees ENGINEERING · EDIT.
906
+ - **The target on the payload is gone.** Its `$filter.modes: [owner]` leaves it out in read mode, whichever of the two routes set it.
907
+
908
+ [Custom Layout → Serve read mode](?path=/docs/layouts-custom-layout--docs#serve-read-mode) shows the other route, a configuration opened with `read-revision` access; the layout reads both the same way.
909
+
910
+ ## The stage and the model
911
+
912
+ The spec's `canvasCustomEnvironment` names `DroneHangar`, a layout element rendered inside the scene: a dark backdrop, drei's `Grid` as a measured floor 320 mm below the drone, a landing ring, a contact shadow, and `Lightformer` panels that show only in the reflections. The scene is in millimetres.
913
+
914
+ ```tsx
915
+ import { ContactShadows, Environment, Grid, Lightformer } from '@react-three/drei'
916
+
917
+ import { phx } from '../../.phoenix/phx.js'
918
+ import { CORVID } from '../_shared/tokens.js'
919
+
920
+ // The floor lies 320 mm below the drone's centre, so the drone hovers over it on either landing gear
921
+ const FLOOR_Y = -320
922
+
923
+ // The stage named by the spec's `canvasCustomEnvironment`: a dark hangar with a measured floor, a landing ring and a
924
+ // soft shadow. Light panels exist only in the reflections, so the shell and the anodised motors have something to
925
+ // mirror and no image has to be downloaded. The scene is in millimetres.
926
+ export default phx.frontend.createCustomPhoenixLayoutElement('DroneHangar', () => {
927
+ return (
928
+ <>
929
+ <color attach="background" args={[CORVID.graphite]} />
930
+ <ambientLight intensity={0.6} />
931
+ <directionalLight position={[-700, 1500, 1100]} intensity={2.4} />
932
+ <directionalLight position={[1200, 500, 600]} intensity={0.7} />
933
+ {/* A cool rim light from behind outlines the drone against the dark hangar */}
934
+ <directionalLight position={[600, 700, -1400]} intensity={1.8} color={CORVID.cyan} />
935
+
936
+ <Environment resolution={256} frames={1}>
937
+ <color attach="background" args={['#262b32']} />
938
+ {/* A long softbox above, two tall strips at the sides and a dim fill from the front */}
939
+ <Lightformer form="rect" intensity={3.2} position={[0, 6, 0]} rotation-x={Math.PI / 2} scale={[10, 3, 1]} />
940
+ <Lightformer form="rect" intensity={2.4} position={[-6, 1.5, 3]} rotation-y={Math.PI / 2} scale={[1, 7, 1]} />
941
+ <Lightformer form="rect" intensity={1.6} position={[6, 1.5, -2]} rotation-y={-Math.PI / 2} scale={[1, 7, 1]} />
942
+ <Lightformer form="rect" intensity={1} position={[0, 0, 8]} rotation-y={Math.PI} scale={[8, 2, 1]} />
943
+ </Environment>
944
+
945
+ <Grid
946
+ position={[0, FLOOR_Y, 0]}
947
+ infiniteGrid
948
+ cellSize={50}
949
+ cellThickness={0.6}
950
+ cellColor="#262c34"
951
+ sectionSize={250}
952
+ sectionThickness={1}
953
+ sectionColor="#3a4652"
954
+ fadeDistance={4200}
955
+ fadeStrength={2}
956
+ />
957
+ {/* The landing ring, drawn just above the floor so the grid does not cut through it */}
958
+ <mesh rotation-x={-Math.PI / 2} position={[0, FLOOR_Y + 1, 0]}>
959
+ <ringGeometry args={[440, 445, 128]} />
960
+ <meshBasicMaterial color={CORVID.lime} transparent opacity={0.5} toneMapped={false} />
961
+ </mesh>
962
+ <ContactShadows position={[0, FLOOR_Y + 2, 0]} scale={1600} far={440} blur={2.6} opacity={0.8} resolution={512} />
963
+ </>
964
+ )
965
+ })
966
+ ```
967
+
968
+ The drone is `public/models/gallery/drone.glb`, generated by `scripts/models/drone.ts` in the Storybook package from three.js geometry: two carbon plates with a lofted canopy, quad and hexa arm sets, outrunner motors, two- and three-blade propellers, fixed and folded landing gear, three payloads and the light strips. Each part is a named node the spec loads with `sceneEntityRemoteObject`, and shows or hides with `visible` in `sceneEntityObjectProperties`. Three techniques carry the continuous choices:
969
+
970
+ - **A morph target for the arm length.** Every part at the arm tips, the arms, the motors, the propellers and the light pods, carries a `Reach` morph target from 280 mm to 380 mm. `morphTargetInfluences` maps the attribute onto it:
971
+
972
+ ```yaml
973
+ components:
974
+ Drone:
975
+ sceneEntityObjectProperties:
976
+ armsQuad:
977
+ visible:
978
+ $eval: self.frame === 'Quad'
979
+ morphTargetInfluences:
980
+ Reach:
981
+ value:
982
+ $read: armLength
983
+ scaleZero: 280
984
+ scaleOne: 380
985
+ ```
986
+
987
+ - **An object group for the tilt.** The camera and its lens have their node origin on the gimbal's tilt axis. The `gimbalPivot` group sits on that axis and turns by the attribute, in radians; the two objects are parented into it with `targetParentGroup`:
988
+
989
+ ```yaml
990
+ components:
991
+ Drone:
992
+ sceneEntityObjectGroup:
993
+ gimbalPivot:
994
+ visible:
995
+ $eval: self.payload === 'Camera'
996
+ offsetY: -70
997
+ offsetZ: 90
998
+ rotationX:
999
+ $read: gimbalTilt
1000
+ sceneEntityObjectProperties:
1001
+ gimbalCamera:
1002
+ targetParentGroup: gimbalPivot
1003
+ gimbalLens:
1004
+ targetParentGroup: gimbalPivot
1005
+ ```
1006
+
1007
+ - **Look-dev in YAML.** The GLB's materials are plain colours; `sceneEntityObjectLocalMaterialOverride` makes each object's look, named by the object's instance name: the anodised motors, the canopy's livery with its clearcoat, the glowing lights and the lenses. The colours come from expressions in `src/expressions/drone.ts`, which also compute the price and the weight.
1008
+
1009
+ ```yaml
1010
+ components:
1011
+ Drone:
1012
+ sceneEntityObjectLocalMaterialOverride:
1013
+ motorsQuad:
1014
+ materialsToOverride: [Motor]
1015
+ materialType: physical
1016
+ color:
1017
+ $read: $motorColor
1018
+ metalness: 0.95
1019
+ roughness: 0.3
1020
+ clearcoat: 0.5
1021
+ clearcoatRoughness: 0.2
1022
+ ledsQuad:
1023
+ materialsToOverride: [LED]
1024
+ color:
1025
+ $read: $ledColor
1026
+ emissive:
1027
+ $read: $ledColor
1028
+ emissiveIntensity: 2.6
1029
+ toneMapped: false
1030
+ ```
1031
+
1032
+ The script and its helper live in `scripts/models/`, outside the example's `src/`, because every file under `src/` is part of this page.
1033
+
1034
+ ## On a phone
1035
+
1036
+ The same layout in a frame 390 pixels wide: the scene at the top with a compact HUD, the active tab's controls in a sheet of fixed height, and the tabs as a segmented bar at the bottom. The sheet keeps its height on every tab, so the drone does not jump when the tab changes. `LayoutSlots.MobileViewControls` is one button in the dock that opens the list of views. Opened on its own from the sidebar, the **Phone** story sets Storybook's viewport to 390 × 844.
1037
+
1038
+ ## Test cases
1039
+
1040
+ - The page opens on the **Airframe** tab: a quad with 300 mm arms on fixed skids, hovering over the hangar floor inside the landing ring. The HUD shows "CORVID K6", ENGINEERING · EDIT, **$13,220.00**, "AUW 3.81 kg" and "VIEW ORBIT"; the log line reads "READY".
1041
+ - **Hexa** shows six arms, motors and propellers, and raises the price to $15,620.00 and the weight to 4.44 kg.
1042
+ - Dragging the **Arm length** slider grows the arms, motors and propellers under the thumb; letting go commits the length. The End key sets 380 mm with "Motor to motor 760 mm", and on the hexa the price becomes $16,100.00 and the weight 4.56 kg.
1043
+ - **Retractable gear** folds the legs flat under the belly and adds $650 and 0.13 kg; switching it off brings the skids back.
1044
+ - The **Propulsion** tab shows the propellers and the motors' anodising. **Three-blade** adds a blade to every propeller and $60 a motor; **Ember** turns the motors orange and adds $180.
1045
+ - Outside the **Payload** tab a target labelled "PAYLOAD ›" sits on the payload mount. Clicking it opens the **Payload** tab, and the target disappears.
1046
+ - On the **Payload** tab, **Gimbal tilt** turns the camera: the End key points it straight down at 90° ("Straight down, for mapping"), the Home key levels it at 0°, and the dial beside the value follows.
1047
+ - On a quad the **Delivery winch** tile is hatched, and "The delivery winch needs the hexa frame to lift its load." is shown below the payloads. Clicking it keeps the camera, and the log line shows "REJECTED" with the same sentence.
1048
+ - On a hexa the winch is accepted and hangs a line and a hook under the nose; the **Quad** tile on the Airframe tab is then hatched with the same sentence.
1049
+ - **LiDAR** hangs the scanner under the nose, removes **Gimbal tilt**, adds $7,600 and shows "NOTE LiDAR missions need RTK positioning, so a GNSS base station ships with the drone." in the log line.
1050
+ - On the **Livery** tab, **Arctic** turns the canopy white with cyan lights, and **Signal** orange with white lights, for $250 more.
1051
+ - **Top** looks down on the drone, **Payload** shows the payload close up from below the nose, and **Orbit** returns; the HUD names the view. **Reset camera** undoes a drag or a zoom.
1052
+ - **Fullscreen** leaves the drone and the dock only; **Exit fullscreen** brings back the rail, the panel, the HUD and the log line.
1053
+ - With a tab focused, the arrow keys move to the next or previous tab and open it.
1054
+ - The **Read Mode** story opens a finished drone with SPEC SHEET · VIEW ONLY in the HUD, **$24,650.00** and "AUW 4.92 kg": a hexa with 340 mm arms, retractable gear, three-blade propellers, cyan motors, the LiDAR and the Arctic livery. Every tab shows spec-sheet lines, with no tiles, sliders or switch, and no target on the drone.
1055
+ - In the **Phone** story the tabs are a bar at the bottom and the controls a sheet above it; the same changes, the target, the rejected winch in the log line and the liveries work there. The view button in the dock opens the list of views, and fullscreen leaves the reset and the exit.
1056
+ - The browser console shows no errors.
1057
+
1058
+ ## What this layout leaves out
1059
+
1060
+ - **Finalizing.** The engineer's work is the saved configuration; the drone is not ordered from here. A layout adds an order with `phx.frontend.useFinalizeActions()`, as [Kestrel Kettle](?path=/docs/layouts-gallery-kestrel-kettle--docs#add-to-cart) does.
1061
+ - **Undo and redo, sharing and measurements.** The dock has the view actions only. A layout adds undo and redo with `phx.frontend.useUndoRedo()`, see [Configurator Interface → Hooks for a Project Layout](?path=/docs/phoenix-configurator-interface--docs#hooks-for-a-project-layout).
1062
+ - **Editing a copy.** The client cannot clone the spec sheet to change it; the local editor reopens a local clone in read mode, so the example does not offer it.
1063
+ - **The built-in arrangement.** Header, sidebar, tabs, snackbars and the preview start screen are not mounted; [Layout Slots](?path=/docs/phoenix-layout-slots--docs) lists every slot a project layout can mount.
1064
+
1065
+ ## Related pages
1066
+
1067
+ - [Custom Layout](?path=/docs/layouts-custom-layout--docs) explains a project layout step by step and shows read mode by a shared link.
1068
+ - [Kestrel Kettle](?path=/docs/layouts-gallery-kestrel-kettle--docs) is a single-page product with a start screen and the built-in snackbars.
1069
+ - [Configurator Interface → Hooks for a Project Layout](?path=/docs/phoenix-configurator-interface--docs#hooks-for-a-project-layout) lists every hook used here, with the steps and tabs paragraph.
1070
+ - [Layout Slots](?path=/docs/phoenix-layout-slots--docs) describes `DesktopViewControls`, `MobileViewControls` and `SharedCanvas`.
1071
+ - [Sidebar Tabs](?path=/docs/layouts-sidebar-tabs--docs) shows the built-in tabs for the same `sidebarTabs` component.
1072
+
1073
+ ## Complete Implementation
1074
+
1075
+ {/* AUTO-GENERATED SECTION: This section is automatically updated by 'phoenix story build' */}
1076
+
1077
+ <details>
1078
+ <summary>Click to view the complete implementation files</summary>
1079
+
1080
+ ### ./spec/domain.yaml
1081
+
1082
+ ```yaml
1083
+ model:
1084
+ latest:
1085
+ entityTypes:
1086
+ - Drone
1087
+ aliases:
1088
+ RootEntity:
1089
+ - Drone
1090
+ commonTypes:
1091
+ Frame:
1092
+ enum:
1093
+ - Quad
1094
+ - Hexa
1095
+ Props:
1096
+ enum:
1097
+ - TwoBlade
1098
+ - ThreeBlade
1099
+ MotorColor:
1100
+ enum:
1101
+ - Titanium
1102
+ - Lime
1103
+ - Cyan
1104
+ - Ember
1105
+ Payload:
1106
+ enum:
1107
+ - Camera
1108
+ - Lidar
1109
+ - Winch
1110
+ Livery:
1111
+ enum:
1112
+ - Graphite
1113
+ - Arctic
1114
+ - Signal
1115
+ shapes:
1116
+ Drone:
1117
+ frame:
1118
+ $ref: '#/model/latest/commonTypes/Frame'
1119
+ armLength:
1120
+ type: number
1121
+ props:
1122
+ $ref: '#/model/latest/commonTypes/Props'
1123
+ motorColor:
1124
+ $ref: '#/model/latest/commonTypes/MotorColor'
1125
+ payload:
1126
+ $ref: '#/model/latest/commonTypes/Payload'
1127
+ gimbalTilt:
1128
+ type: number
1129
+ retractableGear:
1130
+ type: boolean
1131
+ livery:
1132
+ $ref: '#/model/latest/commonTypes/Livery'
1133
+
1134
+ presets:
1135
+ - rootEntityType: Drone
1136
+ initialDomainEntities:
1137
+ - id: Drone-1
1138
+ type: Drone
1139
+ frame: Quad
1140
+ armLength: 300
1141
+ props: TwoBlade
1142
+ motorColor: Titanium
1143
+ payload: Camera
1144
+ gimbalTilt: 0.5235987756
1145
+ retractableGear: false
1146
+ livery: Graphite
1147
+
1148
+ # One navigation state per tab; each control is active only in its tab's state
1149
+ navigationStates:
1150
+ - airframe
1151
+ - propulsion
1152
+ - payload
1153
+ - livery
1154
+
1155
+ viewStates:
1156
+ - orbit
1157
+ - top
1158
+ - payloadView
1159
+
1160
+ expressions:
1161
+ Drone:
1162
+ $price:
1163
+ type: number
1164
+ $weightLabel:
1165
+ type: string
1166
+ $shellColor:
1167
+ type: string
1168
+ $shellClearcoat:
1169
+ type: number
1170
+ $ledColor:
1171
+ type: string
1172
+ $motorColor:
1173
+ type: string
1174
+
1175
+ invariants:
1176
+ Drone:
1177
+ winchNeedsHexa:
1178
+ type: winchNeedsHexa
1179
+ severity: fixable
1180
+ message: The delivery winch needs the hexa frame to lift its load.
1181
+
1182
+ transformers:
1183
+ - name: lidar-base-station
1184
+
1185
+ components:
1186
+ Drone:
1187
+ detail:
1188
+ main:
1189
+ title: Corvid K6
1190
+ subtitle:
1191
+ $read: $weightLabel
1192
+ description: A survey drone for mapping and inspection. Pick the airframe, the propulsion, the payload and the livery; the engineer's configuration becomes the client's spec sheet.
1193
+ price:
1194
+ main:
1195
+ price:
1196
+ $read: $price
1197
+
1198
+ sidebarTabs:
1199
+ main:
1200
+ tabs:
1201
+ - label: Airframe
1202
+ icon: Cube
1203
+ navigationStates: [airframe]
1204
+ - label: Propulsion
1205
+ icon: ArrowClockwise
1206
+ navigationStates: [propulsion]
1207
+ - label: Payload
1208
+ icon: Scan
1209
+ navigationStates: [payload]
1210
+ - label: Livery
1211
+ icon: PaletteBold
1212
+ navigationStates: [livery]
1213
+
1214
+ attributeSelection:
1215
+ frame:
1216
+ value:
1217
+ $bind: frame
1218
+ options:
1219
+ - label: Quad
1220
+ description: Four motors, the lightest and longest-flying
1221
+ value: Quad
1222
+ - label: Hexa
1223
+ description: Six motors; lands safely with one motor out
1224
+ value: Hexa
1225
+ $filter:
1226
+ navigationStates: [airframe]
1227
+ props:
1228
+ value:
1229
+ $bind: props
1230
+ options:
1231
+ - label: Two-blade
1232
+ description: Efficient in cruise, for the longest flights
1233
+ value: TwoBlade
1234
+ - label: Three-blade
1235
+ description: More thrust and steadier in gusts, a little shorter in the air
1236
+ value: ThreeBlade
1237
+ $filter:
1238
+ navigationStates: [propulsion]
1239
+ motorColor:
1240
+ value:
1241
+ $bind: motorColor
1242
+ options:
1243
+ - label: Titanium
1244
+ description: Bare anodised aluminium
1245
+ value: Titanium
1246
+ - label: Lime
1247
+ description: Signal lime anodising
1248
+ value: Lime
1249
+ - label: Cyan
1250
+ description: Glacier cyan anodising
1251
+ value: Cyan
1252
+ - label: Ember
1253
+ description: Ember orange anodising
1254
+ value: Ember
1255
+ $filter:
1256
+ navigationStates: [propulsion]
1257
+ payload:
1258
+ value:
1259
+ $bind: payload
1260
+ options:
1261
+ - label: Gimbal camera
1262
+ description: 45 MP stabilised camera on a tilting gimbal
1263
+ value: Camera
1264
+ - label: LiDAR
1265
+ description: 240,000 points a second, 360° around
1266
+ value: Lidar
1267
+ - label: Delivery winch
1268
+ description: Lowers up to 3 kg on a 12 m line
1269
+ value: Winch
1270
+ $filter:
1271
+ navigationStates: [payload]
1272
+ livery:
1273
+ value:
1274
+ $bind: livery
1275
+ options:
1276
+ - label: Graphite
1277
+ description: Satin graphite with lime lights
1278
+ value: Graphite
1279
+ - label: Arctic
1280
+ description: Gloss white with cyan lights
1281
+ value: Arctic
1282
+ - label: Signal
1283
+ description: High-visibility orange with white lights
1284
+ value: Signal
1285
+ $filter:
1286
+ navigationStates: [livery]
1287
+ distanceDimension:
1288
+ armLength:
1289
+ value:
1290
+ $bind: armLength
1291
+ min: 280
1292
+ max: 380
1293
+ $filter:
1294
+ navigationStates: [airframe]
1295
+ angleDimension:
1296
+ gimbalTilt:
1297
+ value:
1298
+ $bind: gimbalTilt
1299
+ min: 0
1300
+ max: 1.5707963268
1301
+ unit: degree
1302
+ decimalPlaces: 0
1303
+ # Only the camera tilts
1304
+ $filter:
1305
+ navigationStates: [payload]
1306
+ active:
1307
+ $eval: self.payload === 'Camera'
1308
+ attributeSwitch:
1309
+ retractableGear:
1310
+ value:
1311
+ $bind: retractableGear
1312
+ $filter:
1313
+ navigationStates: [airframe]
1314
+
1315
+ viewControls:
1316
+ main:
1317
+ items:
1318
+ - label: Orbit
1319
+ value: orbit
1320
+ icon: CameraRotate
1321
+ - label: Top
1322
+ value: top
1323
+ icon: GridFour
1324
+ - label: Payload
1325
+ value: payloadView
1326
+ icon: Scan
1327
+
1328
+ # Pinned to the payload mount; an engineer outside the Payload tab clicks it to get there. A viewer never sees it,
1329
+ # and fullscreen leaves it out
1330
+ sceneEntityObjectHtmlHotspot:
1331
+ payloadMount:
1332
+ target: payloadMount
1333
+ name: PayloadHotspot
1334
+ $filter:
1335
+ modes: [owner]
1336
+ navigationStates: [airframe, propulsion, livery]
1337
+ fullscreen: false
1338
+
1339
+ canvasConfig:
1340
+ main:
1341
+ highlightingDisabled: true
1342
+ canvasCustomEnvironment:
1343
+ main:
1344
+ name: DroneHangar
1345
+ fitRootPerspectiveCamera:
1346
+ orbit:
1347
+ priority: 2
1348
+ fov: 30
1349
+ near: 10
1350
+ far: 20000
1351
+ minDistance: 500
1352
+ maxDistance: 5000
1353
+ azimuthAngle: 0.62
1354
+ polarAngle: 1.1
1355
+ minPolarAngle: 0.05
1356
+ maxPolarAngle: 1.8
1357
+ fitToBox: true
1358
+ fitMeshChanges: false
1359
+ $filter:
1360
+ viewStates: [orbit]
1361
+ top:
1362
+ priority: 2
1363
+ fov: 30
1364
+ near: 10
1365
+ far: 20000
1366
+ minDistance: 500
1367
+ maxDistance: 5000
1368
+ azimuthAngle: 0
1369
+ polarAngle: 0.001
1370
+ minPolarAngle: 0
1371
+ maxPolarAngle: 0.6
1372
+ fitToBox: true
1373
+ fitMeshChanges: false
1374
+ $filter:
1375
+ viewStates: [top]
1376
+ staticPerspectiveCamera:
1377
+ payloadView:
1378
+ priority: 2
1379
+ fov: 30
1380
+ near: 10
1381
+ far: 20000
1382
+ distance: 1050
1383
+ minDistance: 400
1384
+ maxDistance: 2400
1385
+ azimuthAngle: 0.5
1386
+ polarAngle: 1.7
1387
+ minPolarAngle: 1.2
1388
+ maxPolarAngle: 2.4
1389
+ lookAtX: 0
1390
+ lookAtY: -60
1391
+ lookAtZ: 90
1392
+ $filter:
1393
+ viewStates: [payloadView]
1394
+
1395
+ sceneEntityRemoteObject:
1396
+ bodyPlates:
1397
+ fileUrl: /models/gallery/drone.glb
1398
+ objectName: BodyPlates
1399
+ dimensionUnit: m
1400
+ canopy:
1401
+ fileUrl: /models/gallery/drone.glb
1402
+ objectName: Canopy
1403
+ dimensionUnit: m
1404
+ noseSensors:
1405
+ fileUrl: /models/gallery/drone.glb
1406
+ objectName: NoseSensors
1407
+ dimensionUnit: m
1408
+ ledStrip:
1409
+ fileUrl: /models/gallery/drone.glb
1410
+ objectName: LedStrip
1411
+ dimensionUnit: m
1412
+ gps:
1413
+ fileUrl: /models/gallery/drone.glb
1414
+ objectName: Gps
1415
+ dimensionUnit: m
1416
+ armsQuad:
1417
+ fileUrl: /models/gallery/drone.glb
1418
+ objectName: ArmsQuad
1419
+ dimensionUnit: m
1420
+ armsHexa:
1421
+ fileUrl: /models/gallery/drone.glb
1422
+ objectName: ArmsHexa
1423
+ dimensionUnit: m
1424
+ motorsQuad:
1425
+ fileUrl: /models/gallery/drone.glb
1426
+ objectName: MotorsQuad
1427
+ dimensionUnit: m
1428
+ motorsHexa:
1429
+ fileUrl: /models/gallery/drone.glb
1430
+ objectName: MotorsHexa
1431
+ dimensionUnit: m
1432
+ ledsQuad:
1433
+ fileUrl: /models/gallery/drone.glb
1434
+ objectName: LedsQuad
1435
+ dimensionUnit: m
1436
+ ledsHexa:
1437
+ fileUrl: /models/gallery/drone.glb
1438
+ objectName: LedsHexa
1439
+ dimensionUnit: m
1440
+ propsQuadTwoBlade:
1441
+ fileUrl: /models/gallery/drone.glb
1442
+ objectName: PropsQuadTwoBlade
1443
+ dimensionUnit: m
1444
+ propsQuadThreeBlade:
1445
+ fileUrl: /models/gallery/drone.glb
1446
+ objectName: PropsQuadThreeBlade
1447
+ dimensionUnit: m
1448
+ propsHexaTwoBlade:
1449
+ fileUrl: /models/gallery/drone.glb
1450
+ objectName: PropsHexaTwoBlade
1451
+ dimensionUnit: m
1452
+ propsHexaThreeBlade:
1453
+ fileUrl: /models/gallery/drone.glb
1454
+ objectName: PropsHexaThreeBlade
1455
+ dimensionUnit: m
1456
+ gearFixed:
1457
+ fileUrl: /models/gallery/drone.glb
1458
+ objectName: GearFixed
1459
+ dimensionUnit: m
1460
+ gearFolded:
1461
+ fileUrl: /models/gallery/drone.glb
1462
+ objectName: GearFolded
1463
+ dimensionUnit: m
1464
+ payloadMount:
1465
+ fileUrl: /models/gallery/drone.glb
1466
+ objectName: PayloadMount
1467
+ dimensionUnit: m
1468
+ gimbalYoke:
1469
+ fileUrl: /models/gallery/drone.glb
1470
+ objectName: GimbalYoke
1471
+ dimensionUnit: m
1472
+ gimbalCamera:
1473
+ fileUrl: /models/gallery/drone.glb
1474
+ objectName: GimbalCamera
1475
+ dimensionUnit: m
1476
+ gimbalLens:
1477
+ fileUrl: /models/gallery/drone.glb
1478
+ objectName: GimbalLens
1479
+ dimensionUnit: m
1480
+ lidarPuck:
1481
+ fileUrl: /models/gallery/drone.glb
1482
+ objectName: LidarPuck
1483
+ dimensionUnit: m
1484
+ lidarWindow:
1485
+ fileUrl: /models/gallery/drone.glb
1486
+ objectName: LidarWindow
1487
+ dimensionUnit: m
1488
+ winch:
1489
+ fileUrl: /models/gallery/drone.glb
1490
+ objectName: Winch
1491
+ dimensionUnit: m
1492
+
1493
+ # The camera turns about the gimbal's tilt axis, 70 mm below the belly and 90 mm forward of the centre
1494
+ sceneEntityObjectGroup:
1495
+ gimbalPivot:
1496
+ visible:
1497
+ $eval: self.payload === 'Camera'
1498
+ offsetY: -70
1499
+ offsetZ: 90
1500
+ rotationX:
1501
+ $read: gimbalTilt
1502
+
1503
+ # The arm length morphs every part at the arm tips: 280 mm is the model's shape, 380 mm its `Reach` target
1504
+ sceneEntityObjectProperties:
1505
+ armsQuad:
1506
+ visible:
1507
+ $eval: self.frame === 'Quad'
1508
+ morphTargetInfluences:
1509
+ Reach:
1510
+ value:
1511
+ $read: armLength
1512
+ scaleZero: 280
1513
+ scaleOne: 380
1514
+ armsHexa:
1515
+ visible:
1516
+ $eval: self.frame === 'Hexa'
1517
+ morphTargetInfluences:
1518
+ Reach:
1519
+ value:
1520
+ $read: armLength
1521
+ scaleZero: 280
1522
+ scaleOne: 380
1523
+ motorsQuad:
1524
+ visible:
1525
+ $eval: self.frame === 'Quad'
1526
+ morphTargetInfluences:
1527
+ Reach:
1528
+ value:
1529
+ $read: armLength
1530
+ scaleZero: 280
1531
+ scaleOne: 380
1532
+ motorsHexa:
1533
+ visible:
1534
+ $eval: self.frame === 'Hexa'
1535
+ morphTargetInfluences:
1536
+ Reach:
1537
+ value:
1538
+ $read: armLength
1539
+ scaleZero: 280
1540
+ scaleOne: 380
1541
+ ledsQuad:
1542
+ visible:
1543
+ $eval: self.frame === 'Quad'
1544
+ morphTargetInfluences:
1545
+ Reach:
1546
+ value:
1547
+ $read: armLength
1548
+ scaleZero: 280
1549
+ scaleOne: 380
1550
+ ledsHexa:
1551
+ visible:
1552
+ $eval: self.frame === 'Hexa'
1553
+ morphTargetInfluences:
1554
+ Reach:
1555
+ value:
1556
+ $read: armLength
1557
+ scaleZero: 280
1558
+ scaleOne: 380
1559
+ propsQuadTwoBlade:
1560
+ visible:
1561
+ $eval: self.frame === 'Quad' && self.props === 'TwoBlade'
1562
+ morphTargetInfluences:
1563
+ Reach:
1564
+ value:
1565
+ $read: armLength
1566
+ scaleZero: 280
1567
+ scaleOne: 380
1568
+ propsQuadThreeBlade:
1569
+ visible:
1570
+ $eval: self.frame === 'Quad' && self.props === 'ThreeBlade'
1571
+ morphTargetInfluences:
1572
+ Reach:
1573
+ value:
1574
+ $read: armLength
1575
+ scaleZero: 280
1576
+ scaleOne: 380
1577
+ propsHexaTwoBlade:
1578
+ visible:
1579
+ $eval: self.frame === 'Hexa' && self.props === 'TwoBlade'
1580
+ morphTargetInfluences:
1581
+ Reach:
1582
+ value:
1583
+ $read: armLength
1584
+ scaleZero: 280
1585
+ scaleOne: 380
1586
+ propsHexaThreeBlade:
1587
+ visible:
1588
+ $eval: self.frame === 'Hexa' && self.props === 'ThreeBlade'
1589
+ morphTargetInfluences:
1590
+ Reach:
1591
+ value:
1592
+ $read: armLength
1593
+ scaleZero: 280
1594
+ scaleOne: 380
1595
+ gearFixed:
1596
+ visible:
1597
+ $eval: '!self.retractableGear'
1598
+ gearFolded:
1599
+ visible:
1600
+ $read: retractableGear
1601
+ payloadMount:
1602
+ offsetZ: 90
1603
+ gimbalYoke:
1604
+ visible:
1605
+ $eval: self.payload === 'Camera'
1606
+ gimbalCamera:
1607
+ targetParentGroup: gimbalPivot
1608
+ gimbalLens:
1609
+ targetParentGroup: gimbalPivot
1610
+ lidarPuck:
1611
+ visible:
1612
+ $eval: self.payload === 'Lidar'
1613
+ lidarWindow:
1614
+ visible:
1615
+ $eval: self.payload === 'Lidar'
1616
+ winch:
1617
+ visible:
1618
+ $eval: self.payload === 'Winch'
1619
+
1620
+ # Look-dev: each override recolours the material of one object, named by its instance name
1621
+ sceneEntityObjectLocalMaterialOverride:
1622
+ canopy:
1623
+ materialsToOverride: [Shell]
1624
+ materialType: physical
1625
+ color:
1626
+ $read: $shellColor
1627
+ metalness: 0.15
1628
+ roughness: 0.38
1629
+ clearcoat:
1630
+ $read: $shellClearcoat
1631
+ clearcoatRoughness: 0.12
1632
+ motorsQuad:
1633
+ materialsToOverride: [Motor]
1634
+ materialType: physical
1635
+ color:
1636
+ $read: $motorColor
1637
+ metalness: 0.95
1638
+ roughness: 0.3
1639
+ clearcoat: 0.5
1640
+ clearcoatRoughness: 0.2
1641
+ motorsHexa:
1642
+ materialsToOverride: [Motor]
1643
+ materialType: physical
1644
+ color:
1645
+ $read: $motorColor
1646
+ metalness: 0.95
1647
+ roughness: 0.3
1648
+ clearcoat: 0.5
1649
+ clearcoatRoughness: 0.2
1650
+ ledStrip:
1651
+ materialsToOverride: [LED]
1652
+ color:
1653
+ $read: $ledColor
1654
+ emissive:
1655
+ $read: $ledColor
1656
+ emissiveIntensity: 2.2
1657
+ toneMapped: false
1658
+ ledsQuad:
1659
+ materialsToOverride: [LED]
1660
+ color:
1661
+ $read: $ledColor
1662
+ emissive:
1663
+ $read: $ledColor
1664
+ emissiveIntensity: 2.6
1665
+ toneMapped: false
1666
+ ledsHexa:
1667
+ materialsToOverride: [LED]
1668
+ color:
1669
+ $read: $ledColor
1670
+ emissive:
1671
+ $read: $ledColor
1672
+ emissiveIntensity: 2.6
1673
+ toneMapped: false
1674
+ gimbalLens:
1675
+ materialsToOverride: [Lens]
1676
+ materialType: physical
1677
+ color: '#05070a'
1678
+ metalness: 0.2
1679
+ roughness: 0.04
1680
+ clearcoat: 1
1681
+ clearcoatRoughness: 0.02
1682
+ iridescence: 0.6
1683
+ iridescenceIor: 1.6
1684
+ lidarWindow:
1685
+ materialsToOverride: [Lens]
1686
+ materialType: physical
1687
+ color: '#0a0f14'
1688
+ metalness: 0.3
1689
+ roughness: 0.06
1690
+ clearcoat: 1
1691
+ clearcoatRoughness: 0.03
1692
+ noseSensors:
1693
+ materialsToOverride: [Lens]
1694
+ materialType: physical
1695
+ color: '#05070a'
1696
+ metalness: 0.2
1697
+ roughness: 0.04
1698
+ clearcoat: 1
1699
+ clearcoatRoughness: 0.02
1700
+ ```
1701
+
1702
+ ### ./src/\_controls/ArmLengthField.tsx
1703
+
1704
+ ```tsx
1705
+ import { distanceDimensionComponent } from '@salsita-ai/sdk/domain'
1706
+
1707
+ import { phx } from '../../.phoenix/phx.js'
1708
+ import { HudSlider } from './HudSlider.js'
1709
+ import { SpecRow } from './SpecRow.js'
1710
+
1711
+ const STEP = 10
1712
+
1713
+ interface ArmLengthFieldProps {
1714
+ readonly readOnly: boolean
1715
+ }
1716
+
1717
+ const ArmLengthControl = ({ readOnly }: ArmLengthFieldProps) => {
1718
+ const droneId = phx.frontend.useRootEntityId()
1719
+ const arm = phx.frontend.useDistanceDimension(droneId, 'armLength')
1720
+ // Motor to motor across the drone: twice the arm, which is measured from the centre
1721
+ const span = `Motor to motor ${arm.formatValue(arm.value * 2, true)}`
1722
+
1723
+ if (readOnly) {
1724
+ return <SpecRow label="Arm length" value={arm.valueWithUnit} detail={span} />
1725
+ }
1726
+
1727
+ return (
1728
+ <HudSlider
1729
+ id="corvid-arm-length"
1730
+ label="Arm length"
1731
+ valueText={arm.valueWithUnit}
1732
+ value={arm.value}
1733
+ min={arm.min}
1734
+ max={arm.max}
1735
+ step={STEP}
1736
+ minLabel={arm.formatValue(arm.min, true)}
1737
+ maxLabel={arm.formatValue(arm.max, true)}
1738
+ detail={span}
1739
+ onChange={arm.update}
1740
+ onCommit={arm.resolve}
1741
+ />
1742
+ )
1743
+ }
1744
+
1745
+ // The spec's `distanceDimension` for the arm length, in the display unit. Moving the slider stages each value with
1746
+ // `update`, so the arms grow under the thumb through the spec's morph target, and letting go commits it with
1747
+ // `resolve`. The field exists only while the spec's component is active.
1748
+ export const ArmLengthField = (props: ArmLengthFieldProps) => {
1749
+ const droneId = phx.frontend.useRootEntityId()
1750
+ const component = phx.frontend.useComponent({
1751
+ entityId: droneId,
1752
+ componentType: distanceDimensionComponent,
1753
+ instanceName: 'armLength',
1754
+ })
1755
+
1756
+ return component === null ? null : <ArmLengthControl {...props} />
1757
+ }
1758
+ ```
1759
+
1760
+ ### ./src/\_controls/DroneFields.tsx
1761
+
1762
+ ```tsx
1763
+ import { Box } from '@mui/material'
1764
+
1765
+ import { ArmLengthField } from './ArmLengthField.js'
1766
+ import { GearField } from './GearField.js'
1767
+ import { GimbalTiltField } from './GimbalTiltField.js'
1768
+ import { OptionField } from './OptionField.js'
1769
+ import { FRAME_VISUALS, LIVERY_VISUALS, MOTOR_VISUALS, PAYLOAD_VISUALS, PROP_VISUALS } from './visuals.js'
1770
+
1771
+ interface DroneFieldsProps {
1772
+ readonly readOnly: boolean
1773
+ }
1774
+
1775
+ // Every control of the spec, in the order the panel shows them. Each one renders only while its spec component is
1776
+ // active, so the spec's `$filter.navigationStates` decides which tab shows it and `$filter.active` hides the tilt
1777
+ // without the camera. The labels are the layout's: `attributeSelection` and the dimensions carry none.
1778
+ export const DroneFields = ({ readOnly }: DroneFieldsProps) => {
1779
+ return (
1780
+ <Box component="div" sx={{ display: 'grid', gap: readOnly ? 1.5 : 3 }}>
1781
+ <OptionField instanceName="frame" label="Frame" visuals={FRAME_VISUALS} readOnly={readOnly} />
1782
+ <ArmLengthField readOnly={readOnly} />
1783
+ <GearField readOnly={readOnly} />
1784
+ <OptionField instanceName="props" label="Propellers" visuals={PROP_VISUALS} readOnly={readOnly} />
1785
+ <OptionField instanceName="motorColor" label="Motor anodising" visuals={MOTOR_VISUALS} readOnly={readOnly} />
1786
+ <OptionField instanceName="payload" label="Payload" visuals={PAYLOAD_VISUALS} readOnly={readOnly} />
1787
+ <GimbalTiltField readOnly={readOnly} />
1788
+ <OptionField instanceName="livery" label="Livery" visuals={LIVERY_VISUALS} readOnly={readOnly} />
1789
+ </Box>
1790
+ )
1791
+ }
1792
+ ```
1793
+
1794
+ ### ./src/\_controls/GearField.tsx
1795
+
1796
+ ```tsx
1797
+ import { Box, Switch, Typography } from '@mui/material'
1798
+
1799
+ import { attributeSwitchComponent } from '@salsita-ai/sdk/domain'
1800
+
1801
+ import { phx } from '../../.phoenix/phx.js'
1802
+ import { CORVID, LABEL_SX } from '../_shared/tokens.js'
1803
+ import { SpecRow } from './SpecRow.js'
1804
+
1805
+ interface GearFieldProps {
1806
+ readonly readOnly: boolean
1807
+ }
1808
+
1809
+ const describe = (retractable: boolean) => {
1810
+ return retractable
1811
+ ? 'The legs fold flat under the belly in flight, for a clear view all round.'
1812
+ : 'Fixed carbon skids, the lightest and simplest.'
1813
+ }
1814
+
1815
+ const GearSwitch = ({ readOnly }: GearFieldProps) => {
1816
+ const droneId = phx.frontend.useRootEntityId()
1817
+ const gear = phx.frontend.useAttributeSwitch(droneId, 'retractableGear')
1818
+ const isOn = gear.state.value
1819
+
1820
+ if (readOnly) {
1821
+ return <SpecRow label="Landing gear" value={isOn ? 'Retractable' : 'Fixed skids'} detail={describe(isOn)} />
1822
+ }
1823
+
1824
+ return (
1825
+ <Box component="section" sx={{ display: 'grid', gap: 0.75 }}>
1826
+ <Box component="div" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
1827
+ <Typography component="h3" id="corvid-gear-label" sx={LABEL_SX}>
1828
+ Retractable gear
1829
+ </Typography>
1830
+ <Switch
1831
+ checked={isOn}
1832
+ disabled={gear.disabled}
1833
+ onChange={gear.onChange}
1834
+ inputProps={{ 'aria-labelledby': 'corvid-gear-label' }}
1835
+ sx={{
1836
+ '& .MuiSwitch-switchBase': { color: CORVID.muted },
1837
+ '& .MuiSwitch-switchBase.Mui-checked': { color: CORVID.lime },
1838
+ '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: CORVID.lime, opacity: 0.4 },
1839
+ '& .MuiSwitch-track': { bgcolor: CORVID.line, opacity: 1 },
1840
+ }}
1841
+ />
1842
+ </Box>
1843
+ <Typography sx={{ fontFamily: CORVID.mono, fontSize: 11.5, lineHeight: 1.4, color: CORVID.muted }}>
1844
+ {describe(isOn)}
1845
+ </Typography>
1846
+ </Box>
1847
+ )
1848
+ }
1849
+
1850
+ // The spec's `attributeSwitch` for the landing gear: `useAttributeSwitch` returns `state.value` and `onChange()`,
1851
+ // which writes the opposite value. The field exists only while the spec's component is active.
1852
+ export const GearField = (props: GearFieldProps) => {
1853
+ const droneId = phx.frontend.useRootEntityId()
1854
+ const component = phx.frontend.useComponent({
1855
+ entityId: droneId,
1856
+ componentType: attributeSwitchComponent,
1857
+ instanceName: 'retractableGear',
1858
+ })
1859
+
1860
+ return component === null ? null : <GearSwitch {...props} />
1861
+ }
1862
+ ```
1863
+
1864
+ ### ./src/\_controls/GimbalTiltField.tsx
1865
+
1866
+ ```tsx
1867
+ import { angleDimensionComponent } from '@salsita-ai/sdk/domain'
1868
+
1869
+ import { phx } from '../../.phoenix/phx.js'
1870
+ import { CORVID } from '../_shared/tokens.js'
1871
+ import { HudSlider } from './HudSlider.js'
1872
+ import { SpecRow } from './SpecRow.js'
1873
+
1874
+ interface TiltDialProps {
1875
+ readonly degrees: number
1876
+ }
1877
+
1878
+ // A quarter dial from the horizon down to the ground, with the camera's line of sight on it
1879
+ const TiltDial = ({ degrees }: TiltDialProps) => {
1880
+ const radians = (degrees * Math.PI) / 180
1881
+ const x = 6 + 40 * Math.cos(radians)
1882
+ const y = 6 + 40 * Math.sin(radians)
1883
+
1884
+ return (
1885
+ <svg viewBox="0 0 52 52" width={52} height={52} aria-hidden fill="none">
1886
+ <path d="M6 6h44M6 6v44" stroke={CORVID.line} />
1887
+ <path d="M46 6A40 40 0 0 1 6 46" stroke={CORVID.faint} strokeDasharray="2 3" />
1888
+ <path d={`M6 6L${x} ${y}`} stroke={CORVID.lime} strokeWidth={1.6} />
1889
+ <circle cx={x} cy={y} r={2.5} fill={CORVID.lime} />
1890
+ <rect x={2} y={2} width={8} height={8} fill={CORVID.graphite} stroke={CORVID.lime} />
1891
+ </svg>
1892
+ )
1893
+ }
1894
+
1895
+ const describe = (degrees: number) => {
1896
+ if (degrees <= 0) {
1897
+ return 'Level with the horizon, for inspection'
1898
+ }
1899
+
1900
+ if (degrees >= 90) {
1901
+ return 'Straight down, for mapping'
1902
+ }
1903
+
1904
+ return `${degrees}° below the horizon`
1905
+ }
1906
+
1907
+ interface GimbalTiltFieldProps {
1908
+ readonly readOnly: boolean
1909
+ }
1910
+
1911
+ const GimbalTiltControl = ({ readOnly }: GimbalTiltFieldProps) => {
1912
+ const droneId = phx.frontend.useRootEntityId()
1913
+ const tilt = phx.frontend.useAngleDimension(droneId, 'gimbalTilt')
1914
+ const valueText = `${tilt.value}${tilt.unitLabel}`
1915
+ const min = tilt.min ?? 0
1916
+ const max = tilt.max ?? 90
1917
+
1918
+ if (readOnly) {
1919
+ return <SpecRow label="Gimbal tilt" value={valueText} detail={describe(tilt.value)} />
1920
+ }
1921
+
1922
+ return (
1923
+ <HudSlider
1924
+ id="corvid-gimbal-tilt"
1925
+ label="Gimbal tilt"
1926
+ valueText={valueText}
1927
+ value={tilt.value}
1928
+ min={min}
1929
+ max={max}
1930
+ step={1}
1931
+ minLabel={`${min}${tilt.unitLabel} HORIZON`}
1932
+ maxLabel={`${max}${tilt.unitLabel} NADIR`}
1933
+ detail={describe(tilt.value)}
1934
+ adornment={<TiltDial degrees={tilt.value} />}
1935
+ onChange={tilt.update}
1936
+ onCommit={tilt.resolve}
1937
+ />
1938
+ )
1939
+ }
1940
+
1941
+ // The spec's `angleDimension` for the camera's tilt, in degrees as the spec's `unit` asks; the spec stores radians and
1942
+ // turns the gimbal's pivot group by them. The slider stages with `update` and commits with `resolve`. The spec makes
1943
+ // the component active only on the Payload tab and only while the camera is fitted, so the field checks first.
1944
+ export const GimbalTiltField = (props: GimbalTiltFieldProps) => {
1945
+ const droneId = phx.frontend.useRootEntityId()
1946
+ const component = phx.frontend.useComponent({
1947
+ entityId: droneId,
1948
+ componentType: angleDimensionComponent,
1949
+ instanceName: 'gimbalTilt',
1950
+ })
1951
+
1952
+ return component === null ? null : <GimbalTiltControl {...props} />
1953
+ }
1954
+ ```
1955
+
1956
+ ### ./src/\_controls/HudSlider.tsx
1957
+
1958
+ ```tsx
1959
+ import { Box, Slider, Typography, sliderClasses } from '@mui/material'
1960
+ import type { ReactNode } from 'react'
1961
+
1962
+ import { CORVID, LABEL_SX } from '../_shared/tokens.js'
1963
+
1964
+ interface HudSliderProps {
1965
+ readonly id: string
1966
+ readonly label: string
1967
+ readonly valueText: string
1968
+ readonly value: number
1969
+ readonly min: number
1970
+ readonly max: number
1971
+ readonly step: number
1972
+ readonly minLabel: string
1973
+ readonly maxLabel: string
1974
+ readonly detail: string
1975
+ readonly adornment?: ReactNode
1976
+ // Stages a value while the thumb moves, so the scene follows it
1977
+ readonly onChange: (value: number) => void
1978
+ // Commits the staged value when the thumb is let go
1979
+ readonly onCommit: () => void
1980
+ }
1981
+
1982
+ // A dimension as a readout and a slider, for the arm length and the gimbal tilt
1983
+ export const HudSlider = (props: HudSliderProps) => {
1984
+ const { id, label, valueText, value, min, max, step, minLabel, maxLabel, detail, adornment, onChange, onCommit } =
1985
+ props
1986
+
1987
+ return (
1988
+ <Box component="section" sx={{ display: 'grid', gap: 0.75 }}>
1989
+ <Box component="div" sx={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 2 }}>
1990
+ <Box component="div" sx={{ display: 'grid', gap: 0.5 }}>
1991
+ <Typography component="h3" id={`${id}-label`} sx={LABEL_SX}>
1992
+ {label}
1993
+ </Typography>
1994
+ <Typography
1995
+ sx={{
1996
+ fontFamily: CORVID.mono,
1997
+ fontSize: 26,
1998
+ lineHeight: 1,
1999
+ color: CORVID.lime,
2000
+ fontVariantNumeric: 'tabular-nums',
2001
+ }}
2002
+ >
2003
+ {valueText}
2004
+ </Typography>
2005
+ </Box>
2006
+ {adornment}
2007
+ </Box>
2008
+ <Slider
2009
+ value={value}
2010
+ min={min}
2011
+ max={max}
2012
+ step={step}
2013
+ onChange={(_, next) => onChange(Array.isArray(next) ? (next[0] ?? min) : next)}
2014
+ onChangeCommitted={() => onCommit()}
2015
+ getAriaValueText={() => valueText}
2016
+ slotProps={{ input: { 'aria-labelledby': `${id}-label` } }}
2017
+ sx={{
2018
+ color: CORVID.lime,
2019
+ blockSize: 2,
2020
+ paddingBlock: 1.75,
2021
+ [`& .${sliderClasses.rail}`]: { bgcolor: CORVID.line, opacity: 1 },
2022
+ [`& .${sliderClasses.track}`]: { border: 0 },
2023
+ [`& .${sliderClasses.thumb}`]: {
2024
+ inlineSize: 14,
2025
+ blockSize: 14,
2026
+ borderRadius: 0,
2027
+ transform: 'translate(-50%, -50%) rotate(45deg)',
2028
+ bgcolor: CORVID.graphite,
2029
+ border: `2px solid ${CORVID.lime}`,
2030
+ boxShadow: 'none',
2031
+ '&:hover, &.Mui-focusVisible': { boxShadow: `0 0 0 6px rgba(198, 244, 50, 0.16)` },
2032
+ },
2033
+ }}
2034
+ />
2035
+ <Box
2036
+ component="div"
2037
+ sx={{
2038
+ display: 'flex',
2039
+ justifyContent: 'space-between',
2040
+ fontFamily: CORVID.mono,
2041
+ fontSize: 10.5,
2042
+ color: CORVID.faint,
2043
+ }}
2044
+ >
2045
+ <span>{minLabel}</span>
2046
+ <span>{maxLabel}</span>
2047
+ </Box>
2048
+ <Typography sx={{ fontFamily: CORVID.mono, fontSize: 12, color: CORVID.muted }}>{detail}</Typography>
2049
+ </Box>
2050
+ )
2051
+ }
2052
+ ```
2053
+
2054
+ ### ./src/\_controls/OptionField.tsx
2055
+
2056
+ ```tsx
2057
+ import { Box, ButtonBase, Typography } from '@mui/material'
2058
+ import type { KeyboardEvent, ReactNode } from 'react'
2059
+
2060
+ import { attributeSelectionComponent } from '@salsita-ai/sdk/domain'
2061
+
2062
+ import { phx } from '../../.phoenix/phx.js'
2063
+ import { CORVID, LABEL_SX, bracketsSx } from '../_shared/tokens.js'
2064
+ import { SpecRow } from './SpecRow.js'
2065
+
2066
+ // Arrow keys move the selection between the tiles, as in a native radio group
2067
+ const ARROW_STEPS: Readonly<Record<string, number | undefined>> = {
2068
+ ArrowRight: 1,
2069
+ ArrowDown: 1,
2070
+ ArrowLeft: -1,
2071
+ ArrowUp: -1,
2072
+ }
2073
+
2074
+ interface OptionFieldProps {
2075
+ readonly instanceName: string
2076
+ readonly label: string
2077
+ // How each option looks, keyed by the spec's option values
2078
+ readonly visuals: Readonly<Record<string, ReactNode>>
2079
+ readonly readOnly: boolean
2080
+ }
2081
+
2082
+ const OptionTiles = ({ instanceName, label, visuals, readOnly }: OptionFieldProps) => {
2083
+ const resolve = phx.frontend.useResolveLocalizable()
2084
+ const droneId = phx.frontend.useRootEntityId()
2085
+ const { value, options } = phx.frontend.useAttributeSelection(droneId, instanceName)
2086
+ const selected = options.find(option => option.value === value)
2087
+ const hint = options.find(option => option.disabled && option.hint)?.hint
2088
+
2089
+ if (readOnly) {
2090
+ return (
2091
+ <SpecRow
2092
+ label={label}
2093
+ value={resolve(selected?.title) ?? ''}
2094
+ detail={resolve(selected?.description)}
2095
+ visual={visuals[String(value)]}
2096
+ />
2097
+ )
2098
+ }
2099
+
2100
+ const handleKeyDown = (event: KeyboardEvent<HTMLElement>, index: number) => {
2101
+ const step = ARROW_STEPS[event.key]
2102
+
2103
+ if (step === undefined) {
2104
+ return
2105
+ }
2106
+
2107
+ event.preventDefault()
2108
+ // The next enabled option in the arrow's direction, wrapping around
2109
+ const count = options.length
2110
+ const next = Array.from(
2111
+ { length: count - 1 },
2112
+ (_, offset) => (((index + step * (offset + 1)) % count) + count) % count
2113
+ ).find(candidate => !options[candidate]?.disabled)
2114
+
2115
+ if (next === undefined) {
2116
+ return
2117
+ }
2118
+
2119
+ options[next]?.onSelect()
2120
+ event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="radio"]')[next]?.focus()
2121
+ }
2122
+
2123
+ return (
2124
+ <Box component="section" sx={{ display: 'grid', gap: 1.25 }}>
2125
+ <Box component="div" sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 2 }}>
2126
+ <Typography component="h3" id={`corvid-${instanceName}-label`} sx={LABEL_SX}>
2127
+ {label}
2128
+ </Typography>
2129
+ <Typography sx={{ fontFamily: CORVID.mono, fontSize: 12, color: CORVID.lime }}>
2130
+ {resolve(selected?.title)}
2131
+ </Typography>
2132
+ </Box>
2133
+
2134
+ <Box
2135
+ component="div"
2136
+ role="radiogroup"
2137
+ aria-labelledby={`corvid-${instanceName}-label`}
2138
+ sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(66px, 1fr))', gap: 1 }}
2139
+ >
2140
+ {options.map((option, index) => {
2141
+ const isSelected = option.value === value
2142
+ const title = resolve(option.title)
2143
+
2144
+ return (
2145
+ <ButtonBase
2146
+ key={String(option.value)}
2147
+ role="radio"
2148
+ aria-checked={isSelected}
2149
+ aria-disabled={option.disabled}
2150
+ title={option.hint ?? title}
2151
+ tabIndex={isSelected ? 0 : -1}
2152
+ onClick={option.onSelect}
2153
+ onKeyDown={event => handleKeyDown(event, index)}
2154
+ sx={{
2155
+ display: 'grid',
2156
+ justifyItems: 'start',
2157
+ alignContent: 'space-between',
2158
+ gap: 1.25,
2159
+ minBlockSize: 80,
2160
+ padding: 1.25,
2161
+ textAlign: 'start',
2162
+ color: isSelected ? CORVID.lime : CORVID.ink,
2163
+ border: `1px solid ${isSelected ? 'rgba(198, 244, 50, 0.5)' : CORVID.line}`,
2164
+ bgcolor: isSelected ? 'rgba(198, 244, 50, 0.08)' : 'rgba(232, 237, 242, 0.02)',
2165
+ ...(isSelected ? bracketsSx(CORVID.lime, 7) : {}),
2166
+ // A ruled-out option is struck through with a hatch; it stays clickable, and the change it makes is
2167
+ // rejected with the rule's message in the log
2168
+ ...(option.disabled && {
2169
+ opacity: 0.42,
2170
+ backgroundImage:
2171
+ 'repeating-linear-gradient(135deg, transparent 0 6px, rgba(232, 237, 242, 0.08) 6px 7px)',
2172
+ }),
2173
+ transition: 'border-color 160ms, background-color 160ms, color 160ms',
2174
+ '&:hover': { borderColor: option.disabled ? CORVID.line : 'rgba(198, 244, 50, 0.5)' },
2175
+ '&.Mui-focusVisible': { outline: `1px solid ${CORVID.lime}`, outlineOffset: 2 },
2176
+ }}
2177
+ >
2178
+ <Box component="span" aria-hidden sx={{ display: 'grid', inlineSize: 28, blockSize: 28 }}>
2179
+ {visuals[String(option.value)]}
2180
+ </Box>
2181
+ <Typography
2182
+ component="span"
2183
+ sx={{ fontFamily: CORVID.heading, fontWeight: 500, fontSize: 12.5, lineHeight: 1.2 }}
2184
+ >
2185
+ {title}
2186
+ </Typography>
2187
+ </ButtonBase>
2188
+ )
2189
+ })}
2190
+ </Box>
2191
+
2192
+ <Typography
2193
+ sx={{
2194
+ minBlockSize: '2.8em',
2195
+ fontFamily: CORVID.mono,
2196
+ fontSize: 11.5,
2197
+ lineHeight: 1.4,
2198
+ color: hint ? CORVID.orange : CORVID.muted,
2199
+ }}
2200
+ >
2201
+ {hint ?? resolve(selected?.description)}
2202
+ </Typography>
2203
+ </Box>
2204
+ )
2205
+ }
2206
+
2207
+ // One `attributeSelection` of the spec as a group of tiles, or a spec-sheet line for a viewer. An option an
2208
+ // invariant rules out comes back `disabled` with the rule's message as `hint`. The field exists only while the spec's
2209
+ // component is active: the control hooks throw for an inactive component.
2210
+ export const OptionField = (props: OptionFieldProps) => {
2211
+ const droneId = phx.frontend.useRootEntityId()
2212
+ const component = phx.frontend.useComponent({
2213
+ entityId: droneId,
2214
+ componentType: attributeSelectionComponent,
2215
+ instanceName: props.instanceName,
2216
+ })
2217
+
2218
+ return component === null ? null : <OptionTiles {...props} />
2219
+ }
2220
+ ```
2221
+
2222
+ ### ./src/\_controls/SpecRow.tsx
2223
+
2224
+ ```tsx
2225
+ import { Box, Typography } from '@mui/material'
2226
+ import type { ReactNode } from 'react'
2227
+
2228
+ import { CORVID, LABEL_SX } from '../_shared/tokens.js'
2229
+
2230
+ interface SpecRowProps {
2231
+ readonly label: string
2232
+ readonly value: string
2233
+ readonly detail?: string | undefined
2234
+ readonly visual?: ReactNode
2235
+ }
2236
+
2237
+ // One control as a line of the spec sheet, which is what a viewer sees in place of the control
2238
+ export const SpecRow = ({ label, value, detail, visual }: SpecRowProps) => {
2239
+ return (
2240
+ <Box
2241
+ component="div"
2242
+ sx={{ display: 'grid', gap: 0.5, paddingBlockEnd: 1.5, borderBlockEnd: `1px dashed ${CORVID.line}` }}
2243
+ >
2244
+ <Box component="div" sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
2245
+ <Typography component="span" sx={{ ...LABEL_SX, flex: 1 }}>
2246
+ {label}
2247
+ </Typography>
2248
+ {visual !== undefined && (
2249
+ <Box component="span" aria-hidden sx={{ display: 'grid', color: CORVID.cyan, inlineSize: 22, blockSize: 22 }}>
2250
+ {visual}
2251
+ </Box>
2252
+ )}
2253
+ <Typography
2254
+ component="span"
2255
+ sx={{ fontFamily: CORVID.heading, fontWeight: 500, fontSize: 15, color: CORVID.ink }}
2256
+ >
2257
+ {value}
2258
+ </Typography>
2259
+ </Box>
2260
+ {detail && (
2261
+ <Typography sx={{ fontFamily: CORVID.mono, fontSize: 11.5, color: CORVID.muted }}>{detail}</Typography>
2262
+ )}
2263
+ </Box>
2264
+ )
2265
+ }
2266
+ ```
2267
+
2268
+ ### ./src/\_controls/visuals.tsx
2269
+
2270
+ ```tsx
2271
+ import { Box } from '@mui/material'
2272
+ import type { ReactNode } from 'react'
2273
+
2274
+ // How each option of the spec looks on its tile, keyed by the option's value: line drawings for the parts, colour
2275
+ // chips for the finishes. They are the layout's own; the spec's options carry no images.
2276
+
2277
+ const glyph = (path: string) => (
2278
+ <svg viewBox="0 0 24 24" width="100%" height="100%" fill="none" stroke="currentColor" strokeWidth={1.4}>
2279
+ <path d={path} strokeLinecap="round" strokeLinejoin="round" />
2280
+ </svg>
2281
+ )
2282
+
2283
+ const chip = (background: string) => (
2284
+ <Box
2285
+ component="span"
2286
+ sx={{
2287
+ display: 'block',
2288
+ inlineSize: '100%',
2289
+ blockSize: '100%',
2290
+ borderRadius: '50%',
2291
+ background,
2292
+ boxShadow: 'inset 0 0 0 1px rgba(255, 255, 255, 0.18)',
2293
+ }}
2294
+ />
2295
+ )
2296
+
2297
+ // A shell colour with a dot of the lights' colour
2298
+ const livery = (shell: string, lights: string) =>
2299
+ chip(`radial-gradient(circle at 72% 72%, ${lights} 0 16%, transparent 19%), ${shell}`)
2300
+
2301
+ export const FRAME_VISUALS: Readonly<Record<string, ReactNode>> = {
2302
+ Quad: glyph(
2303
+ 'M8 8l8 8M16 8l-8 8M10 12a2 2 0 1 0 4 0a2 2 0 1 0-4 0M3.5 5.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0M15.5 5.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0M3.5 18.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0M15.5 18.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0'
2304
+ ),
2305
+ Hexa: glyph(
2306
+ 'M5 12h14M8.5 6l7 12M15.5 6l-7 12M1.5 12a2 2 0 1 0 4 0a2 2 0 1 0-4 0M18.5 12a2 2 0 1 0 4 0a2 2 0 1 0-4 0M5.5 4.5a2 2 0 1 0 4 0a2 2 0 1 0-4 0M14.5 4.5a2 2 0 1 0 4 0a2 2 0 1 0-4 0M5.5 19.5a2 2 0 1 0 4 0a2 2 0 1 0-4 0M14.5 19.5a2 2 0 1 0 4 0a2 2 0 1 0-4 0'
2307
+ ),
2308
+ }
2309
+
2310
+ export const PROP_VISUALS: Readonly<Record<string, ReactNode>> = {
2311
+ TwoBlade: glyph(
2312
+ 'M10.5 12a1.5 1.5 0 1 0 3 0a1.5 1.5 0 1 0-3 0M12 10.5C11 6 12.5 3 14 2.5c.8 2-.2 5.5-2 8M12 13.5c1 4.5-.5 7.5-2 8c-.8-2 .2-5.5 2-8'
2313
+ ),
2314
+ ThreeBlade: glyph(
2315
+ 'M10.5 12a1.5 1.5 0 1 0 3 0a1.5 1.5 0 1 0-3 0M12 10.5C11 6 12.5 3 14 2.5c.8 2-.2 5.5-2 8M13.3 12.8c4 1.8 6.2 4.4 6 6c-2 .5-5-1.5-6.6-4.6M10.7 12.8c-3.5 2.7-6.9 3.1-8 2c.7-1.9 4-3.6 7.2-3.6'
2316
+ ),
2317
+ }
2318
+
2319
+ export const PAYLOAD_VISUALS: Readonly<Record<string, ReactNode>> = {
2320
+ Camera: glyph('M4 7.5h16v11H4zM8.5 13a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0M12 4.5v3M9 4.5h6'),
2321
+ Lidar: glyph(
2322
+ 'M6 6.5c0-1.4 2.7-2.5 6-2.5s6 1.1 6 2.5v11c0 1.4-2.7 2.5-6 2.5s-6-1.1-6-2.5zM6 10.5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5M2 12h2M20 12h2'
2323
+ ),
2324
+ Winch: glyph('M6 3.5h12v6H6zM12 9.5v7M12 16.5a2.5 2.5 0 1 1-2.5 2.5'),
2325
+ }
2326
+
2327
+ export const MOTOR_VISUALS: Readonly<Record<string, ReactNode>> = {
2328
+ Titanium: chip('linear-gradient(135deg, #e3e6ea 0%, #8c939b 55%, #c9ced3 100%)'),
2329
+ Lime: chip('linear-gradient(135deg, #e3f78c 0%, #9cc417 55%, #c6f432 100%)'),
2330
+ Cyan: chip('linear-gradient(135deg, #9beff7 0%, #1aa3b5 55%, #3ed6e8 100%)'),
2331
+ Ember: chip('linear-gradient(135deg, #ffc196 0%, #e2561a 55%, #ff8a3d 100%)'),
2332
+ }
2333
+
2334
+ export const LIVERY_VISUALS: Readonly<Record<string, ReactNode>> = {
2335
+ Graphite: livery('#4a525c', '#c6f432'),
2336
+ Arctic: livery('#e9edf0', '#3ed6e8'),
2337
+ Signal: livery('#ff6a1a', '#f4f7fa'),
2338
+ }
2339
+ ```
2340
+
2341
+ ### ./src/\_shared/DesktopDeck.tsx
2342
+
2343
+ ```tsx
2344
+ import { Box, Typography } from '@mui/material'
2345
+
2346
+ import { phx } from '../../.phoenix/phx.js'
2347
+ import { DroneTabs } from './DroneTabs.js'
2348
+ import { HudReadout } from './HudReadout.js'
2349
+ import { MessageLog } from './MessageLog.js'
2350
+ import { TabPanel } from './TabPanel.js'
2351
+ import { ViewDock } from './ViewDock.js'
2352
+ import { CORVID, GLASS_SX, bracketsSx } from './tokens.js'
2353
+
2354
+ const RAIL_WIDTH = 84
2355
+ const PANEL_WIDTH = 384
2356
+ const GUTTER = 20
2357
+
2358
+ // The brand's mark: a hexagon with a wing's chevron
2359
+ const MARK = 'M16 3 27 9.5v13L16 29 5 22.5v-13ZM10 19l6-6 6 6'
2360
+
2361
+ // The desktop deck: the tab rail on the left, the active tab's panel beside it, and the free area over the rest of
2362
+ // the scene with the HUD at its top right, the message log and the view dock along its bottom. Fullscreen is a
2363
+ // preview: only the view dock stays, and the drone takes the whole canvas.
2364
+ export const DesktopDeck = () => {
2365
+ const { containerRef, safeAreaRef } = phx.frontend.useSceneSafeArea()
2366
+ const isFullscreen = phx.frontend.useIsFullscreenActive()
2367
+ const { accessInfo } = phx.frontend.useAccessService()
2368
+
2369
+ return (
2370
+ <Box
2371
+ component="div"
2372
+ ref={containerRef}
2373
+ sx={{ position: 'absolute', inset: 0, display: 'flex', fontFamily: CORVID.mono, pointerEvents: 'none' }}
2374
+ >
2375
+ {!isFullscreen && (
2376
+ <>
2377
+ <Box
2378
+ component="nav"
2379
+ aria-label="Corvid K6"
2380
+ sx={{
2381
+ flex: `0 0 ${RAIL_WIDTH}px`,
2382
+ display: 'flex',
2383
+ flexDirection: 'column',
2384
+ gap: 3,
2385
+ paddingBlock: 2.5,
2386
+ paddingInline: 1,
2387
+ bgcolor: 'rgba(12, 14, 18, 0.9)',
2388
+ borderInlineEnd: `1px solid ${CORVID.line}`,
2389
+ pointerEvents: 'auto',
2390
+ }}
2391
+ >
2392
+ <Box component="div" sx={{ display: 'grid', justifyItems: 'center', gap: 0.5, color: CORVID.lime }}>
2393
+ <svg viewBox="0 0 32 32" width={30} height={30} fill="none" stroke="currentColor" strokeWidth={1.5}>
2394
+ <path d={MARK} strokeLinejoin="round" />
2395
+ </svg>
2396
+ <Typography sx={{ fontFamily: CORVID.mono, fontSize: 11, letterSpacing: '0.2em', color: CORVID.ink }}>
2397
+ K6
2398
+ </Typography>
2399
+ </Box>
2400
+ <DroneTabs orientation="vertical" />
2401
+ </Box>
2402
+
2403
+ <Box
2404
+ component="div"
2405
+ sx={{
2406
+ flex: `0 0 ${PANEL_WIDTH}px`,
2407
+ display: 'flex',
2408
+ minBlockSize: 0,
2409
+ padding: `${GUTTER}px 0 ${GUTTER}px ${GUTTER}px`,
2410
+ }}
2411
+ >
2412
+ <Box
2413
+ component="div"
2414
+ sx={{
2415
+ ...GLASS_SX,
2416
+ // Lime corners while editing, cyan on the spec sheet, as the HUD's mode
2417
+ ...bracketsSx(accessInfo.viewMode ? CORVID.cyan : CORVID.lime),
2418
+ flex: 1,
2419
+ overflowY: 'auto',
2420
+ overscrollBehavior: 'contain',
2421
+ padding: 3,
2422
+ pointerEvents: 'auto',
2423
+ scrollbarWidth: 'thin',
2424
+ scrollbarColor: `${CORVID.line} transparent`,
2425
+ }}
2426
+ >
2427
+ <TabPanel compact={false} />
2428
+ </Box>
2429
+ </Box>
2430
+ </>
2431
+ )}
2432
+
2433
+ <Box component="div" sx={{ position: 'relative', flex: '1 1 0', minInlineSize: 0 }}>
2434
+ {/* The camera fits the drone into this box: below the HUD, above the dock, clear of the edges */}
2435
+ <Box component="div" ref={safeAreaRef} sx={{ position: 'absolute', inset: '112px 24px 84px' }} />
2436
+ {!isFullscreen && (
2437
+ <Box
2438
+ component="div"
2439
+ sx={{ position: 'absolute', insetBlockStart: GUTTER, insetInlineEnd: GUTTER, pointerEvents: 'auto' }}
2440
+ >
2441
+ <HudReadout compact={false} />
2442
+ </Box>
2443
+ )}
2444
+ <Box
2445
+ component="div"
2446
+ sx={{
2447
+ position: 'absolute',
2448
+ insetBlockEnd: GUTTER,
2449
+ insetInline: GUTTER,
2450
+ display: 'flex',
2451
+ alignItems: 'flex-end',
2452
+ justifyContent: 'space-between',
2453
+ gap: 3,
2454
+ }}
2455
+ >
2456
+ {!isFullscreen && <MessageLog compact={false} />}
2457
+ <Box component="div" sx={{ flex: 'none', marginInlineStart: 'auto', pointerEvents: 'auto' }}>
2458
+ <ViewDock placement="desktop" />
2459
+ </Box>
2460
+ </Box>
2461
+ </Box>
2462
+ </Box>
2463
+ )
2464
+ }
2465
+ ```
2466
+
2467
+ ### ./src/\_shared/DroneTabs.tsx
2468
+
2469
+ ```tsx
2470
+ import { Box, ButtonBase, Typography } from '@mui/material'
2471
+ import type { KeyboardEvent } from 'react'
2472
+
2473
+ import { PhxIcon } from '@salsita-ai/sdk/ui'
2474
+
2475
+ import { phx } from '../../.phoenix/phx.js'
2476
+ import { CORVID } from './tokens.js'
2477
+
2478
+ // The panel the tabs control; `TabPanel` carries this id
2479
+ export const TAB_PANEL_ID = 'corvid-tab-panel'
2480
+
2481
+ interface DroneTabsProps {
2482
+ readonly orientation: 'vertical' | 'horizontal'
2483
+ }
2484
+
2485
+ // The spec's `sidebarTabs` as a tab list: a rail of icons on a desktop, a segmented bar at the bottom of a phone.
2486
+ // `useTabNavigation` gives each tab its resolved label, the PhxIcon name the spec sets and `isActive`; `select` enters
2487
+ // the tab's navigation state, and the controls the spec filters to that state become active. Switching tabs is
2488
+ // navigation, not editing, so a viewer switches too.
2489
+ export const DroneTabs = ({ orientation }: DroneTabsProps) => {
2490
+ const { tabs, select } = phx.frontend.useTabNavigation()
2491
+ const vertical = orientation === 'vertical'
2492
+ const nextKey = vertical ? 'ArrowDown' : 'ArrowRight'
2493
+ const previousKey = vertical ? 'ArrowUp' : 'ArrowLeft'
2494
+
2495
+ // The arrow keys move between the tabs and enter the one they reach, as in a native tab list
2496
+ const handleKeyDown = (event: KeyboardEvent<HTMLElement>, index: number) => {
2497
+ const step = event.key === nextKey ? 1 : event.key === previousKey ? -1 : 0
2498
+
2499
+ if (step === 0) {
2500
+ return
2501
+ }
2502
+
2503
+ event.preventDefault()
2504
+ const next = (index + step + tabs.length) % tabs.length
2505
+ select(next)
2506
+ event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')[next]?.focus()
2507
+ }
2508
+
2509
+ return (
2510
+ <Box
2511
+ component="div"
2512
+ role="tablist"
2513
+ aria-label="Configuration"
2514
+ aria-orientation={orientation}
2515
+ sx={{ display: 'flex', flexDirection: vertical ? 'column' : 'row', gap: vertical ? 0.5 : 0, blockSize: '100%' }}
2516
+ >
2517
+ {tabs.map(tab => (
2518
+ <ButtonBase
2519
+ key={tab.state}
2520
+ role="tab"
2521
+ id={`corvid-tab-${tab.index}`}
2522
+ aria-selected={tab.isActive}
2523
+ aria-controls={TAB_PANEL_ID}
2524
+ tabIndex={tab.isActive ? 0 : -1}
2525
+ onClick={() => select(tab.index)}
2526
+ onKeyDown={event => handleKeyDown(event, tab.index)}
2527
+ sx={{
2528
+ position: 'relative',
2529
+ flex: vertical ? 'none' : '1 1 0',
2530
+ display: 'flex',
2531
+ flexDirection: 'column',
2532
+ alignItems: 'center',
2533
+ justifyContent: 'center',
2534
+ gap: 0.75,
2535
+ minBlockSize: vertical ? 68 : 'auto',
2536
+ color: tab.isActive ? CORVID.lime : CORVID.muted,
2537
+ bgcolor: tab.isActive ? 'rgba(198, 244, 50, 0.07)' : 'transparent',
2538
+ transition: 'color 160ms, background-color 160ms',
2539
+ // The active tab's marker: a bar on the rail's outer edge, or along the top of the bar's segment
2540
+ '&::before': {
2541
+ content: '""',
2542
+ position: 'absolute',
2543
+ insetBlock: vertical ? '14px' : '0 auto',
2544
+ insetInlineStart: vertical ? 0 : '18%',
2545
+ insetInlineEnd: vertical ? 'auto' : '18%',
2546
+ inlineSize: vertical ? 2 : 'auto',
2547
+ blockSize: vertical ? 'auto' : 2,
2548
+ bgcolor: CORVID.lime,
2549
+ opacity: tab.isActive ? 1 : 0,
2550
+ transition: 'opacity 160ms',
2551
+ },
2552
+ '&:hover': { color: tab.isActive ? CORVID.lime : CORVID.ink },
2553
+ '&.Mui-focusVisible': { outline: `1px solid ${CORVID.lime}`, outlineOffset: -4 },
2554
+ }}
2555
+ >
2556
+ {tab.icon !== undefined && <PhxIcon name={tab.icon} fontSize="medium" />}
2557
+ <Typography
2558
+ component="span"
2559
+ sx={{ fontFamily: CORVID.mono, fontSize: 9.5, letterSpacing: '0.14em', textTransform: 'uppercase' }}
2560
+ >
2561
+ {tab.label}
2562
+ </Typography>
2563
+ </ButtonBase>
2564
+ ))}
2565
+ </Box>
2566
+ )
2567
+ }
2568
+ ```
2569
+
2570
+ ### ./src/\_shared/HudReadout.tsx
2571
+
2572
+ ```tsx
2573
+ import { Box, Typography } from '@mui/material'
2574
+
2575
+ import { detailComponent, viewControlsComponent } from '@salsita-ai/sdk/domain'
2576
+
2577
+ import { phx } from '../../.phoenix/phx.js'
2578
+ import { CORVID, GLASS_SX, LABEL_SX, bracketsSx } from './tokens.js'
2579
+
2580
+ interface HudReadoutProps {
2581
+ readonly compact: boolean
2582
+ }
2583
+
2584
+ // The HUD's readouts: the price, the all-up weight, the active view and the mode. The weight is the subtitle of the
2585
+ // spec's `detail` component, which an expression computes from the choices; the view's name is the label the spec's
2586
+ // `viewControls` give it. A viewer sees the mode change to the spec sheet.
2587
+ export const HudReadout = ({ compact }: HudReadoutProps) => {
2588
+ const detail = phx.frontend.useFirstRootComponent(detailComponent)
2589
+ const name = phx.frontend.useLocalizable(detail?.state.title)
2590
+ const weight = phx.frontend.useLocalizable(detail?.state.subtitle)
2591
+ const price = phx.frontend.useRootEntityPrice()
2592
+ const viewControls = phx.frontend.useFirstRootComponent(viewControlsComponent)
2593
+ const viewState = phx.frontend.useActiveViewState()
2594
+ const resolve = phx.frontend.useResolveLocalizable()
2595
+ const { accessInfo } = phx.frontend.useAccessService()
2596
+ const readOnly = accessInfo.viewMode
2597
+ const view = viewControls?.state.items.find(item => item.value === viewState)
2598
+ const modeColor = readOnly ? CORVID.cyan : CORVID.lime
2599
+
2600
+ const mode = (
2601
+ <Typography
2602
+ component="span"
2603
+ sx={{
2604
+ display: 'inline-flex',
2605
+ alignItems: 'center',
2606
+ gap: 1,
2607
+ fontFamily: CORVID.mono,
2608
+ fontSize: compact ? 9.5 : 10.5,
2609
+ letterSpacing: '0.16em',
2610
+ color: modeColor,
2611
+ whiteSpace: 'nowrap',
2612
+ // A steady square for the spec sheet, a pulsing dot while editing
2613
+ '&::before': {
2614
+ content: '""',
2615
+ inlineSize: 7,
2616
+ blockSize: 7,
2617
+ borderRadius: readOnly ? 0 : '50%',
2618
+ bgcolor: modeColor,
2619
+ animation: readOnly ? 'none' : 'corvid-pulse 1.6s ease-in-out infinite',
2620
+ },
2621
+ '@keyframes corvid-pulse': { '50%': { opacity: 0.25 } },
2622
+ }}
2623
+ >
2624
+ {readOnly ? 'SPEC SHEET · VIEW ONLY' : 'ENGINEERING · EDIT'}
2625
+ </Typography>
2626
+ )
2627
+
2628
+ if (compact) {
2629
+ return (
2630
+ <Box
2631
+ component="div"
2632
+ role="group"
2633
+ aria-label="Readouts"
2634
+ sx={{
2635
+ ...GLASS_SX,
2636
+ display: 'grid',
2637
+ gridTemplateColumns: '1fr auto',
2638
+ alignItems: 'center',
2639
+ columnGap: 2,
2640
+ rowGap: 0.5,
2641
+ paddingInline: 1.5,
2642
+ paddingBlock: 1,
2643
+ }}
2644
+ >
2645
+ <Typography sx={{ fontFamily: CORVID.heading, fontWeight: 600, fontSize: 18, lineHeight: 1.2 }}>
2646
+ {price}
2647
+ </Typography>
2648
+ <Typography sx={{ fontFamily: CORVID.mono, fontSize: 12, color: CORVID.ink, textAlign: 'end' }}>
2649
+ {weight}
2650
+ </Typography>
2651
+ {mode}
2652
+ <Typography sx={{ ...LABEL_SX, fontSize: 9.5, textAlign: 'end' }}>{name}</Typography>
2653
+ </Box>
2654
+ )
2655
+ }
2656
+
2657
+ return (
2658
+ <Box
2659
+ component="div"
2660
+ role="group"
2661
+ aria-label="Readouts"
2662
+ sx={{
2663
+ ...GLASS_SX,
2664
+ ...bracketsSx(modeColor, 8),
2665
+ display: 'grid',
2666
+ gap: 1,
2667
+ minInlineSize: 300,
2668
+ paddingInline: 2.25,
2669
+ paddingBlock: 1.75,
2670
+ }}
2671
+ >
2672
+ <Box component="div" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 3 }}>
2673
+ <Typography sx={LABEL_SX}>{name}</Typography>
2674
+ {mode}
2675
+ </Box>
2676
+ <Typography sx={{ fontFamily: CORVID.heading, fontWeight: 600, fontSize: 30, lineHeight: 1.1 }}>
2677
+ {price}
2678
+ </Typography>
2679
+ <Box
2680
+ component="div"
2681
+ sx={{ display: 'flex', gap: 2.5, fontFamily: CORVID.mono, fontSize: 12, letterSpacing: '0.08em' }}
2682
+ >
2683
+ <Box component="span">{weight}</Box>
2684
+ {view && (
2685
+ <Box component="span" sx={{ color: CORVID.muted }}>
2686
+ VIEW {resolve(view.label)?.toUpperCase()}
2687
+ </Box>
2688
+ )}
2689
+ </Box>
2690
+ </Box>
2691
+ )
2692
+ }
2693
+ ```
2694
+
2695
+ ### ./src/\_shared/MessageLog.tsx
2696
+
2697
+ ```tsx
2698
+ import { Box } from '@mui/material'
2699
+ import { useEffect, useState } from 'react'
2700
+
2701
+ import type { ConfiguratorMessage } from '@salsita-ai/sdk/react'
2702
+
2703
+ import { phx } from '../../.phoenix/phx.js'
2704
+ import { CORVID, GLASS_SX } from './tokens.js'
2705
+
2706
+ const VISIBLE_MS = 9000
2707
+
2708
+ interface LogEntry {
2709
+ readonly message: ConfiguratorMessage
2710
+ readonly time: string
2711
+ }
2712
+
2713
+ // The tag before each line, by the kind of message and, for a violation, its severity
2714
+ const tagOf = (message: ConfiguratorMessage) => {
2715
+ if (message.kind === 'violation') {
2716
+ return message.violation.severity === 'fixable' || message.violation.severity === 'fatal'
2717
+ ? { label: 'REJECTED', color: CORVID.orange }
2718
+ : { label: message.violation.severity.toUpperCase(), color: CORVID.cyan }
2719
+ }
2720
+
2721
+ if (message.kind === 'sideEffect') {
2722
+ return { label: 'NOTE', color: CORVID.cyan }
2723
+ }
2724
+
2725
+ if (message.kind === 'saveRecovery') {
2726
+ return { label: 'SAVED', color: CORVID.lime }
2727
+ }
2728
+
2729
+ return { label: 'ERROR', color: CORVID.orange }
2730
+ }
2731
+
2732
+ interface MessageLogProps {
2733
+ readonly compact: boolean
2734
+ }
2735
+
2736
+ // The configurator's messages as the HUD's log line: a rejected change, the hint a change carries, a failed save.
2737
+ // `useConfiguratorMessages` hands each one over with its text resolved; the line shows the latest for a few seconds,
2738
+ // then returns to its idle state.
2739
+ export const MessageLog = ({ compact }: MessageLogProps) => {
2740
+ const [entry, setEntry] = useState<LogEntry | null>(null)
2741
+ const { accessInfo } = phx.frontend.useAccessService()
2742
+
2743
+ phx.frontend.useConfiguratorMessages(message => {
2744
+ setEntry({ message, time: new Date().toLocaleTimeString('en-GB') })
2745
+ })
2746
+
2747
+ useEffect(() => {
2748
+ if (entry === null) {
2749
+ return
2750
+ }
2751
+
2752
+ const timeout = setTimeout(() => setEntry(null), VISIBLE_MS)
2753
+
2754
+ return () => clearTimeout(timeout)
2755
+ }, [entry])
2756
+
2757
+ const tag = entry ? tagOf(entry.message) : { label: 'READY', color: CORVID.faint }
2758
+ const idle = accessInfo.viewMode ? 'Viewing a shared configuration' : 'Every change saves on its own'
2759
+
2760
+ return (
2761
+ <Box
2762
+ component="div"
2763
+ role="status"
2764
+ aria-live="polite"
2765
+ sx={{
2766
+ ...GLASS_SX,
2767
+ display: 'flex',
2768
+ alignItems: 'baseline',
2769
+ gap: compact ? 1 : 1.5,
2770
+ minInlineSize: 0,
2771
+ maxInlineSize: compact ? 'none' : 560,
2772
+ paddingInline: 1.5,
2773
+ paddingBlock: 1,
2774
+ fontFamily: CORVID.mono,
2775
+ fontSize: compact ? 11 : 12,
2776
+ lineHeight: 1.5,
2777
+ }}
2778
+ >
2779
+ <Box component="span" aria-hidden sx={{ color: CORVID.lime }}>
2780
+ &gt;
2781
+ </Box>
2782
+ {entry && !compact && (
2783
+ <Box component="span" sx={{ color: CORVID.faint }}>
2784
+ {entry.time}
2785
+ </Box>
2786
+ )}
2787
+ <Box component="span" sx={{ flex: 'none', color: tag.color, letterSpacing: '0.12em' }}>
2788
+ {tag.label}
2789
+ </Box>
2790
+ <Box component="span" sx={{ minInlineSize: 0, color: entry ? CORVID.ink : CORVID.muted }}>
2791
+ {entry ? entry.message.text : idle}
2792
+ </Box>
2793
+ </Box>
2794
+ )
2795
+ }
2796
+ ```
2797
+
2798
+ ### ./src/\_shared/PhoneDeck.tsx
2799
+
2800
+ ```tsx
2801
+ import { Box } from '@mui/material'
2802
+
2803
+ import { phx } from '../../.phoenix/phx.js'
2804
+ import { DroneTabs } from './DroneTabs.js'
2805
+ import { HudReadout } from './HudReadout.js'
2806
+ import { MessageLog } from './MessageLog.js'
2807
+ import { TabPanel } from './TabPanel.js'
2808
+ import { ViewDock } from './ViewDock.js'
2809
+ import { CORVID, GLASS_SX } from './tokens.js'
2810
+
2811
+ const SHEET_HEIGHT = 312
2812
+ const BAR_HEIGHT = 64
2813
+ const GUTTER = 12
2814
+
2815
+ // The phone deck: the scene at the top with the HUD over it, a sheet of fixed height with the active tab's controls,
2816
+ // and the tabs as a segmented bar at the bottom. The sheet keeps its height on every tab, so the drone does not jump
2817
+ // when the tab changes. Fullscreen is a preview: only the reset and the exit stay.
2818
+ export const PhoneDeck = () => {
2819
+ const { containerRef, safeAreaRef } = phx.frontend.useSceneSafeArea()
2820
+ const isFullscreen = phx.frontend.useIsFullscreenActive()
2821
+
2822
+ return (
2823
+ <Box
2824
+ component="div"
2825
+ ref={containerRef}
2826
+ sx={{
2827
+ position: 'absolute',
2828
+ inset: 0,
2829
+ display: 'flex',
2830
+ flexDirection: 'column',
2831
+ fontFamily: CORVID.mono,
2832
+ pointerEvents: 'none',
2833
+ }}
2834
+ >
2835
+ <Box component="div" sx={{ position: 'relative', flex: '1 1 0', minBlockSize: 0 }}>
2836
+ {/* The camera fits the drone into this box: below the HUD, above the log and the view controls */}
2837
+ <Box component="div" ref={safeAreaRef} sx={{ position: 'absolute', inset: '92px 16px 72px' }} />
2838
+ {!isFullscreen && (
2839
+ <Box
2840
+ component="div"
2841
+ sx={{ position: 'absolute', insetBlockStart: GUTTER, insetInline: GUTTER, pointerEvents: 'auto' }}
2842
+ >
2843
+ <HudReadout compact />
2844
+ </Box>
2845
+ )}
2846
+ <Box
2847
+ component="div"
2848
+ sx={{
2849
+ position: 'absolute',
2850
+ insetBlockEnd: GUTTER,
2851
+ insetInline: GUTTER,
2852
+ display: 'flex',
2853
+ alignItems: 'flex-end',
2854
+ justifyContent: 'space-between',
2855
+ gap: 1,
2856
+ }}
2857
+ >
2858
+ {!isFullscreen && <MessageLog compact />}
2859
+ <Box component="div" sx={{ flex: 'none', marginInlineStart: 'auto', pointerEvents: 'auto' }}>
2860
+ <ViewDock placement="phone" />
2861
+ </Box>
2862
+ </Box>
2863
+ </Box>
2864
+
2865
+ {!isFullscreen && (
2866
+ <>
2867
+ <Box
2868
+ component="div"
2869
+ sx={{
2870
+ ...GLASS_SX,
2871
+ flex: `0 0 ${SHEET_HEIGHT}px`,
2872
+ overflowY: 'auto',
2873
+ overscrollBehavior: 'contain',
2874
+ padding: 2.5,
2875
+ borderInline: 'none',
2876
+ borderBlockEnd: 'none',
2877
+ pointerEvents: 'auto',
2878
+ }}
2879
+ >
2880
+ <TabPanel compact />
2881
+ </Box>
2882
+ <Box
2883
+ component="nav"
2884
+ aria-label="Corvid K6"
2885
+ sx={{
2886
+ flex: `0 0 ${BAR_HEIGHT}px`,
2887
+ bgcolor: 'rgba(12, 14, 18, 0.96)',
2888
+ borderBlockStart: `1px solid ${CORVID.line}`,
2889
+ pointerEvents: 'auto',
2890
+ }}
2891
+ >
2892
+ <DroneTabs orientation="horizontal" />
2893
+ </Box>
2894
+ </>
2895
+ )}
2896
+ </Box>
2897
+ )
2898
+ }
2899
+ ```
2900
+
2901
+ ### ./src/\_shared/TabPanel.tsx
2902
+
2903
+ ```tsx
2904
+ import { Box, Typography } from '@mui/material'
2905
+
2906
+ import { phx } from '../../.phoenix/phx.js'
2907
+ import { DroneFields } from '../_controls/DroneFields.js'
2908
+ import { TAB_PANEL_ID } from './DroneTabs.js'
2909
+ import { CORVID, LABEL_SX } from './tokens.js'
2910
+
2911
+ interface TabPanelProps {
2912
+ readonly compact: boolean
2913
+ }
2914
+
2915
+ // The active tab's heading and controls. The heading is the tab's label from the spec; the controls are whichever
2916
+ // the spec activates for the tab. A viewer gets the same tabs with every control as a line of the spec sheet.
2917
+ export const TabPanel = ({ compact }: TabPanelProps) => {
2918
+ const { tabs, activeTab } = phx.frontend.useTabNavigation()
2919
+ const { accessInfo } = phx.frontend.useAccessService()
2920
+ const readOnly = accessInfo.viewMode
2921
+ const position = `${String((activeTab?.index ?? 0) + 1).padStart(2, '0')} / ${String(tabs.length).padStart(2, '0')}`
2922
+
2923
+ return (
2924
+ <Box
2925
+ component="section"
2926
+ role="tabpanel"
2927
+ id={TAB_PANEL_ID}
2928
+ aria-labelledby={activeTab ? `corvid-tab-${activeTab.index}` : undefined}
2929
+ sx={{ display: 'grid', gap: compact ? 2 : 3 }}
2930
+ >
2931
+ <Box component="header" sx={{ display: 'grid', gap: 0.5 }}>
2932
+ <Typography sx={{ ...LABEL_SX, color: readOnly ? CORVID.cyan : CORVID.lime }}>
2933
+ {position} · {readOnly ? 'SPEC SHEET' : 'CONFIGURE'}
2934
+ </Typography>
2935
+ <Typography
2936
+ component="h2"
2937
+ sx={{ fontFamily: CORVID.heading, fontWeight: 600, fontSize: compact ? 20 : 26, lineHeight: 1.15 }}
2938
+ >
2939
+ {activeTab?.label}
2940
+ </Typography>
2941
+ </Box>
2942
+ <DroneFields readOnly={readOnly} />
2943
+ </Box>
2944
+ )
2945
+ }
2946
+ ```
2947
+
2948
+ ### ./src/\_shared/ViewDock.tsx
2949
+
2950
+ ```tsx
2951
+ import {
2952
+ Box,
2953
+ Divider,
2954
+ IconButton,
2955
+ ThemeProvider,
2956
+ Tooltip,
2957
+ createTheme,
2958
+ toggleButtonClasses,
2959
+ toggleButtonGroupClasses,
2960
+ } from '@mui/material'
2961
+ import type { Theme } from '@mui/material'
2962
+
2963
+ import { FrontendAnalyticsFactory, LayoutSlots } from '@salsita-ai/sdk/configurator'
2964
+ import { PhxIcon } from '@salsita-ai/sdk/ui'
2965
+
2966
+ import { phx } from '../../.phoenix/phx.js'
2967
+ import { CORVID, GLASS_SX } from './tokens.js'
2968
+
2969
+ // The built-in view controls draw with the MUI theme's palette. A dark palette makes them part of the deck, and with
2970
+ // them the list of views a phone opens, which renders in a popper outside the dock.
2971
+ const deckTheme = (outer: Theme) =>
2972
+ createTheme(outer, {
2973
+ palette: {
2974
+ mode: 'dark',
2975
+ primary: { main: CORVID.lime },
2976
+ text: { primary: CORVID.ink, secondary: CORVID.muted },
2977
+ background: { paper: CORVID.panelSolid },
2978
+ divider: CORVID.line,
2979
+ action: { hover: 'rgba(232, 237, 242, 0.06)' },
2980
+ },
2981
+ })
2982
+
2983
+ interface DockButtonProps {
2984
+ readonly label: string
2985
+ readonly icon: string
2986
+ readonly onClick: () => void
2987
+ }
2988
+
2989
+ const DockButton = ({ label, icon, onClick }: DockButtonProps) => {
2990
+ return (
2991
+ <Tooltip title={label}>
2992
+ <IconButton
2993
+ aria-label={label}
2994
+ onClick={onClick}
2995
+ sx={{
2996
+ inlineSize: 36,
2997
+ blockSize: 36,
2998
+ borderRadius: 0,
2999
+ color: CORVID.muted,
3000
+ '&:hover': { color: CORVID.lime, bgcolor: 'rgba(198, 244, 50, 0.08)' },
3001
+ }}
3002
+ >
3003
+ <PhxIcon name={icon} />
3004
+ </IconButton>
3005
+ </Tooltip>
3006
+ )
3007
+ }
3008
+
3009
+ interface ViewDockProps {
3010
+ readonly placement: 'desktop' | 'phone'
3011
+ }
3012
+
3013
+ // The spec's view controls, mounted from `LayoutSlots`, with the camera reset and fullscreen beside them. On a
3014
+ // desktop `DesktopViewControls` is a row of toggles; on a phone `MobileViewControls` is one button that opens the
3015
+ // list of views, left out in fullscreen as the UX guidelines ask. The dock gives the built-in controls a dark theme
3016
+ // and restyles the toggles through their MUI classes.
3017
+ export const ViewDock = ({ placement }: ViewDockProps) => {
3018
+ const resetCamera = phx.frontend.useResetCamera()
3019
+ const isFullscreen = phx.frontend.useIsFullscreenActive()
3020
+ const setFullscreen = phx.frontend.useSetFullscreen()
3021
+ const analytics = phx.frontend.useAnalytics()
3022
+
3023
+ // The same event the built-in fullscreen button records, for entering and for leaving
3024
+ const toggleFullscreen = () => {
3025
+ analytics.push(FrontendAnalyticsFactory.FullScreenModeToggled())
3026
+ setFullscreen(!isFullscreen)
3027
+ }
3028
+
3029
+ return (
3030
+ <ThemeProvider theme={deckTheme}>
3031
+ <Box
3032
+ component="div"
3033
+ role="toolbar"
3034
+ aria-label="View"
3035
+ sx={{
3036
+ ...GLASS_SX,
3037
+ display: 'flex',
3038
+ alignItems: 'center',
3039
+ gap: 0.5,
3040
+ padding: 0.5,
3041
+ color: CORVID.ink,
3042
+ [`& .${toggleButtonGroupClasses.root}`]: {
3043
+ gap: 0.5,
3044
+ padding: 0,
3045
+ bgcolor: 'transparent',
3046
+ boxShadow: 'none',
3047
+ borderRadius: 0,
3048
+ },
3049
+ [`& .${toggleButtonClasses.root}`]: {
3050
+ gap: 1,
3051
+ minBlockSize: 36,
3052
+ paddingInline: 1.5,
3053
+ border: 0,
3054
+ borderRadius: 0,
3055
+ color: CORVID.muted,
3056
+ '& .MuiTypography-root': {
3057
+ fontFamily: CORVID.mono,
3058
+ fontSize: 11,
3059
+ letterSpacing: '0.14em',
3060
+ textTransform: 'uppercase',
3061
+ },
3062
+ '&:hover': { color: CORVID.ink, bgcolor: 'rgba(232, 237, 242, 0.06)' },
3063
+ },
3064
+ [`& .${toggleButtonClasses.root}.${toggleButtonClasses.selected}`]: {
3065
+ color: CORVID.graphite,
3066
+ bgcolor: CORVID.lime,
3067
+ '&:hover': { bgcolor: CORVID.lime },
3068
+ },
3069
+ // The phone's view button sits on the dock, not on a paper of its own
3070
+ '& .MuiPaper-root': { bgcolor: 'transparent', boxShadow: 'none' },
3071
+ }}
3072
+ >
3073
+ {placement === 'desktop' && <LayoutSlots.DesktopViewControls />}
3074
+ {placement === 'phone' && !isFullscreen && <LayoutSlots.MobileViewControls />}
3075
+ {(placement === 'desktop' || !isFullscreen) && (
3076
+ <Divider orientation="vertical" flexItem sx={{ borderColor: CORVID.line, marginBlock: 0.5 }} />
3077
+ )}
3078
+ <DockButton label="Reset camera" icon="ArrowCounterClockwise" onClick={resetCamera} />
3079
+ <DockButton
3080
+ label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
3081
+ icon={isFullscreen ? 'ArrowsIn' : 'ArrowsOut'}
3082
+ onClick={toggleFullscreen}
3083
+ />
3084
+ </Box>
3085
+ </ThemeProvider>
3086
+ )
3087
+ }
3088
+ ```
3089
+
3090
+ ### ./src/\_shared/tokens.ts
3091
+
3092
+ ```ts
3093
+ // The flight deck's palette and type. Poppins is loaded by the page; labels and figures use the system's monospace.
3094
+ export const CORVID = {
3095
+ graphite: '#14171c',
3096
+ panel: 'rgba(20, 23, 28, 0.72)',
3097
+ panelSolid: '#191d23',
3098
+ ink: '#e8edf2',
3099
+ muted: 'rgba(232, 237, 242, 0.64)',
3100
+ faint: 'rgba(232, 237, 242, 0.4)',
3101
+ line: 'rgba(232, 237, 242, 0.14)',
3102
+ lime: '#c6f432',
3103
+ cyan: '#3ed6e8',
3104
+ orange: '#ff8a3d',
3105
+ mono: "ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace",
3106
+ heading: "Poppins, 'Helvetica Neue', Arial, sans-serif",
3107
+ } as const
3108
+
3109
+ // Small capitals with wide tracking, for labels and readouts
3110
+ export const LABEL_SX = {
3111
+ fontFamily: CORVID.mono,
3112
+ fontSize: 11,
3113
+ letterSpacing: '0.14em',
3114
+ textTransform: 'uppercase',
3115
+ color: CORVID.muted,
3116
+ } as const
3117
+
3118
+ // A translucent panel that lets the scene show through, blurred
3119
+ export const GLASS_SX = {
3120
+ bgcolor: CORVID.panel,
3121
+ backdropFilter: 'blur(14px)',
3122
+ border: `1px solid ${CORVID.line}`,
3123
+ } as const
3124
+
3125
+ /** Corner brackets: four 1-pixel strokes `size` long at each corner, drawn as background gradients. */
3126
+ export const bracketsSx = (color: string, size = 10) => {
3127
+ const stroke = `linear-gradient(${color}, ${color})`
3128
+
3129
+ return {
3130
+ backgroundImage: Array.from({ length: 8 }, () => stroke).join(', '),
3131
+ backgroundRepeat: 'no-repeat',
3132
+ backgroundSize: Array.from({ length: 4 }, () => `${size}px 1px, 1px ${size}px`).join(', '),
3133
+ backgroundPosition:
3134
+ 'left top, left top, right top, right top, left bottom, left bottom, right bottom, right bottom',
3135
+ }
3136
+ }
3137
+ ```
3138
+
3139
+ ### ./src/elements/ConfiguratorLayout.tsx
3140
+
3141
+ ```tsx
3142
+ import { Box } from '@mui/material'
3143
+
3144
+ import { LayoutSlots } from '@salsita-ai/sdk/configurator'
3145
+
3146
+ import { phx } from '../../.phoenix/phx.js'
3147
+ import { DesktopDeck } from '../_shared/DesktopDeck.js'
3148
+ import { PhoneDeck } from '../_shared/PhoneDeck.js'
3149
+ import { CORVID } from '../_shared/tokens.js'
3150
+
3151
+ // A pro tool over a full-bleed scene: the spec's tabs, a panel with the active tab's controls, a HUD with the
3152
+ // readouts, and the spec's view controls. The canvas stays mounted; what lies over it is the desktop or the phone
3153
+ // deck, and each calls `useSceneSafeArea` itself, so only the one on screen tells the camera where the drone goes.
3154
+ // The layout shows messages in its own log line, so it does not mount `LayoutSlots.Services`.
3155
+ const ConfiguratorLayout = () => {
3156
+ // `null` until the container has been measured; only the scene shows until then
3157
+ const viewports = phx.frontend.useActiveViewports()
3158
+ const isDesktop = viewports?.includes('desktop') ?? false
3159
+
3160
+ return (
3161
+ <Box
3162
+ component="div"
3163
+ sx={{ position: 'absolute', inset: 0, overflow: 'clip', bgcolor: CORVID.graphite, color: CORVID.ink }}
3164
+ >
3165
+ <LayoutSlots.SharedCanvas />
3166
+ {viewports !== null && (isDesktop ? <DesktopDeck /> : <PhoneDeck />)}
3167
+ </Box>
3168
+ )
3169
+ }
3170
+
3171
+ export default phx.frontend.createCustomPhoenixLayoutElement('ConfiguratorLayout', ConfiguratorLayout)
3172
+ ```
3173
+
3174
+ ### ./src/elements/DroneHangar.tsx
3175
+
3176
+ ```tsx
3177
+ import { ContactShadows, Environment, Grid, Lightformer } from '@react-three/drei'
3178
+
3179
+ import { phx } from '../../.phoenix/phx.js'
3180
+ import { CORVID } from '../_shared/tokens.js'
3181
+
3182
+ // The floor lies 320 mm below the drone's centre, so the drone hovers over it on either landing gear
3183
+ const FLOOR_Y = -320
3184
+
3185
+ // The stage named by the spec's `canvasCustomEnvironment`: a dark hangar with a measured floor, a landing ring and a
3186
+ // soft shadow. Light panels exist only in the reflections, so the shell and the anodised motors have something to
3187
+ // mirror and no image has to be downloaded. The scene is in millimetres.
3188
+ export default phx.frontend.createCustomPhoenixLayoutElement('DroneHangar', () => {
3189
+ return (
3190
+ <>
3191
+ <color attach="background" args={[CORVID.graphite]} />
3192
+ <ambientLight intensity={0.6} />
3193
+ <directionalLight position={[-700, 1500, 1100]} intensity={2.4} />
3194
+ <directionalLight position={[1200, 500, 600]} intensity={0.7} />
3195
+ {/* A cool rim light from behind outlines the drone against the dark hangar */}
3196
+ <directionalLight position={[600, 700, -1400]} intensity={1.8} color={CORVID.cyan} />
3197
+
3198
+ <Environment resolution={256} frames={1}>
3199
+ <color attach="background" args={['#262b32']} />
3200
+ {/* A long softbox above, two tall strips at the sides and a dim fill from the front */}
3201
+ <Lightformer form="rect" intensity={3.2} position={[0, 6, 0]} rotation-x={Math.PI / 2} scale={[10, 3, 1]} />
3202
+ <Lightformer form="rect" intensity={2.4} position={[-6, 1.5, 3]} rotation-y={Math.PI / 2} scale={[1, 7, 1]} />
3203
+ <Lightformer form="rect" intensity={1.6} position={[6, 1.5, -2]} rotation-y={-Math.PI / 2} scale={[1, 7, 1]} />
3204
+ <Lightformer form="rect" intensity={1} position={[0, 0, 8]} rotation-y={Math.PI} scale={[8, 2, 1]} />
3205
+ </Environment>
3206
+
3207
+ <Grid
3208
+ position={[0, FLOOR_Y, 0]}
3209
+ infiniteGrid
3210
+ cellSize={50}
3211
+ cellThickness={0.6}
3212
+ cellColor="#262c34"
3213
+ sectionSize={250}
3214
+ sectionThickness={1}
3215
+ sectionColor="#3a4652"
3216
+ fadeDistance={4200}
3217
+ fadeStrength={2}
3218
+ />
3219
+ {/* The landing ring, drawn just above the floor so the grid does not cut through it */}
3220
+ <mesh rotation-x={-Math.PI / 2} position={[0, FLOOR_Y + 1, 0]}>
3221
+ <ringGeometry args={[440, 445, 128]} />
3222
+ <meshBasicMaterial color={CORVID.lime} transparent opacity={0.5} toneMapped={false} />
3223
+ </mesh>
3224
+ <ContactShadows position={[0, FLOOR_Y + 2, 0]} scale={1600} far={440} blur={2.6} opacity={0.8} resolution={512} />
3225
+ </>
3226
+ )
3227
+ })
3228
+ ```
3229
+
3230
+ ### ./src/elements/PayloadHotspot.tsx
3231
+
3232
+ ```tsx
3233
+ import { Box, ButtonBase } from '@mui/material'
3234
+
3235
+ import { phx } from '../../.phoenix/phx.js'
3236
+ import { CORVID } from '../_shared/tokens.js'
3237
+
3238
+ // Rendered by the spec's `sceneEntityObjectHtmlHotspot` on the payload mount: a target on the drone that opens the
3239
+ // Payload tab. The spec's `$filter` shows it only to the engineer and only outside that tab.
3240
+ export default phx.frontend.createCustomPhoenixEntityElement('PayloadHotspot', () => {
3241
+ const { select } = phx.frontend.useTabNavigation()
3242
+
3243
+ return (
3244
+ <ButtonBase
3245
+ aria-label="Payload: open the Payload tab"
3246
+ onClick={() => select('payload')}
3247
+ sx={{
3248
+ display: 'flex',
3249
+ alignItems: 'center',
3250
+ gap: 1,
3251
+ // The target's centre sits on the mount; the label hangs to its right
3252
+ transform: 'translate(-14px, 0)',
3253
+ color: CORVID.lime,
3254
+ '&:hover .corvid-hotspot-label, &.Mui-focusVisible .corvid-hotspot-label': {
3255
+ bgcolor: CORVID.lime,
3256
+ color: CORVID.graphite,
3257
+ },
3258
+ }}
3259
+ >
3260
+ <Box
3261
+ component="span"
3262
+ aria-hidden
3263
+ sx={{
3264
+ position: 'relative',
3265
+ display: 'grid',
3266
+ placeItems: 'center',
3267
+ inlineSize: 28,
3268
+ blockSize: 28,
3269
+ // A ring that keeps widening and fading, so the target reads as live
3270
+ '&::after': {
3271
+ content: '""',
3272
+ position: 'absolute',
3273
+ inset: 0,
3274
+ borderRadius: '50%',
3275
+ border: `1px solid ${CORVID.lime}`,
3276
+ animation: 'corvid-ping 2s ease-out infinite',
3277
+ },
3278
+ '@keyframes corvid-ping': {
3279
+ from: { transform: 'scale(0.6)', opacity: 0.9 },
3280
+ to: { transform: 'scale(1.6)', opacity: 0 },
3281
+ },
3282
+ }}
3283
+ >
3284
+ <svg viewBox="0 0 28 28" width={28} height={28} fill="none" stroke="currentColor" strokeWidth={1.4}>
3285
+ <circle cx={14} cy={14} r={8} fill="rgba(20, 23, 28, 0.6)" />
3286
+ <path d="M14 2v6M14 20v6M2 14h6M20 14h6" />
3287
+ <circle cx={14} cy={14} r={1.6} fill="currentColor" />
3288
+ </svg>
3289
+ </Box>
3290
+ <Box
3291
+ component="span"
3292
+ className="corvid-hotspot-label"
3293
+ sx={{
3294
+ paddingInline: 1,
3295
+ paddingBlock: 0.5,
3296
+ fontFamily: CORVID.mono,
3297
+ fontSize: 10.5,
3298
+ letterSpacing: '0.16em',
3299
+ whiteSpace: 'nowrap',
3300
+ color: CORVID.lime,
3301
+ bgcolor: 'rgba(20, 23, 28, 0.8)',
3302
+ border: `1px solid ${CORVID.lime}`,
3303
+ transition: 'background-color 160ms, color 160ms',
3304
+ }}
3305
+ >
3306
+ PAYLOAD ›
3307
+ </Box>
3308
+ </ButtonBase>
3309
+ )
3310
+ })
3311
+ ```
3312
+
3313
+ ### ./src/expressions/drone.ts
3314
+
3315
+ ```ts
3316
+ import { phx } from '../../.phoenix/phx.js'
3317
+
3318
+ const MOTORS = { Quad: 4, Hexa: 6 }
3319
+
3320
+ // Prices in US dollars
3321
+ const BASE_PRICE = 8900
3322
+ const FRAME_PRICE = { Quad: 0, Hexa: 2400 }
3323
+ // Per millimetre of arm beyond the shortest, 280 mm
3324
+ const ARM_PRICE_PER_MM = 6
3325
+ const PROP_PRICE = { TwoBlade: 0, ThreeBlade: 60 }
3326
+ const MOTOR_COLOR_PRICE = { Titanium: 0, Lime: 180, Cyan: 180, Ember: 180 }
3327
+ const PAYLOAD_PRICE = { Camera: 4200, Lidar: 11800, Winch: 1900 }
3328
+ const RETRACTABLE_GEAR_PRICE = 650
3329
+ const LIVERY_PRICE = { Graphite: 0, Arctic: 0, Signal: 250 }
3330
+
3331
+ // Weights in grams. The body includes the battery; an arm weighs more the longer it is.
3332
+ const BODY_WEIGHT = 1650
3333
+ const ARM_WEIGHT = (armLength: number) => 60 + 0.25 * armLength
3334
+ const MOTOR_WEIGHT = 160
3335
+ const PROP_WEIGHT = { TwoBlade: 22, ThreeBlade: 30 }
3336
+ const PAYLOAD_WEIGHT = { Camera: 680, Lidar: 920, Winch: 540 }
3337
+ const GEAR_WEIGHT = { fixed: 210, retractable: 340 }
3338
+
3339
+ // How each livery renders: the spec's overrides of the canopy and of the lights read these
3340
+ const LIVERIES = {
3341
+ Graphite: { shell: '#4a525c', clearcoat: 0.5, lights: '#c6f432' },
3342
+ Arctic: { shell: '#e9edf0', clearcoat: 0.8, lights: '#3ed6e8' },
3343
+ Signal: { shell: '#ff6a1a', clearcoat: 0.6, lights: '#f4f7fa' },
3344
+ }
3345
+
3346
+ const MOTOR_COLORS = { Titanium: '#a7adb4', Lime: '#a6d21c', Cyan: '#25b9cc', Ember: '#ff6f24' }
3347
+
3348
+ export default phx.domain.implementCustomExpressions({
3349
+ Drone: {
3350
+ $price: s => {
3351
+ const motors = MOTORS[s.frame]
3352
+
3353
+ return (
3354
+ BASE_PRICE +
3355
+ FRAME_PRICE[s.frame] +
3356
+ (s.armLength - 280) * ARM_PRICE_PER_MM +
3357
+ motors * PROP_PRICE[s.props] +
3358
+ MOTOR_COLOR_PRICE[s.motorColor] +
3359
+ PAYLOAD_PRICE[s.payload] +
3360
+ (s.retractableGear ? RETRACTABLE_GEAR_PRICE : 0) +
3361
+ LIVERY_PRICE[s.livery]
3362
+ )
3363
+ },
3364
+ // The all-up weight, ready to fly: the `detail` component's subtitle reads it
3365
+ $weightLabel: s => {
3366
+ const motors = MOTORS[s.frame]
3367
+ const grams =
3368
+ BODY_WEIGHT +
3369
+ motors * (ARM_WEIGHT(s.armLength) + MOTOR_WEIGHT + PROP_WEIGHT[s.props]) +
3370
+ PAYLOAD_WEIGHT[s.payload] +
3371
+ GEAR_WEIGHT[s.retractableGear ? 'retractable' : 'fixed']
3372
+
3373
+ return `AUW ${(grams / 1000).toFixed(2)} kg`
3374
+ },
3375
+ $shellColor: s => {
3376
+ return LIVERIES[s.livery].shell
3377
+ },
3378
+ $shellClearcoat: s => {
3379
+ return LIVERIES[s.livery].clearcoat
3380
+ },
3381
+ $ledColor: s => {
3382
+ return LIVERIES[s.livery].lights
3383
+ },
3384
+ $motorColor: s => {
3385
+ return MOTOR_COLORS[s.motorColor]
3386
+ },
3387
+ },
3388
+ })
3389
+ ```
3390
+
3391
+ ### ./src/invariants/drone.ts
3392
+
3393
+ ```ts
3394
+ import { phx } from '../../.phoenix/phx.js'
3395
+
3396
+ // The winch's load needs the thrust of six motors. The spec marks the violation `fixable`, so a change that breaks it
3397
+ // is rejected, and the layout's message log shows the spec's message.
3398
+ export default phx.domain.implementCustomInvariants({
3399
+ Drone: {
3400
+ winchNeedsHexa: s => {
3401
+ return s.payload !== 'Winch' || s.frame === 'Hexa'
3402
+ },
3403
+ },
3404
+ })
3405
+ ```
3406
+
3407
+ ### ./src/transformers/lidar-base-station.ts
3408
+
3409
+ ```ts
3410
+ import { msg } from '@lingui/core/macro'
3411
+
3412
+ import { phx } from '../../.phoenix/phx.js'
3413
+
3414
+ // Choosing the LiDAR carries a hint, which reaches the layout as a side-effect message
3415
+ export default phx.frontend.transformerBuilder.when
3416
+ .entity(['Drone'])
3417
+ .modified({ atFields: ['payload'] })
3418
+ .then.overrideMatchingUpdate(({ update }) => {
3419
+ if (update.stateDelta.payload !== 'Lidar') {
3420
+ return [update]
3421
+ }
3422
+
3423
+ return [{ ...update, hint: msg`LiDAR missions need RTK positioning, so a GNSS base station ships with the drone.` }]
3424
+ })
3425
+ .build('lidar-base-station')
3426
+ ```
3427
+
3428
+ </details>