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