@praxisui/page-builder 9.0.66 → 9.0.67

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/README.md CHANGED
@@ -6,6 +6,23 @@ Use this package when an application must let users compose governed operational
6
6
 
7
7
  ## Widget shell editor integrity
8
8
 
9
+ The shell editor exposes **Keep header visible while scrolling the page**
10
+ (`widget.shell.stickyHeader`). It retains the current title/subtitle rather than
11
+ copying record state into the host. Apply/save/reopen/reset use the same shell
12
+ document and the existing identity-authoring capability governs the option.
13
+ Pinning is visible in presentation mode, not while dragging/editing the canvas;
14
+ hidden headers, collapsed/overlay widgets and scroll recovery retain their own
15
+ behavior. Hosts with a fixed heading provide the Core CSS length
16
+ `--pdx-shell-sticky-header-offset`. The default remains normal flow.
17
+
18
+ The page editor keeps Material tab pagination/keyboard navigation with wrapped labels
19
+ for narrow panels. Its configuration summary is progressively disclosed; when hosted
20
+ in Settings Panel it does not repeat the shell's title, reset action or dirty status.
21
+ The selected widget/layout remains visible while scrolling the limits inspector.
22
+ Rendered-height diagnostics appear at the visible height field only; diagnostics for
23
+ another target or a hidden height field remain in the section summary. The validity
24
+ and apply/save protocol are unchanged.
25
+
9
26
  Dynamic Page height validation follows viewport resize and Angular render completion
10
27
  while the editor remains open. It reuses the runtime's transient validator and updates
11
28
  the existing field diagnostics/Apply/Save gates only when the diagnostic changes.
@@ -51,6 +68,22 @@ content-aware minimums remain important, including in restored layouts; changing
51
68
  recipe does not silently rewrite a saved user page. Auto-content rows still do not
52
69
  provide pixel-continuous vertical shrinking of a one-row widget.
53
70
 
71
+ ### Device composition
72
+
73
+ **Page settings → Responsiveness** opens on Mobile and shows one device at a time.
74
+ **Stack widgets** prepares a single-column variant in the base canvas's visual reading
75
+ order, with natural content height. It replaces positions and width bounds only for
76
+ that device, preserves hidden widgets and other overrides, and leaves the base and
77
+ other device layouts unchanged. Apply/Save materializes the existing
78
+ `deviceLayouts[device].canvas` contract; Reset restores the original draft.
79
+ Advanced JSON overrides remain available under **Advanced overrides**.
80
+
81
+ Reducing a device's column count also reflows inherited base positions in Core.
82
+ Explicit device positions remain authoritative. With no device variant the base
83
+ canvas is still inherited; the editor does not silently apply a mobile template.
84
+ Grouping settings are preserved for flow layouts; canvas geometry currently takes
85
+ precedence over grouping. The editor explains this distinction in the Grouping tab.
86
+
54
87
  ### Visual widget limits
55
88
 
56
89
  Open **Page settings → Widget limits** in the default Dynamic Page editor to set
@@ -527,3 +560,23 @@ Main exports:
527
560
  - Use component-owned config editors through metadata instead of duplicating widget-specific editors in Page Builder.
528
561
  - Keep diagnostics and streaming opt-in for hosts that need auditability or richer authoring feedback.
529
562
  - Use the official documentation for extended recipes, playground routes, and advanced AI authoring flows.
563
+
564
+ ### Montagem manual de master detail a partir de uma página vazia
565
+
566
+ 1. Insira Filter, Table e Dynamic Form e conecte-os ao recurso pelo catálogo.
567
+ 2. No filtro, carregue o schema e selecione os campos sempre visíveis.
568
+ 3. Na tabela, habilite seleção única por clique e escolha paginação no servidor para APIs paginadas; no formulário, escolha modo `view`.
569
+ 4. Crie as conexões `Filter.change → Table.queryContext` e `Table.selectionChange → Dynamic Form.resourceId`. O editor usa os contratos públicos das portas para propor as transformações do filtro e do identificador.
570
+ 5. Configure a grade, salve a página e reabra para conferir filtro, seleção e detalhe.
571
+
572
+ Ao converter uma página sem posições em grade, o editor acrescenta posições para todos os widgets e usa altura de conteúdo como padrão. Isso garante visibilidade; a distribuição lado a lado continua sendo uma decisão de layout do autor. O diagrama radial mantém os modos de edição e apresentação.
573
+
574
+ ### Menu de ações e tamanho preciso
575
+
576
+ O editor de shell aceita `placement: 'menu'` para manter uma ação em Mais ações mesmo
577
+ com espaço livre no cabeçalho. A edição do canvas oferece `Mais ações → Tamanho…`,
578
+ com largura em colunas e altura em linhas ou pixels conforme `autoRows`. O ajuste usa
579
+ as restrições e o layout de dispositivo canônicos, com prévia e aplicação explícita.
580
+ Selecionar o widget continua expondo suas alças; o ícone dedicado e os painéis de setas
581
+ separados foram removidos. O editor oferece presets exatos de largura e recuperação do último resize da sessão,
582
+ incluindo vizinhos. Clique duplo e histórico geral continuam fora desta entrega.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
- "generatedAt": "2026-09-06T19:09:46.917Z",
3
+ "generatedAt": "2026-09-07T00:54:55.301Z",
4
4
  "packageName": "@praxisui/page-builder",
5
- "packageVersion": "9.0.66",
5
+ "packageVersion": "9.0.67",
6
6
  "sourceRegistry": "praxis-component-registry-ingestion",
7
7
  "sourceRegistryVersion": "1.0.0",
8
8
  "componentCount": 1,
@@ -2059,7 +2059,13 @@
2059
2059
  "type": "string"
2060
2060
  },
2061
2061
  "shell": {
2062
- "type": "object"
2062
+ "type": "object",
2063
+ "properties": {
2064
+ "stickyHeader": {
2065
+ "type": "boolean",
2066
+ "description": "Retain the existing header within the widget during page scrolling; suspended for drag authoring, collapsed/overlay states and scroll recovery."
2067
+ }
2068
+ }
2063
2069
  }
2064
2070
  }
2065
2071
  },
@@ -3783,9 +3789,9 @@
3783
3789
  {
3784
3790
  "chunkIndex": 10,
3785
3791
  "chunkKind": "authoring_manifest",
3786
- "content": "{\"operationId\":\"widget.shell.configure\",\"schemaVersion\":\"1.0.0\",\"manifestVersion\":\"1.5.0\",\"componentId\":\"praxis-page-builder\",\"ownerPackage\":\"@praxisui/page-builder\",\"configSchemaId\":\"WidgetPageDefinition\",\"chunkSection\":\"operation_card\",\"semanticSummary\":\"Configure widget shell Effects: compile-domain-patch. Rename the employees widget card and enable the refresh shell action.\",\"scope\":\"templating\",\"target\":{\"kind\":\"widgetShell\",\"resolver\":\"widget-shell-by-widget-key\",\"required\":true},\"inputContract\":{\"required\":[\"widgetKey\"],\"properties\":[\"widgetKey\",\"shell\"],\"semanticTerms\":[\"widgetKey\",\"shell\"]},\"effects\":[\"compile-domain-patch\"],\"affectedPaths\":[\"widgets[].shell\"],\"validators\":[{\"validatorId\":\"widget-exists\",\"level\":\"error\",\"code\":\"PAGE_BUILDER_WIDGET_EXISTS\",\"description\":\"Target widget must exist.\"},{\"validatorId\":\"widget-shell-shape-valid\",\"level\":\"error\",\"code\":\"PAGE_BUILDER_SHELL_SHAPE\",\"description\":\"Widget shell config must match the shell editor contract.\"},{\"validatorId\":\"settings-panel-bridge-available\",\"level\":\"error\",\"code\":\"PAGE_BUILDER_SETTINGS_BRIDGE\",\"description\":\"Settings panel bridge must be available for shell/page editors.\"},{\"validatorId\":\"child-inputs-not-mutated\",\"level\":\"error\",\"code\":\"PAGE_BUILDER_CHILD_INPUTS_NOT_MUTATED\",\"description\":\"Shell edits cannot mutate child inputs.\"}],\"preconditions\":[\"page-loaded\",\"target-widget-exists\"],\"submissionImpact\":\"config-only\",\"presentationAffordances\":[],\"positiveExamples\":[{\"id\":\"configure-widget-shell\",\"request\":\"Rename the employees widget card and enable the refresh shell action.\",\"operationId\":\"widget.shell.configure\",\"target\":\"employees-table\",\"params\":{\"widgetKey\":\"employees-table\",\"shell\":{\"title\":\"Employees\",\"actions\":[{\"id\":\"refresh\"}]}},\"isPositive\":true}],\"negativeExamples\":[]}",
3792
+ "content": "{\"operationId\":\"widget.shell.configure\",\"schemaVersion\":\"1.0.0\",\"manifestVersion\":\"1.5.0\",\"componentId\":\"praxis-page-builder\",\"ownerPackage\":\"@praxisui/page-builder\",\"configSchemaId\":\"WidgetPageDefinition\",\"chunkSection\":\"operation_card\",\"semanticSummary\":\"Configure widget shell Effects: compile-domain-patch. Rename the employees widget card and enable the refresh shell action.\",\"scope\":\"templating\",\"target\":{\"kind\":\"widgetShell\",\"resolver\":\"widget-shell-by-widget-key\",\"required\":true},\"inputContract\":{\"required\":[\"widgetKey\"],\"properties\":[\"widgetKey\",\"shell\"],\"semanticTerms\":[\"widgetKey\",\"shell\",\"shell.stickyHeader\"]},\"effects\":[\"compile-domain-patch\"],\"affectedPaths\":[\"widgets[].shell\"],\"validators\":[{\"validatorId\":\"widget-exists\",\"level\":\"error\",\"code\":\"PAGE_BUILDER_WIDGET_EXISTS\",\"description\":\"Target widget must exist.\"},{\"validatorId\":\"widget-shell-shape-valid\",\"level\":\"error\",\"code\":\"PAGE_BUILDER_SHELL_SHAPE\",\"description\":\"Widget shell config must match the shell editor contract.\"},{\"validatorId\":\"settings-panel-bridge-available\",\"level\":\"error\",\"code\":\"PAGE_BUILDER_SETTINGS_BRIDGE\",\"description\":\"Settings panel bridge must be available for shell/page editors.\"},{\"validatorId\":\"child-inputs-not-mutated\",\"level\":\"error\",\"code\":\"PAGE_BUILDER_CHILD_INPUTS_NOT_MUTATED\",\"description\":\"Shell edits cannot mutate child inputs.\"}],\"preconditions\":[\"page-loaded\",\"target-widget-exists\"],\"submissionImpact\":\"config-only\",\"presentationAffordances\":[],\"positiveExamples\":[{\"id\":\"configure-widget-shell\",\"request\":\"Rename the employees widget card and enable the refresh shell action.\",\"operationId\":\"widget.shell.configure\",\"target\":\"employees-table\",\"params\":{\"widgetKey\":\"employees-table\",\"shell\":{\"title\":\"Employees\",\"actions\":[{\"id\":\"refresh\"}]}},\"isPositive\":true}],\"negativeExamples\":[]}",
3787
3793
  "sourcePointer": "projects/praxis-page-builder/src/lib/ai/praxis-page-builder-authoring-manifest.ts",
3788
- "contentHash": "dcc7c6aa2af41e42874984177813703afaa2115744d1d565eb265671bf1d1fbd",
3794
+ "contentHash": "12355c4633b7f09a4c4e46d4edac2f23a2ff0e45b6d2a27251ec93b181e50b6e",
3789
3795
  "sourceKind": "component_definition",
3790
3796
  "sourceId": "praxis-page-builder",
3791
3797
  "corpusVersion": "1.0.0"
@@ -3893,9 +3899,9 @@
3893
3899
  {
3894
3900
  "chunkIndex": 21,
3895
3901
  "chunkKind": "authoring_manifest",
3896
- "content": "{\"schemaVersion\":\"1.0.0\",\"manifestVersion\":\"1.5.0\",\"componentId\":\"praxis-page-builder\",\"ownerPackage\":\"@praxisui/page-builder\",\"configSchemaId\":\"WidgetPageDefinition\",\"chunkSection\":\"operations\",\"operations\":[{\"operationId\":\"page.configure\",\"title\":\"Configure page metadata\",\"scope\":\"global\",\"targetKind\":\"page\",\"target\":{\"kind\":\"page\",\"resolver\":\"widget-page-definition-root\",\"ambiguityPolicy\":\"fail\",\"required\":false},\"inputSchema\":{\"type\":\"object\",\"properties\":{\"title\":{\"type\":\"string\"},\"layoutPreset\":{\"type\":\"string\"},\"themePreset\":{\"type\":\"string\"},\"context\":{\"type\":\"object\"},\"metadata\":{\"type\":\"object\"}}},\"effects\":[{\"kind\":\"compile-domain-patch\",\"handler\":\"page-builder-page-configure\",\"handlerContract\":{\"reads\":[\"WidgetPageDefinition\",\"DynamicPageConfigEditorComponent\",\"SettingsPanelBridge\"],\"writes\":[\"page.title\",\"page.layoutPreset\",\"page.themePreset\",\"page.context\",\"page.metadata\"],\"identityKeys\":[\"pageIdentity.componentId\",\"pageIdentity.scope\"],\"inputSchema\":{\"type\":\"object\",\"properties\":{\"title\":{\"type\":\"string\"},\"layoutPreset\":{\"type\":\"string\"},\"themePreset\":{\"type\":\"string\"},\"context\":{\"type\":\"object\"},\"metadata\":{\"type\":\"object\"}}},\"failureModes\":[\"page-not-loaded\",\"context-not-json\",\"settings-panel-bridge-missing\"],\"description\":\"Updates root page metadata while preserving the renderable WidgetPageDefinition shape.\"}}],\"validators\":[\"widget-page-definition-valid\",\"no-legacy-grid-page-definition\",\"page-context-json-valid\",\"settings-panel-round-trip-valid\"],\"affectedPaths\":[\"title\",\"layoutPreset\",\"themePreset\",\"context\",\"metadata\"],\"submissionImpact\":\"config-only\",\"preconditions\":[\"page-loaded\"]},{\"operationId\":\"canvas.configure\",\"title\":\"Configure page canvas\",\"scope\":\"layout\",\"targetKind\":\"canvas\",\"target\":{\"kind\":\"canvas\",\"resolver\":\"widget-page-canvas\",\"ambiguityPolicy\":\"fail\",\"required\":true},\"inputSchema\":{\"type\":\"object\",\"properties\":{\"columns\":{\"type\":\"number\"},\"rowUnit\":{\"type\":\"number\"},\"gap\":{\"type\":\"number\"},\"items\":{\"type\":\"array\"}}},\"effects\":[{\"kind\":\"compile-domain-patch\",\"handler\":\"page-builder-canvas-configure\",\"handlerContract\":{\"reads\":[\"WidgetPageDefinition.canvas\",\"DynamicPageBuilderComponent\"],\"writes\":[\"page.canvas.columns\",\"page.canvas.rowUnit\",\"page.canvas.gap\",\"page.canvas.items\"],\"identityKeys\":[\"canvas\"],\"inputSchema\":{\"type\":\"object\",\"properties\":{\"columns\":{\"type\":\"number\"},\"rowUnit\":{\"type\":\"number\"},\"gap\":{\"type\":\"number\"},\"items\":{\"type\":\"array\"}}},\"failureModes\":[\"canvas-missing\",\"canvas-item-references-missing-widget\",\"invalid-grid-geometry\"],\"description\":\"Configures the top-level canvas without introducing legacy GridPageDefinition semantics.\"}}],\"validators\":[\"canvas-columns-integer\",\"canvas-row-unit-valid\",\"canvas-gap-valid\",\"canvas-items-reference-existing-widgets\"],\"affectedPaths\":[\"canvas.columns\",\"canvas.rowUnit\",\"canvas.gap\",\"canvas.items\"],\"submissionImpact\":\"config-only\",\"preconditions\":[\"page-loaded\"]},{\"operationId\":\"widget.add\",\"title\":\"Add widget to page\",\"scope\":\"layout\",\"targetKind\":\"widget\",\"target\":{\"kind\":\"widget\",\"resolver\":\"widget-by-stable-key\",\"ambiguityPolicy\":\"fail\",\"required\":true},\"inputSchema\":{\"type\":\"object\",\"required\":[\"widgetKey\",\"componentId\"],\"properties\":{\"widgetKey\":{\"type\":\"string\"},\"componentId\":{\"type\":\"string\"},\"title\":{\"type\":\"string\"},\"inputs\":{\"type\":\"object\"},\"canvasItem\":{\"type\":\"object\"}}},\"effects\":[{\"kind\":\"compile-domain-patch\",\"handler\":\"page-builder-widget-add\",\"handlerContract\":{\"reads\":[\"WidgetPageDefinition.widgets\",\"WidgetPageDefinition.canvas\",\"ComponentDocRegistry\",\"PAGE_BUILDER_WIDGET_AI_CATALOGS\"],\"writes\":[\"page.widgets[]\",\"page.canvas.items[]\"],\"identityKeys\":[\"widgetKey\"],\"inputSchema\":{\"type\":\"object\",\"required\":[\"widgetKey\",\"componentId\"],\"properties\":{\"widgetKey\":{\"type\":\"string\"},\"componentId\":{\"type\":\"string\"},\"title\":{\"type\":\"string\"},\"inputs\":{\"type\":\"object\"},\"canvasItem\":{\"type\":\"object\"}}},\"failureModes\":[\"widget-key-duplicate\",\"component-not-registered\",\"canvas-item-invalid\",\"child-input-contract-unknown\"],\"description\":\"Adds a registered component as a widget and places it on the canvas using a stable widgetKey.\"}}],\"validators\":[\"widget-key-unique\",\"component-registered\",\"canvas-item-valid\",\"child-inputs-delegated\",\"widget-key-not-array-index\"],\"affectedPaths\":[\"widgets[]\",\"canvas.items[]\"],\"submissionImpact\":\"config-only\",\"preconditions\":[\"page-loaded\",\"component-catalog-loaded\"]},{\"operationId\":\"widget.remove\",\"title\":\"Remove widget from page\",\"scope\":\"layout\",\"targetKind\":\"widget\",\"target\":{\"kind\":\"widget\",\"resolver\":\"widget-by-stable-key\",\"ambiguityPolicy\":\"fail\",\"required\":true},\"inputSchema\":{\"type\":\"object\",\"required\":[\"widgetKey\"],\"properties\":{\"widgetKey\":{\"type\":\"string\"},\"confirmed\":{\"type\":\"boolean\"}}},\"effects\":[{\"kind\":\"compile-domain-patch\",\"handler\":\"page-builder-widget-remove\",\"handlerContract\":{\"reads\":[\"WidgetPageDefinition.widgets\",\"WidgetPageDefinition.canvas.items\",\"WidgetPageDefinition.composition.links\"],\"writes\":[\"page.widgets[]\",\"page.canvas.items[]\",\"page.composition.links[]\"],\"identityKeys\":[\"widgetKey\"],\"failureModes\":[\"widget-not-found\",\"destructive-removal-not-confirmed\",\"dangling-composition-link\"],\"description\":\"Removes a widget and cleans canvas items and composition links that reference it.\"}}],\"destructive\":true,\"requiresConfirmation\":true,\"validators\":[\"widget-exists\",\"destructive-removal-confirmed\",\"composition-links-cleaned\",\"canvas-items-reference-existing-widgets\"],\"affectedPaths\":[\"widgets[]\",\"canvas.items[]\",\"composition.links[]\"],\"submissionImpact\":\"config-only\",\"preconditions\":[\"page-loaded\",\"target-widget-exists\"]},{\"operationId\":\"widget.moveResize\",\"title\":\"Move or resize widget\",\"scope\":\"layout\",\"targetKind\":\"widget\",\"target\":{\"kind\":\"widget\",\"resolver\":\"widget-by-stable-key\",\"ambiguityPolicy\":\"fail\",\"required\":true},\"inputSchema\":{\"type\":\"object\",\"required\":[\"widgetKey\",\"canvasItem\"],\"properties\":{\"widgetKey\":{\"type\":\"string\"},\"canvasItem\":{\"type\":\"object\"}}},\"effects\":[{\"kind\":\"compile-domain-patch\",\"handler\":\"page-builder-widget-move-resize\",\"handlerContract\":{\"reads\":[\"WidgetPageDefinition.canvas.items\",\"WidgetPageDefinition.widgets\"],\"writes\":[\"page.canvas.items[]\"],\"identityKeys\":[\"widgetKey\"],\"failureModes\":[\"widget-not-found\",\"canvas-item-invalid\",\"canvas-overlap-invalid\"],\"description\":\"Updates canvas placement for an existing widget using widgetKey as the stable identity.\"}}],\"validators\":[\"widget-exists\",\"canvas-item-valid\",\"canvas-item-targets-existing-widget\",\"canvas-overlap-policy-valid\"],\"affectedPaths\":[\"canvas.items[]\"],\"submissionImpact\":\"config-only\",\"preconditions\":[\"page-loaded\",\"target-widget-exists\"]},{\"operationId\":\"widget.shell.configure\",\"title\":\"Configure widget shell\",\"scope\":\"templating\",\"targetKind\":\"widgetShell\",\"target\":{\"kind\":\"widgetShell\",\"resolver\":\"widget-shell-by-widget-key\",\"ambiguityPolicy\":\"fail\",\"required\":true},\"inputSchema\":{\"type\":\"object\",\"required\":[\"widgetKey\"],\"properties\":{\"widgetKey\":{\"type\":\"string\"},\"shell\":{\"type\":\"object\"}}},\"effects\":[{\"kind\":\"compile-domain-patch\",\"handler\":\"page-builder-widget-shell-configure\",\"handlerContract\":{\"reads\":[\"WidgetPageDefinition.widgets\",\"WidgetShellEditorComponent\",\"SettingsPanelBridge\"],\"writes\":[\"page.widgets[].shell\"],\"identityKeys\":[\"widgetKey\"],\"failureModes\":[\"widget-not-found\",\"settings-panel-bridge-missing\",\"shell-config-invalid\"],\"description\":\"Updates the page-builder-owned widget shell without editing child component inputs.\"}}],\"validators\":[\"widget-exists\",\"widget-shell-shape-valid\",\"settings-panel-bridge-available\",\"child-inputs-not-mutated\"],\"affectedPaths\":[\"widgets[].shell\"],\"submissionImpact\":\"config-only\",\"preconditions\":[\"page-loaded\",\"target-widget-exists\"]}]}",
3902
+ "content": "{\"schemaVersion\":\"1.0.0\",\"manifestVersion\":\"1.5.0\",\"componentId\":\"praxis-page-builder\",\"ownerPackage\":\"@praxisui/page-builder\",\"configSchemaId\":\"WidgetPageDefinition\",\"chunkSection\":\"operations\",\"operations\":[{\"operationId\":\"page.configure\",\"title\":\"Configure page metadata\",\"scope\":\"global\",\"targetKind\":\"page\",\"target\":{\"kind\":\"page\",\"resolver\":\"widget-page-definition-root\",\"ambiguityPolicy\":\"fail\",\"required\":false},\"inputSchema\":{\"type\":\"object\",\"properties\":{\"title\":{\"type\":\"string\"},\"layoutPreset\":{\"type\":\"string\"},\"themePreset\":{\"type\":\"string\"},\"context\":{\"type\":\"object\"},\"metadata\":{\"type\":\"object\"}}},\"effects\":[{\"kind\":\"compile-domain-patch\",\"handler\":\"page-builder-page-configure\",\"handlerContract\":{\"reads\":[\"WidgetPageDefinition\",\"DynamicPageConfigEditorComponent\",\"SettingsPanelBridge\"],\"writes\":[\"page.title\",\"page.layoutPreset\",\"page.themePreset\",\"page.context\",\"page.metadata\"],\"identityKeys\":[\"pageIdentity.componentId\",\"pageIdentity.scope\"],\"inputSchema\":{\"type\":\"object\",\"properties\":{\"title\":{\"type\":\"string\"},\"layoutPreset\":{\"type\":\"string\"},\"themePreset\":{\"type\":\"string\"},\"context\":{\"type\":\"object\"},\"metadata\":{\"type\":\"object\"}}},\"failureModes\":[\"page-not-loaded\",\"context-not-json\",\"settings-panel-bridge-missing\"],\"description\":\"Updates root page metadata while preserving the renderable WidgetPageDefinition shape.\"}}],\"validators\":[\"widget-page-definition-valid\",\"no-legacy-grid-page-definition\",\"page-context-json-valid\",\"settings-panel-round-trip-valid\"],\"affectedPaths\":[\"title\",\"layoutPreset\",\"themePreset\",\"context\",\"metadata\"],\"submissionImpact\":\"config-only\",\"preconditions\":[\"page-loaded\"]},{\"operationId\":\"canvas.configure\",\"title\":\"Configure page canvas\",\"scope\":\"layout\",\"targetKind\":\"canvas\",\"target\":{\"kind\":\"canvas\",\"resolver\":\"widget-page-canvas\",\"ambiguityPolicy\":\"fail\",\"required\":true},\"inputSchema\":{\"type\":\"object\",\"properties\":{\"columns\":{\"type\":\"number\"},\"rowUnit\":{\"type\":\"number\"},\"gap\":{\"type\":\"number\"},\"items\":{\"type\":\"array\"}}},\"effects\":[{\"kind\":\"compile-domain-patch\",\"handler\":\"page-builder-canvas-configure\",\"handlerContract\":{\"reads\":[\"WidgetPageDefinition.canvas\",\"DynamicPageBuilderComponent\"],\"writes\":[\"page.canvas.columns\",\"page.canvas.rowUnit\",\"page.canvas.gap\",\"page.canvas.items\"],\"identityKeys\":[\"canvas\"],\"inputSchema\":{\"type\":\"object\",\"properties\":{\"columns\":{\"type\":\"number\"},\"rowUnit\":{\"type\":\"number\"},\"gap\":{\"type\":\"number\"},\"items\":{\"type\":\"array\"}}},\"failureModes\":[\"canvas-missing\",\"canvas-item-references-missing-widget\",\"invalid-grid-geometry\"],\"description\":\"Configures the top-level canvas without introducing legacy GridPageDefinition semantics.\"}}],\"validators\":[\"canvas-columns-integer\",\"canvas-row-unit-valid\",\"canvas-gap-valid\",\"canvas-items-reference-existing-widgets\"],\"affectedPaths\":[\"canvas.columns\",\"canvas.rowUnit\",\"canvas.gap\",\"canvas.items\"],\"submissionImpact\":\"config-only\",\"preconditions\":[\"page-loaded\"]},{\"operationId\":\"widget.add\",\"title\":\"Add widget to page\",\"scope\":\"layout\",\"targetKind\":\"widget\",\"target\":{\"kind\":\"widget\",\"resolver\":\"widget-by-stable-key\",\"ambiguityPolicy\":\"fail\",\"required\":true},\"inputSchema\":{\"type\":\"object\",\"required\":[\"widgetKey\",\"componentId\"],\"properties\":{\"widgetKey\":{\"type\":\"string\"},\"componentId\":{\"type\":\"string\"},\"title\":{\"type\":\"string\"},\"inputs\":{\"type\":\"object\"},\"canvasItem\":{\"type\":\"object\"}}},\"effects\":[{\"kind\":\"compile-domain-patch\",\"handler\":\"page-builder-widget-add\",\"handlerContract\":{\"reads\":[\"WidgetPageDefinition.widgets\",\"WidgetPageDefinition.canvas\",\"ComponentDocRegistry\",\"PAGE_BUILDER_WIDGET_AI_CATALOGS\"],\"writes\":[\"page.widgets[]\",\"page.canvas.items[]\"],\"identityKeys\":[\"widgetKey\"],\"inputSchema\":{\"type\":\"object\",\"required\":[\"widgetKey\",\"componentId\"],\"properties\":{\"widgetKey\":{\"type\":\"string\"},\"componentId\":{\"type\":\"string\"},\"title\":{\"type\":\"string\"},\"inputs\":{\"type\":\"object\"},\"canvasItem\":{\"type\":\"object\"}}},\"failureModes\":[\"widget-key-duplicate\",\"component-not-registered\",\"canvas-item-invalid\",\"child-input-contract-unknown\"],\"description\":\"Adds a registered component as a widget and places it on the canvas using a stable widgetKey.\"}}],\"validators\":[\"widget-key-unique\",\"component-registered\",\"canvas-item-valid\",\"child-inputs-delegated\",\"widget-key-not-array-index\"],\"affectedPaths\":[\"widgets[]\",\"canvas.items[]\"],\"submissionImpact\":\"config-only\",\"preconditions\":[\"page-loaded\",\"component-catalog-loaded\"]},{\"operationId\":\"widget.remove\",\"title\":\"Remove widget from page\",\"scope\":\"layout\",\"targetKind\":\"widget\",\"target\":{\"kind\":\"widget\",\"resolver\":\"widget-by-stable-key\",\"ambiguityPolicy\":\"fail\",\"required\":true},\"inputSchema\":{\"type\":\"object\",\"required\":[\"widgetKey\"],\"properties\":{\"widgetKey\":{\"type\":\"string\"},\"confirmed\":{\"type\":\"boolean\"}}},\"effects\":[{\"kind\":\"compile-domain-patch\",\"handler\":\"page-builder-widget-remove\",\"handlerContract\":{\"reads\":[\"WidgetPageDefinition.widgets\",\"WidgetPageDefinition.canvas.items\",\"WidgetPageDefinition.composition.links\"],\"writes\":[\"page.widgets[]\",\"page.canvas.items[]\",\"page.composition.links[]\"],\"identityKeys\":[\"widgetKey\"],\"failureModes\":[\"widget-not-found\",\"destructive-removal-not-confirmed\",\"dangling-composition-link\"],\"description\":\"Removes a widget and cleans canvas items and composition links that reference it.\"}}],\"destructive\":true,\"requiresConfirmation\":true,\"validators\":[\"widget-exists\",\"destructive-removal-confirmed\",\"composition-links-cleaned\",\"canvas-items-reference-existing-widgets\"],\"affectedPaths\":[\"widgets[]\",\"canvas.items[]\",\"composition.links[]\"],\"submissionImpact\":\"config-only\",\"preconditions\":[\"page-loaded\",\"target-widget-exists\"]},{\"operationId\":\"widget.moveResize\",\"title\":\"Move or resize widget\",\"scope\":\"layout\",\"targetKind\":\"widget\",\"target\":{\"kind\":\"widget\",\"resolver\":\"widget-by-stable-key\",\"ambiguityPolicy\":\"fail\",\"required\":true},\"inputSchema\":{\"type\":\"object\",\"required\":[\"widgetKey\",\"canvasItem\"],\"properties\":{\"widgetKey\":{\"type\":\"string\"},\"canvasItem\":{\"type\":\"object\"}}},\"effects\":[{\"kind\":\"compile-domain-patch\",\"handler\":\"page-builder-widget-move-resize\",\"handlerContract\":{\"reads\":[\"WidgetPageDefinition.canvas.items\",\"WidgetPageDefinition.widgets\"],\"writes\":[\"page.canvas.items[]\"],\"identityKeys\":[\"widgetKey\"],\"failureModes\":[\"widget-not-found\",\"canvas-item-invalid\",\"canvas-overlap-invalid\"],\"description\":\"Updates canvas placement for an existing widget using widgetKey as the stable identity.\"}}],\"validators\":[\"widget-exists\",\"canvas-item-valid\",\"canvas-item-targets-existing-widget\",\"canvas-overlap-policy-valid\"],\"affectedPaths\":[\"canvas.items[]\"],\"submissionImpact\":\"config-only\",\"preconditions\":[\"page-loaded\",\"target-widget-exists\"]},{\"operationId\":\"widget.shell.configure\",\"title\":\"Configure widget shell\",\"scope\":\"templating\",\"targetKind\":\"widgetShell\",\"target\":{\"kind\":\"widgetShell\",\"resolver\":\"widget-shell-by-widget-key\",\"ambiguityPolicy\":\"fail\",\"required\":true},\"inputSchema\":{\"type\":\"object\",\"required\":[\"widgetKey\"],\"properties\":{\"widgetKey\":{\"type\":\"string\"},\"shell\":{\"type\":\"object\",\"properties\":{\"stickyHeader\":{\"type\":\"boolean\",\"description\":\"Retain the existing header within the widget during page scrolling; suspended for drag authoring, collapsed/overlay states and scroll recovery.\"}}}}},\"effects\":[{\"kind\":\"compile-domain-patch\",\"handler\":\"page-builder-widget-shell-configure\",\"handlerContract\":{\"reads\":[\"WidgetPageDefinition.widgets\",\"WidgetShellEditorComponent\",\"SettingsPanelBridge\"],\"writes\":[\"page.widgets[].shell\"],\"identityKeys\":[\"widgetKey\"],\"failureModes\":[\"widget-not-found\",\"settings-panel-bridge-missing\",\"shell-config-invalid\"],\"description\":\"Updates the page-builder-owned widget shell without editing child component inputs.\"}}],\"validators\":[\"widget-exists\",\"widget-shell-shape-valid\",\"settings-panel-bridge-available\",\"child-inputs-not-mutated\"],\"affectedPaths\":[\"widgets[].shell\"],\"submissionImpact\":\"config-only\",\"preconditions\":[\"page-loaded\",\"target-widget-exists\"]}]}",
3897
3903
  "sourcePointer": "projects/praxis-page-builder/src/lib/ai/praxis-page-builder-authoring-manifest.ts",
3898
- "contentHash": "274e44542351d7394147fd0337a495141c70e188417a7c4a7cf5bb15d8179ceb",
3904
+ "contentHash": "9c3f606edc681ae101c35f0f28dd14c59a3978e73357a81b1c85080d9280c239",
3899
3905
  "sourceKind": "component_definition",
3900
3906
  "sourceId": "praxis-page-builder",
3901
3907
  "corpusVersion": "1.0.0"
@@ -363,6 +363,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
363
363
  }] } });
364
364
 
365
365
  const PRAXIS_PAGE_BUILDER_EN_US = {
366
+ "praxis.pageBuilder.editor.devices.target": "Device to configure",
367
+ "praxis.pageBuilder.editor.devices.advanced": "Advanced overrides",
368
+ "praxis.pageBuilder.editor.grouping.canvasNotice": "Canvas positions control this page. Groups below are preserved for flow layouts and do not arrange widgets in the canvas. Use the widget move and resize controls to place them side by side.",
369
+ "praxis.pageBuilder.editor.devices.stack": "Stack widgets",
370
+ "praxis.pageBuilder.editor.devices.stackHelp": "Use full width and natural height, in reading order. This replaces positions and width limits only for this device; other layouts stay unchanged.",
371
+ "praxis.pageBuilder.editor.devices.stacked": "Single-column layout prepared. Save to apply; Reset undoes the changes.",
372
+ 'praxis.pageBuilder.shell.menuPlacement': 'More actions menu',
366
373
  'praxis.pageBuilder.editor.limits.tab': "Widget limits",
367
374
  'praxis.pageBuilder.editor.limits.help': 'Grid limits use columns and rows. Content-row layouts also offer pixel height below. Leave limits blank for defaults or to inherit the base layout.',
368
375
  'praxis.pageBuilder.editor.limits.target': "Widget and layout",
@@ -388,6 +395,7 @@ const PRAXIS_PAGE_BUILDER_EN_US = {
388
395
  'praxis.pageBuilder.editor.limits.errors.columns': "The minimum width exceeds the grid columns.",
389
396
  'praxis.pageBuilder.editor.limits.errors.size': "The current size is outside these limits. Resize the widget first, or review the limits.",
390
397
  'praxis.pageBuilder.shell.invalidCss': 'Invalid CSS value. Use a valid color, dimension or expression for this field.',
398
+ 'praxis.pageBuilder.shell.stickyHeader': 'Keep header visible while scrolling the page',
391
399
  'praxis.pageBuilder.authoringSource.alert.title': 'Semantic lineage requires attention',
392
400
  'praxis.pageBuilder.authoringSource.alert.description': 'The page remains available, but this source will not be reused until new governed authoring restores its provenance.',
393
401
  'praxis.pageBuilder.authoringSource.diagnostics.authoring-source-schema-invalid': 'The authoring envelope does not match the schema supported by Page Builder.',
@@ -1054,6 +1062,7 @@ const PRAXIS_PAGE_BUILDER_EN_US = {
1054
1062
  'praxis.pageBuilder.connections.draft.backToEdit': 'Back to editing',
1055
1063
  'praxis.pageBuilder.connections.draft.present': 'Present diagram',
1056
1064
  'praxis.pageBuilder.connections.draft.title': 'Connection draft',
1065
+ 'praxis.pageBuilder.connections.draft.prerequisites': 'Enable the source event in its component settings. For row selection, enable single selection; configure the detail as read-only when needed.',
1057
1066
  'praxis.pageBuilder.connections.draft.help': 'Choose the source and destination. Review the route and delivered value before applying.',
1058
1067
  'praxis.pageBuilder.connections.draft.source': '1 · Source / event',
1059
1068
  'praxis.pageBuilder.connections.draft.target': '2 · Destination / input',
@@ -1092,6 +1101,13 @@ const PRAXIS_PAGE_BUILDER_EN_US = {
1092
1101
  };
1093
1102
 
1094
1103
  const PRAXIS_PAGE_BUILDER_PT_BR = {
1104
+ "praxis.pageBuilder.editor.devices.target": "Dispositivo para configurar",
1105
+ "praxis.pageBuilder.editor.devices.advanced": "Ajustes avançados",
1106
+ "praxis.pageBuilder.editor.grouping.canvasNotice": "As posições no canvas controlam esta página. Os agrupamentos abaixo são preservados para layouts de fluxo e não organizam os widgets no canvas. Para colocá-los lado a lado, use os controles de mover e redimensionar de cada widget.",
1107
+ "praxis.pageBuilder.editor.devices.stack": "Empilhar widgets",
1108
+ "praxis.pageBuilder.editor.devices.stackHelp": "Usa a largura total e altura natural, na ordem de leitura. Substitui as posições e os limites de largura apenas neste dispositivo; os outros layouts são preservados.",
1109
+ "praxis.pageBuilder.editor.devices.stacked": "Layout de uma coluna preparado. Salve para aplicar; Redefinir desfaz os ajustes.",
1110
+ 'praxis.pageBuilder.shell.menuPlacement': 'Menu Mais ações',
1095
1111
  'praxis.pageBuilder.editor.limits.tab': "Limites dos widgets",
1096
1112
  'praxis.pageBuilder.editor.limits.help': 'Os limites da grade usam colunas e linhas. Em linhas de conteúdo, a seção de altura também permite pixels. Deixe os limites em branco para usar os padrões ou herdar o layout base.',
1097
1113
  'praxis.pageBuilder.editor.limits.target': "Widget e layout",
@@ -1117,6 +1133,7 @@ const PRAXIS_PAGE_BUILDER_PT_BR = {
1117
1133
  'praxis.pageBuilder.editor.limits.errors.columns': "A largura mínima ultrapassa as colunas da grade.",
1118
1134
  'praxis.pageBuilder.editor.limits.errors.size': "O tamanho atual está fora destes limites. Redimensione o widget antes ou revise os limites.",
1119
1135
  'praxis.pageBuilder.shell.invalidCss': 'Valor CSS inválido. Use uma cor, medida ou expressão válida para este campo.',
1136
+ 'praxis.pageBuilder.shell.stickyHeader': 'Manter cabeçalho visível ao rolar a página',
1120
1137
  'praxis.pageBuilder.authoringSource.alert.title': 'Linhagem semântica requer atenção',
1121
1138
  'praxis.pageBuilder.authoringSource.alert.description': 'A página continua disponível, mas esta fonte não será reutilizada até que uma nova autoria governada restabeleça sua proveniência.',
1122
1139
  'praxis.pageBuilder.authoringSource.diagnostics.authoring-source-schema-invalid': 'O envelope de autoria não atende ao schema suportado pelo Page Builder.',
@@ -1783,6 +1800,7 @@ const PRAXIS_PAGE_BUILDER_PT_BR = {
1783
1800
  'praxis.pageBuilder.connections.draft.backToEdit': 'Voltar à edição',
1784
1801
  'praxis.pageBuilder.connections.draft.present': 'Apresentar diagrama',
1785
1802
  'praxis.pageBuilder.connections.draft.title': 'Rascunho da conexão',
1803
+ 'praxis.pageBuilder.connections.draft.prerequisites': 'Habilite o evento nas configurações do componente de origem. Para seleção de linhas, habilite a seleção única; configure o detalhe como somente leitura quando necessário.',
1786
1804
  'praxis.pageBuilder.connections.draft.help': 'Escolha a origem e o destino. Revise o percurso e o valor entregue antes de aplicar.',
1787
1805
  'praxis.pageBuilder.connections.draft.source': '1 · Origem / evento',
1788
1806
  'praxis.pageBuilder.connections.draft.target': '2 · Destino / entrada',
@@ -2464,9 +2482,13 @@ const SET_INPUT_PRESETS = {
2464
2482
  class WidgetShellEditorComponent {
2465
2483
  registry;
2466
2484
  i18n = inject(PraxisI18nService);
2485
+ menuPlacementLabel() { return this.i18n.t('praxis.pageBuilder.shell.menuPlacement'); }
2467
2486
  appearanceError() {
2468
2487
  return this.i18n.t('praxis.pageBuilder.shell.invalidCss');
2469
2488
  }
2489
+ stickyHeaderLabel() {
2490
+ return this.i18n.t('praxis.pageBuilder.shell.stickyHeader');
2491
+ }
2470
2492
  form = new FormGroup({
2471
2493
  kind: new FormControl('dashboard-card', {
2472
2494
  nonNullable: true,
@@ -2479,6 +2501,7 @@ class WidgetShellEditorComponent {
2479
2501
  showHeader: new FormControl('auto', {
2480
2502
  nonNullable: true,
2481
2503
  }),
2504
+ stickyHeader: new FormControl(false, { nonNullable: true }),
2482
2505
  collapsible: new FormControl(true, { nonNullable: true }),
2483
2506
  cardBg: new FormControl('', { nonNullable: true }),
2484
2507
  cardBorder: new FormControl('', { nonNullable: true }),
@@ -2636,6 +2659,7 @@ class WidgetShellEditorComponent {
2636
2659
  subtitle: this.editableText(shell?.subtitle),
2637
2660
  icon: shell?.icon || '',
2638
2661
  showHeader,
2662
+ stickyHeader: shell?.stickyHeader === true,
2639
2663
  collapsible: window.collapsible !== false,
2640
2664
  cardBg: card.background || '',
2641
2665
  cardBorder: card.borderColor || '',
@@ -2737,6 +2761,7 @@ class WidgetShellEditorComponent {
2737
2761
  delete shell.subtitle;
2738
2762
  delete shell.icon;
2739
2763
  delete shell.showHeader;
2764
+ delete shell.stickyHeader;
2740
2765
  if (raw.preset)
2741
2766
  shell.preset = raw.preset;
2742
2767
  const title = this.preserveTextDescriptor(this.initialTitle, raw.title);
@@ -2749,6 +2774,8 @@ class WidgetShellEditorComponent {
2749
2774
  shell.icon = raw.icon.trim();
2750
2775
  if (raw.showHeader !== 'auto')
2751
2776
  shell.showHeader = raw.showHeader === 'true';
2777
+ if (raw.stickyHeader)
2778
+ shell.stickyHeader = true;
2752
2779
  const windowActions = {
2753
2780
  ...(initial?.windowActions || {}),
2754
2781
  };
@@ -2933,7 +2960,7 @@ class WidgetShellEditorComponent {
2933
2960
  }
2934
2961
  previewOverflowCount() {
2935
2962
  const actions = this.previewActions().filter((a) => (a.placement || 'header') === 'header');
2936
- return Math.max(0, actions.length - 3);
2963
+ return Math.max(0, actions.length - 3) + this.previewActions().filter(a => a.placement === 'menu').length;
2937
2964
  }
2938
2965
  previewWindowActions() {
2939
2966
  return this.previewActions().filter((a) => (a.placement || 'header') === 'window');
@@ -3221,6 +3248,9 @@ class WidgetShellEditorComponent {
3221
3248
  <mat-option value="false">Nunca</mat-option>
3222
3249
  </mat-select>
3223
3250
  </mat-form-field>
3251
+ <mat-checkbox [formControl]="form.controls.stickyHeader">
3252
+ {{ stickyHeaderLabel() }}
3253
+ </mat-checkbox>
3224
3254
  </div>
3225
3255
 
3226
3256
  <div class="shell-section">Controles de janela</div>
@@ -3684,6 +3714,7 @@ class WidgetShellEditorComponent {
3684
3714
  <mat-select formControlName="placement">
3685
3715
  <mat-option value="header">Cabeçalho</mat-option>
3686
3716
  <mat-option value="window">Janela</mat-option>
3717
+ <mat-option value="menu">{{ menuPlacementLabel() }}</mat-option>
3687
3718
  </mat-select>
3688
3719
  </mat-form-field>
3689
3720
  <mat-form-field appearance="outline">
@@ -3831,6 +3862,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
3831
3862
  <mat-option value="false">Nunca</mat-option>
3832
3863
  </mat-select>
3833
3864
  </mat-form-field>
3865
+ <mat-checkbox [formControl]="form.controls.stickyHeader">
3866
+ {{ stickyHeaderLabel() }}
3867
+ </mat-checkbox>
3834
3868
  </div>
3835
3869
 
3836
3870
  <div class="shell-section">Controles de janela</div>
@@ -4294,6 +4328,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
4294
4328
  <mat-select formControlName="placement">
4295
4329
  <mat-option value="header">Cabeçalho</mat-option>
4296
4330
  <mat-option value="window">Janela</mat-option>
4331
+ <mat-option value="menu">{{ menuPlacementLabel() }}</mat-option>
4297
4332
  </mat-select>
4298
4333
  </mat-form-field>
4299
4334
  <mat-form-field appearance="outline">
@@ -4487,13 +4522,24 @@ function deriveConnectionEditorGraph(page, options = {}) {
4487
4522
  const angle = (index / count) * Math.PI * 2 - Math.PI / 2;
4488
4523
  const widgetId = widget.key;
4489
4524
  const componentType = widget.definition?.id ?? 'unknown';
4490
- const inputs = Object.keys(widget.definition?.inputs ?? {});
4525
+ const metadata = options.componentMetadata?.(componentType);
4526
+ const contracts = (metadata?.ports ?? []).filter(port => port.exposure?.public !== false && !port.exposure?.deprecated);
4527
+ const inputs = uniqueStrings([
4528
+ ...contracts.filter(port => port.direction === 'input').map(port => port.id),
4529
+ ...(contracts.length ? [] : metadata?.inputs ?? []).map(input => input.name),
4530
+ ...Object.keys(widget.definition?.inputs ?? {}),
4531
+ ]);
4491
4532
  const outputs = uniqueStrings([
4533
+ ...contracts.filter(port => port.direction === 'output').map(port => port.id),
4534
+ ...(contracts.length ? [] : metadata?.outputs ?? []).map(output => output.name),
4492
4535
  ...Object.keys(widget.definition?.outputs ?? {}),
4493
4536
  ...configuredWidgetEventOutputs(widget),
4494
4537
  ]);
4495
- const outputPorts = (outputs.length ? outputs : ['change']).map((port) => componentPort(widgetId, port, 'output', componentType));
4496
- const inputPorts = prioritizedInputPorts(inputs).map((port) => componentPort(widgetId, port, 'input', componentType));
4538
+ const outputPorts = (outputs.length || metadata ? outputs : ['change']).map((port) => componentPort(widgetId, port, 'output', componentType));
4539
+ const inputPorts = (metadata ? inputs : prioritizedInputPorts(inputs)).map((port) => componentPort(widgetId, port, 'input', componentType));
4540
+ for (const port of [...inputPorts, ...outputPorts]) {
4541
+ port.contract = contracts.find(contract => contract.id === port.label && contract.direction === port.kind);
4542
+ }
4497
4543
  for (const nested of collectNestedWidgets(widget)) {
4498
4544
  const nestedComponentType = nested.definition.id ?? nested.path.find((segment) => segment.kind === 'widget')?.componentType;
4499
4545
  const nestedPrefix = nested.definition.childWidgetKey
@@ -4787,6 +4833,17 @@ function buildCompositionLinkFromPorts(source, target, links) {
4787
4833
  };
4788
4834
  }
4789
4835
  function inferCanonicalTransform(source, target) {
4836
+ if (source.kind === 'output' && target.kind === 'input'
4837
+ && source.contract?.resourceQueryFieldSource && target.contract?.semanticKind === 'query-context') {
4838
+ return {
4839
+ id: `${source.nodeId}-${target.nodeId}-filter-query-projection`,
4840
+ version: '2.0', phase: 'link-propagation', mode: 'object-fragment',
4841
+ sourceBindings: [{ source: 'event' }],
4842
+ steps: [{ id: 'shape-filter-query', kind: 'object-template', phase: 'link-propagation',
4843
+ input: { source: 'event' }, config: { template: { filters: '${payload}' } } }],
4844
+ output: { semanticKind: 'query-context', cardinality: 'one', stableShape: true, materializationHint: 'replace' },
4845
+ };
4846
+ }
4790
4847
  if (!isTableRowSelectionToFormResourceId(source, target)) {
4791
4848
  return undefined;
4792
4849
  }
@@ -5946,6 +6003,7 @@ class ConnectionEditorComponent {
5946
6003
  }, ...(ngDevMode ? [{ debugName: "visibleEdges" }] : /* istanbul ignore next */ []));
5947
6004
  selectedEdge = computed(() => this.model().edges.find((edge) => edge.id === this.selectedLinkId()) ?? null, ...(ngDevMode ? [{ debugName: "selectedEdge" }] : /* istanbul ignore next */ []));
5948
6005
  editorGraph = computed(() => deriveConnectionEditorGraph(this.workingPage(), {
6006
+ componentMetadata: componentType => this.componentRegistry.get(componentType),
5949
6007
  stateNodeTitle: this.tx('connections.editor.stateNode', 'Page state'),
5950
6008
  globalActionIds: this.availableGlobalActionIds(),
5951
6009
  widgetTitle: widget => {
@@ -5953,7 +6011,10 @@ class ConnectionEditorComponent {
5953
6011
  if (title && !title.includes('${'))
5954
6012
  return title;
5955
6013
  const metadata = this.componentRegistry.get(widget.definition.id);
5956
- return metadata ? `${metadata.friendlyName} · ${widget.key}` : widget.key;
6014
+ if (!metadata)
6015
+ return widget.key;
6016
+ const peers = (this.workingPage()?.widgets ?? []).filter(peer => peer.definition.id === widget.definition.id);
6017
+ return peers.length > 1 ? `${metadata.friendlyName} ${peers.findIndex(peer => peer.key === widget.key) + 1}` : metadata.friendlyName;
5957
6018
  },
5958
6019
  }), ...(ngDevMode ? [{ debugName: "editorGraph" }] : /* istanbul ignore next */ []));
5959
6020
  connectedNodeIds = computed(() => {
@@ -9103,6 +9164,7 @@ class ConnectionEditorComponent {
9103
9164
  @for (port of draftTargets(); track port.id) { <option [value]="port.id">{{ draftPortLabel(port) }}</option> }
9104
9165
  </select>
9105
9166
  </label>
9167
+ <p>{{ tx('connections.draft.prerequisites', 'Enable the source event in its component settings. For row selection, enable single selection; configure the detail as read-only when needed.') }}</p>
9106
9168
  @if (selectedConnection(); as connection) {
9107
9169
  <div class="connection-editor__draft-review">
9108
9170
  <span>{{ tx('connections.draft.review', 'Review the route') }}</span>
@@ -10737,6 +10799,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
10737
10799
  @for (port of draftTargets(); track port.id) { <option [value]="port.id">{{ draftPortLabel(port) }}</option> }
10738
10800
  </select>
10739
10801
  </label>
10802
+ <p>{{ tx('connections.draft.prerequisites', 'Enable the source event in its component settings. For row selection, enable single selection; configure the detail as read-only when needed.') }}</p>
10740
10803
  @if (selectedConnection(); as connection) {
10741
10804
  <div class="connection-editor__draft-review">
10742
10805
  <span>{{ tx('connections.draft.review', 'Review the route') }}</span>
@@ -11789,9 +11852,12 @@ class DynamicPageConfigEditorComponent {
11789
11852
  limitsError = signal(null, ...(ngDevMode ? [{ debugName: "limitsError" }] : /* istanbul ignore next */ []));
11790
11853
  limitsErrorMatcher = { isErrorState: (control) => !!control?.invalid };
11791
11854
  injectedData = inject(SETTINGS_PANEL_DATA$1, { optional: true });
11855
+ isHostedInSettingsPanel = this.injectedData != null;
11792
11856
  i18n = inject(PraxisI18nService);
11793
11857
  pageInput = null;
11794
11858
  snapshot = '';
11859
+ stackedDevices = new Set();
11860
+ activeDevice = signal('mobile', ...(ngDevMode ? [{ debugName: "activeDevice" }] : /* istanbul ignore next */ []));
11795
11861
  hydrating = false;
11796
11862
  renderedCandidate = null;
11797
11863
  renderedValidationFingerprint = '';
@@ -11808,6 +11874,19 @@ class DynamicPageConfigEditorComponent {
11808
11874
  get validationStateLabel() { const issue = this.limitsError() || this.canvasError() || this.groupingError() || this.deviceError() || this.contextError() || this.stateError(); if (issue)
11809
11875
  return this.tx('editor.status.validationIssue', 'Review: {{issue}}').replace('{{issue}}', issue); return this.isValid$.value ? this.tx('editor.status.valid', 'Valid configuration') : this.tx('editor.status.review', 'Review errors before saving'); }
11810
11876
  get dirtyStateLabel() { return this.hasPendingChanges ? this.tx('editor.status.pending', 'Pending changes') : this.tx('editor.status.clean', 'No changes'); }
11877
+ get nonLimitError() {
11878
+ return this.canvasError() || this.groupingError() || this.deviceError() || this.contextError() || this.stateError();
11879
+ }
11880
+ get limitsSummaryError() {
11881
+ const message = this.limitsError();
11882
+ const form = this.canvasLimits.form.at(this.limitsTargetControl.value);
11883
+ // A rendered-height diagnostic is already attached to the visible height field.
11884
+ // Keep the summary for a different target or a field hidden by the current mode.
11885
+ return form?.controls.heightMode.value === 'fixed'
11886
+ && this.limitsUseContentHeight()
11887
+ && form.controls.heightPx.getError('canvasLimit') === 'rendered-height'
11888
+ ? null : message;
11889
+ }
11811
11890
  get selectedLayoutPresetLabel() { return this.tx('editor.summary.layout', 'Layout: {{label}}').replace('{{label}}', this.lookupPresetLabel(this.layoutPresetEntries, this.layoutPresetControl.value, this.tx('editor.fallback.manual', 'manual'))); }
11812
11891
  get selectedThemePresetLabel() { return this.tx('editor.summary.theme', 'Theme: {{label}}').replace('{{label}}', this.lookupPresetLabel(this.themePresetEntries, this.themePresetControl.value, this.tx('editor.layout.layoutDefault', 'Layout default'))); }
11813
11892
  get selectedLayoutPresetEntry() { return this.layoutPresetEntries.find((entry) => entry.id === this.layoutPresetControl.value) || null; }
@@ -11820,6 +11899,38 @@ class DynamicPageConfigEditorComponent {
11820
11899
  reset() { this.hydrate(); }
11821
11900
  pinRecommendedThemePreset() { const theme = this.recommendedThemePresetEntry; if (theme)
11822
11901
  this.themePresetControl.setValue(theme.id); }
11902
+ get hasCanvasWidgets() { return !!this.currentPageInput()?.widgets.length; }
11903
+ stackDevice(device) {
11904
+ this.hydrating = true;
11905
+ this.stackedDevices.add(device);
11906
+ this.deviceForms[device].patchValue({ columns: 1, autoRows: 'content' }, { emitEvent: false });
11907
+ for (const [index, target] of this.canvasLimits.targets.entries()) {
11908
+ if (target.scope !== device)
11909
+ continue;
11910
+ this.canvasLimits.form.at(index).patchValue({
11911
+ minColSpan: 1, maxColSpan: 1,
11912
+ heightMode: 'auto', heightPx: null, offsetTopPx: null,
11913
+ }, { emitEvent: false });
11914
+ }
11915
+ this.hydrating = false;
11916
+ this.refreshPanelState();
11917
+ }
11918
+ stackedCanvas(device, previous) {
11919
+ const page = this.currentPageInput() || { widgets: [] };
11920
+ const source = this.completeCanvasItems(page);
11921
+ const widgetOverrides = this.parseWidgetOverrides(this.deviceForms[device].controls.widgetOverrides.value);
11922
+ const items = this.clone(previous.items || {});
11923
+ let row = 1;
11924
+ for (const [key] of Object.entries(source).sort(([aKey, a], [bKey, b]) => a.row - b.row || a.col - b.col || aKey.localeCompare(bKey))) {
11925
+ if (!page.widgets.some(widget => widget.key === key) || items[key]?.hidden || widgetOverrides?.[key]?.hidden)
11926
+ continue;
11927
+ const rowSpan = items[key]?.rowSpan ?? source[key].rowSpan;
11928
+ items[key] = { ...items[key], col: 1, row, colSpan: 1, rowSpan, contentSize: 'auto',
11929
+ constraints: { ...items[key]?.constraints, minColSpan: 1, maxColSpan: 1 } };
11930
+ row += rowSpan;
11931
+ }
11932
+ return { ...previous, items };
11933
+ }
11823
11934
  addGroupingNode(seed) { this.groupingNodes.push(this.createGroupingNodeGroup(seed)); this.refreshPanelState(); }
11824
11935
  removeGroupingNode(index) { this.groupingNodes.removeAt(index); this.refreshPanelState(); }
11825
11936
  layoutPresetSummary(preset) { return this.tx('editor.preview.layoutSummary', '{{columns}} columns · {{gap}}').replace('{{columns}}', String(preset.defaultColumns)).replace('{{gap}}', preset.defaultGap || this.tx('editor.preview.defaultGap', 'default gap')); }
@@ -11858,7 +11969,7 @@ class DynamicPageConfigEditorComponent {
11858
11969
  this.layoutPresetControl.value, this.themePresetControl.value, this.shellPresetControl.value,
11859
11970
  this.contextControl.value, this.stateControl.value, this.groupingNodes.getRawValue(),
11860
11971
  this.deviceKeys.map(key => this.deviceForms[key].getRawValue()),
11861
- this.canvasLimits.form.getRawValue(),
11972
+ this.canvasLimits.form.getRawValue(), [...this.stackedDevices],
11862
11973
  ]);
11863
11974
  }
11864
11975
  limitsGeometryLabel() {
@@ -11933,7 +12044,7 @@ class DynamicPageConfigEditorComponent {
11933
12044
  }
11934
12045
  createDeviceVariantForm() { return new FormGroup({ columns: new FormControl(null, { validators: [Validators.min(1), integerColumnValidator] }), rowUnit: new FormControl('', { nonNullable: true }), gap: new FormControl('', { nonNullable: true }), autoRows: new FormControl('', { nonNullable: true }), groupingOverrides: new FormControl('', { nonNullable: true }), widgetOverrides: new FormControl('', { nonNullable: true }) }); }
11935
12046
  createGroupingNodeGroup(seed) { const label = seed && 'label' in seed && typeof seed.label === 'string' ? seed.label : ''; return new FormGroup({ kind: new FormControl(seed?.kind || 'section', { nonNullable: true }), id: new FormControl(seed?.id || '', { nonNullable: true, validators: [Validators.required] }), label: new FormControl(label, { nonNullable: true }), widgetKeysCsv: new FormControl(this.stringifyGroupingWidgetKeys(seed), { nonNullable: true }), layout: new FormControl(seed?.kind === 'section' ? seed.layout || '' : '', { nonNullable: true }), emphasis: new FormControl(seed?.kind === 'hero' ? seed.emphasis || '' : '', { nonNullable: true }), side: new FormControl(seed?.kind === 'rail' ? seed.side || 'right' : 'right', { nonNullable: true }), tabsJson: new FormControl(seed?.kind === 'tabs' ? JSON.stringify(seed.tabs || [], null, 2) : '', { nonNullable: true }) }); }
11936
- hydrate() { const page = this.currentPageInput() || { widgets: [] }; const canvas = page.canvas || this.defaultCanvas(); const context = this.clone(page.context || {}); this.hydrating = true; this.canvasLimits.hydrate(page); this.limitsTargetControl.setValue(0, { emitEvent: false }); this.limitsError.set(null); this.canvasColumnsControl.setValue(canvas.columns || 12, { emitEvent: false }); this.canvasRowUnitControl.setValue(canvas.rowUnit || '80px', { emitEvent: false }); this.canvasGapControl.setValue(canvas.gap || '16px', { emitEvent: false }); this.canvasAutoRowsControl.setValue(canvas.autoRows || 'fixed', { emitEvent: false }); this.layoutPresetControl.setValue(page.layoutPreset || '', { emitEvent: false }); this.themePresetControl.setValue(page.themePreset || '', { emitEvent: false }); this.shellPresetControl.setValue(this.extractShellPreset(context) || this.pagePreset || '', { emitEvent: false }); this.replaceGroupingNodes(page.grouping); this.contextControl.setValue(this.stringify(this.removeShellPresetFromContext(context)), { emitEvent: false }); this.stateControl.setValue(this.stringify(page.state || {}), { emitEvent: false }); for (const device of this.deviceKeys) {
12047
+ hydrate() { this.stackedDevices.clear(); const page = this.currentPageInput() || { widgets: [] }; const canvas = page.canvas || this.defaultCanvas(); const context = this.clone(page.context || {}); this.hydrating = true; this.canvasLimits.hydrate(page); this.limitsTargetControl.setValue(0, { emitEvent: false }); this.limitsError.set(null); this.canvasColumnsControl.setValue(canvas.columns || 12, { emitEvent: false }); this.canvasRowUnitControl.setValue(canvas.rowUnit || '80px', { emitEvent: false }); this.canvasGapControl.setValue(canvas.gap || '16px', { emitEvent: false }); this.canvasAutoRowsControl.setValue(canvas.autoRows || 'fixed', { emitEvent: false }); this.layoutPresetControl.setValue(page.layoutPreset || '', { emitEvent: false }); this.themePresetControl.setValue(page.themePreset || '', { emitEvent: false }); this.shellPresetControl.setValue(this.extractShellPreset(context) || this.pagePreset || '', { emitEvent: false }); this.replaceGroupingNodes(page.grouping); this.contextControl.setValue(this.stringify(this.removeShellPresetFromContext(context)), { emitEvent: false }); this.stateControl.setValue(this.stringify(page.state || {}), { emitEvent: false }); for (const device of this.deviceKeys) {
11937
12048
  const variant = page.deviceLayouts?.[device];
11938
12049
  this.deviceForms[device].setValue({ columns: variant?.canvas?.columns ?? null, rowUnit: variant?.canvas?.rowUnit || '', gap: variant?.canvas?.gap || '', autoRows: variant?.canvas?.autoRows || '', groupingOverrides: this.stringify(variant?.groupingOverrides || []), widgetOverrides: this.stringify(variant?.widgetOverrides || {}) }, { emitEvent: false });
11939
12050
  } this.canvasError.set(null); this.groupingError.set(null); this.deviceError.set(null); this.contextError.set(null); this.stateError.set(null); this.hydrating = false; this.refreshPanelState(true); }
@@ -11944,7 +12055,7 @@ class DynamicPageConfigEditorComponent {
11944
12055
  return null; this.groupingError.set(null); const context = this.parseRecordJson(this.contextControl.value, {}); if (context === null)
11945
12056
  return this.invalidate('context', this.contextError, reportErrors, this.tx('editor.errors.contextInvalid', 'Invalid context')); this.contextError.set(null); const state = this.parseRecordJson(this.stateControl.value, {}); if (state === null)
11946
12057
  return this.invalidate('state', this.stateError, reportErrors, this.tx('editor.errors.stateInvalid', 'Invalid state')); this.stateError.set(null); const deviceLayouts = this.buildDeviceLayouts(reportErrors, next.deviceLayouts); if (deviceLayouts === null)
11947
- return null; next.canvas = { ...(next.canvas || this.defaultCanvas()), mode: 'grid', columns: this.canvasColumnsControl.value, rowUnit: this.canvasRowUnitControl.value.trim() || '80px', gap: this.canvasGapControl.value.trim() || '16px', autoRows: this.canvasAutoRowsControl.value, items: next.canvas?.items || {} }; next.layoutPreset = this.layoutPresetControl.value.trim() || undefined; next.themePreset = this.themePresetControl.value.trim() || undefined; next.grouping = grouping.length ? grouping : undefined; next.deviceLayouts = Object.keys(deviceLayouts).length ? deviceLayouts : undefined; next.context = this.applyShellPresetToContext(context, this.shellPresetControl.value.trim()); next.state = Object.keys(state).length ? state : undefined; return this.applyCanvasLimits(next, reportErrors); }
12058
+ return null; next.canvas = { ...(next.canvas || this.defaultCanvas()), mode: 'grid', columns: this.canvasColumnsControl.value, rowUnit: this.canvasRowUnitControl.value.trim() || '80px', gap: this.canvasGapControl.value.trim() || '16px', autoRows: this.canvasAutoRowsControl.value, items: this.completeCanvasItems(next) }; next.layoutPreset = this.layoutPresetControl.value.trim() || undefined; next.themePreset = this.themePresetControl.value.trim() || undefined; next.grouping = grouping.length ? grouping : undefined; next.deviceLayouts = Object.keys(deviceLayouts).length ? deviceLayouts : undefined; next.context = this.applyShellPresetToContext(context, this.shellPresetControl.value.trim()); next.state = Object.keys(state).length ? state : undefined; return this.applyCanvasLimits(next, reportErrors); }
11948
12059
  buildGrouping(reportErrors) { const grouping = []; const seenIds = new Set(); for (const group of this.groupingNodes.controls) {
11949
12060
  const kind = group.controls.kind.value;
11950
12061
  const id = group.controls.id.value.trim();
@@ -11974,6 +12085,18 @@ class DynamicPageConfigEditorComponent {
11974
12085
  }
11975
12086
  grouping.push({ kind: 'rail', id, side: (group.controls.side.value || 'right'), widgetKeys });
11976
12087
  } return grouping; }
12088
+ /** A grid must place every existing widget before replacing the flow layout. */
12089
+ completeCanvasItems(page) {
12090
+ const items = { ...(page.canvas?.items || {}) };
12091
+ let row = Math.max(1, ...Object.values(items).map(item => item.row + item.rowSpan));
12092
+ for (const widget of page.widgets) {
12093
+ if (items[widget.key])
12094
+ continue;
12095
+ items[widget.key] = { col: 1, row, colSpan: this.canvasColumnsControl.value, rowSpan: 1 };
12096
+ row += 1;
12097
+ }
12098
+ return items;
12099
+ }
11977
12100
  buildDeviceLayouts(reportErrors, base) { const next = {}; for (const device of this.deviceKeys) {
11978
12101
  const form = this.deviceForms[device];
11979
12102
  if (form.controls.columns.invalid)
@@ -11987,7 +12110,7 @@ class DynamicPageConfigEditorComponent {
11987
12110
  delete baseVariant.canvas;
11988
12111
  delete baseVariant.groupingOverrides;
11989
12112
  delete baseVariant.widgetOverrides;
11990
- const canvas = { ...baseCanvas };
12113
+ const canvas = this.stackedDevices.has(device) ? this.stackedCanvas(device, baseCanvas) : { ...baseCanvas };
11991
12114
  if (form.controls.columns.value != null)
11992
12115
  canvas.columns = form.controls.columns.value;
11993
12116
  else
@@ -12075,27 +12198,68 @@ class DynamicPageConfigEditorComponent {
12075
12198
  next.ui.shell.preset = shellPreset;
12076
12199
  } return Object.keys(next).length ? next : undefined; }
12077
12200
  lookupPresetLabel(entries, value, fallback) { return entries.find((entry) => entry.id === value)?.label || fallback; }
12078
- defaultCanvas() { return { mode: 'grid', columns: 12, rowUnit: '80px', gap: '16px', autoRows: 'fixed', items: {} }; }
12201
+ defaultCanvas() { return { mode: 'grid', columns: 12, rowUnit: '80px', gap: '16px', autoRows: 'content', items: {} }; }
12079
12202
  clone(value) { if (value == null || typeof value !== 'object')
12080
12203
  return value; return JSON.parse(JSON.stringify(value)); }
12081
12204
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DynamicPageConfigEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
12082
12205
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: DynamicPageConfigEditorComponent, isStandalone: true, selector: "praxis-dynamic-page-config-editor", inputs: { page: "page", layout: "layout", presets: "presets", pagePreset: "pagePreset" }, outputs: { pageChange: "pageChange" }, host: { listeners: { "window:resize": "refreshRenderedValidation()" } }, providers: [providePraxisPageBuilderI18n()], ngImport: i0, template: `
12083
12206
  <div class="editor">
12084
- <div class="head">
12207
+ @if (!isHostedInSettingsPanel) { <div class="head">
12085
12208
  <div>{{ tx('editor.title', 'Page settings') }}</div>
12086
12209
  <button mat-stroked-button type="button" (click)="reset()">{{ tx('editor.reload', 'Reload') }}</button>
12087
- </div>
12088
- <div class="summary" [attr.aria-label]="tx('editor.summaryAria', 'Configuration summary')">
12089
- <span>{{ validationStateLabel }}</span>
12090
- <span>{{ dirtyStateLabel }}</span>
12210
+ </div> }
12211
+ <details class="summary-details">
12212
+ <summary>{{ tx('editor.summaryAria', 'Configuration summary') }}</summary>
12213
+ <div class="summary">
12214
+ @if (!isHostedInSettingsPanel) {
12215
+ <span>{{ validationStateLabel }}</span>
12216
+ <span>{{ dirtyStateLabel }}</span>
12217
+ }
12091
12218
  <span>{{ selectedLayoutPresetLabel }}</span>
12092
12219
  <span>{{ selectedThemePresetLabel }}</span>
12093
12220
  <span>{{ canvasSummaryLabel }}</span>
12094
12221
  </div>
12095
- <mat-tab-group [selectedIndex]="selectedTabIndex" (selectedIndexChange)="onSelectedTabIndexChange($event)">
12222
+ </details>
12223
+ @if (nonLimitError) { <p class="limits-error" role="alert">{{ nonLimitError }}</p> }
12224
+ @if (activeTab() === 'limits' && canvasLimits.targets.length) {
12225
+ <div class="selection-identity" data-testid="canvas-limits-identity">
12226
+ <mat-icon aria-hidden="true">dashboard_customize</mat-icon>
12227
+ <strong>{{ limitsTargetLabel(limitsTargetControl.value) }}</strong>
12228
+ </div>
12229
+ }
12230
+ <mat-tab-group class="editor-tabs" [mat-stretch-tabs]="false" [selectedIndex]="selectedTabIndex" (selectedIndexChange)="onSelectedTabIndexChange($event)">
12096
12231
  <mat-tab [label]="tx('editor.tabs.layout', 'Layout')"><div class="grid"><mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.cols', 'Columns') }}</mat-label><input matInput type="number" min="1" [formControl]="canvasColumnsControl" /></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.rowUnit', 'Row unit') }}</mat-label><input matInput [formControl]="canvasRowUnitControl" /></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.gap', 'Gap') }}</mat-label><input matInput [formControl]="canvasGapControl" /></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.autoRows', 'Auto rows') }}</mat-label><mat-select [formControl]="canvasAutoRowsControl"><mat-option value="fixed">{{ tx('editor.layout.autoRowsFixed', 'Fixed height') }}</mat-option><mat-option value="content">{{ tx('editor.layout.autoRowsContent', 'Fit content') }}</mat-option></mat-select></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.layoutPreset', 'Layout preset') }}</mat-label><mat-select [formControl]="layoutPresetControl"><mat-option value="">{{ tx('editor.layout.none', 'None') }}</mat-option>@for (preset of layoutPresetEntries; track preset.id) {<mat-option [value]="preset.id">{{ preset.label }}</mat-option>}</mat-select></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.themePreset', 'Theme preset') }}</mat-label><mat-select [formControl]="themePresetControl"><mat-option value="">{{ tx('editor.layout.layoutDefault', 'Layout default') }}</mat-option>@for (preset of themePresetEntries; track preset.id) {<mat-option [value]="preset.id">{{ preset.label }}</mat-option>}</mat-select></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.shellPreset', 'Shell visual preset') }}</mat-label><mat-select [formControl]="shellPresetControl"><mat-option value="">{{ tx('editor.layout.none', 'None') }}</mat-option>@for (preset of shellPresetEntries; track preset.id) {<mat-option [value]="preset.id">{{ preset.label }}</mat-option>}</mat-select></mat-form-field></div>@if (selectedLayoutPresetEntry; as preset) {<div class="preset-preview"><div class="preset-preview__head"><div><span class="eyebrow">{{ tx('editor.preview.layoutEyebrow', 'Layout template') }} · {{ preset.category }}</span><strong>{{ preset.label }}</strong></div><span>{{ layoutPresetSummary(preset) }}</span></div><p>{{ preset.description || tx('editor.preview.layoutFallbackDescription', 'Page organization preset.') }}</p><div class="preset-preview__chips"><span>{{ slotsSummary(preset) }}</span><span>{{ requiredSlotsSummary(preset) }}</span><span>{{ devicePolicySummary(preset) }}</span></div>@if (recommendedThemePresetEntry; as theme) {<div class="preset-preview__recommendation"><div><span class="eyebrow">{{ tx('editor.preview.inheritedTheme', 'Inherited theme') }}</span><strong>{{ theme.label }}</strong><span>{{ inheritedThemeHelp(theme) }}</span></div><button mat-stroked-button type="button" [disabled]="themePresetControl.value === theme.id" (click)="pinRecommendedThemePreset()">{{ tx('editor.preview.pinTheme', 'Pin theme') }}</button></div>}</div>} @if (selectedThemePresetEntry; as theme) {<div class="preset-preview preset-preview--theme"><div class="preset-preview__head"><div><span class="eyebrow">{{ tx('editor.preview.explicitTheme', 'Explicit theme') }}</span><strong>{{ theme.label }}</strong></div><span>{{ theme.density }} · {{ theme.motion }}</span></div><p>{{ theme.description || tx('editor.preview.themeFallbackDescription', 'Page visual preset.') }}</p><div class="preset-preview__chips"><span>{{ tx('editor.preview.shellChip', 'Shell: {{value}}').replace('{{value}}', theme.shellPreset) }}</span><span>{{ tx('editor.preview.chartsChip', 'Charts: {{value}}').replace('{{value}}', theme.chartThemePreset) }}</span></div></div>}</mat-tab>
12097
- <mat-tab [label]="tx('editor.tabs.grouping', 'Grouping')"><div class="panel"><div class="head"><span>{{ tx('editor.grouping.title', 'Grouping') }}</span><button mat-stroked-button type="button" (click)="addGroupingNode()"><mat-icon>add</mat-icon>{{ tx('editor.grouping.add', 'Add group') }}</button></div>@for (group of groupingNodes.controls; track $index) {<div class="card" [formGroup]="group"><div class="head"><span>{{ groupLabel($index) }}</span><button praxisIconButton="delete" size="compact" type="button" [attr.aria-label]="tx('editor.grouping.removeAria', 'Remove group')" (click)="removeGroupingNode($index)"></button></div><div class="grid"><mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.kind', 'Kind') }}</mat-label><mat-select formControlName="kind"><mat-option value="section">section</mat-option><mat-option value="tabs">tabs</mat-option><mat-option value="hero">hero</mat-option><mat-option value="rail">rail</mat-option></mat-select></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.id', 'Id') }}</mat-label><input matInput formControlName="id" /></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.label', 'Label') }}</mat-label><input matInput formControlName="label" /></mat-form-field>@if (group.controls.kind.value === 'section') {<mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.layout', 'Layout') }}</mat-label><mat-select formControlName="layout"><mat-option value="">{{ tx('editor.grouping.layoutDefault', 'Default') }}</mat-option><mat-option value="stack">stack</mat-option><mat-option value="grid">grid</mat-option><mat-option value="row">row</mat-option></mat-select></mat-form-field>}@if (group.controls.kind.value === 'hero') {<mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.emphasis', 'Emphasis') }}</mat-label><mat-select formControlName="emphasis"><mat-option value="">{{ tx('editor.grouping.layoutDefault', 'Default') }}</mat-option><mat-option value="high">high</mat-option><mat-option value="medium">medium</mat-option></mat-select></mat-form-field>}@if (group.controls.kind.value === 'rail') {<mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.side', 'Side') }}</mat-label><mat-select formControlName="side"><mat-option value="left">left</mat-option><mat-option value="right">right</mat-option></mat-select></mat-form-field>}@if (group.controls.kind.value === 'tabs') {<mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.grouping.tabsJson', 'Tabs (JSON)') }}</mat-label><textarea matInput rows="4" formControlName="tabsJson"></textarea></mat-form-field>} @else {<mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.grouping.widgetKeysCsv', 'Widget keys (comma-separated)') }}</mat-label><input matInput formControlName="widgetKeysCsv" /></mat-form-field>}</div></div>}</div></mat-tab>
12098
- <mat-tab [label]="tx('editor.tabs.devices', 'Device Layouts')"><div class="panel">@for (device of deviceKeys; track device) {<div class="card"><div>{{ deviceLabels[device] }}</div><div class="grid"><mat-form-field appearance="outline"><mat-label>{{ tx('editor.devices.columns', 'Columns') }}</mat-label><input matInput type="number" min="1" [formControl]="deviceForms[device].controls.columns" /></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.rowUnit', 'Row unit') }}</mat-label><input matInput [formControl]="deviceForms[device].controls.rowUnit" /></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.devices.gap', 'Gap') }}</mat-label><input matInput [formControl]="deviceForms[device].controls.gap" /></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.autoRows', 'Auto rows') }}</mat-label><mat-select [formControl]="deviceForms[device].controls.autoRows"><mat-option value="">{{ tx('editor.devices.orientationDefault', 'Default') }}</mat-option><mat-option value="fixed">{{ tx('editor.layout.autoRowsFixed', 'Fixed height') }}</mat-option><mat-option value="content">{{ tx('editor.layout.autoRowsContent', 'Fit content') }}</mat-option></mat-select></mat-form-field><mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.devices.groupingOverridesJson', 'Grouping overrides (JSON)') }}</mat-label><textarea matInput rows="4" [formControl]="deviceForms[device].controls.groupingOverrides"></textarea></mat-form-field><mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.devices.widgetOverridesJson', 'Widget overrides (JSON)') }}</mat-label><textarea matInput rows="4" [formControl]="deviceForms[device].controls.widgetOverrides"></textarea></mat-form-field></div><div class="hint">{{ tx('editor.preview.deviceCanvasHint', 'Existing canvas item overrides are preserved.') }}</div></div>}</div></mat-tab>
12232
+ <mat-tab [label]="tx('editor.tabs.grouping', 'Grouping')"><div class="panel">
12233
+ <p class="hint" data-testid="canvas-grouping-notice">{{ tx('editor.grouping.canvasNotice', 'Canvas positions control this page. Groups below are preserved for flow layouts and do not arrange widgets in the canvas. Use the widget move and resize controls to place them side by side.') }}</p><div class="head"><span>{{ tx('editor.grouping.title', 'Grouping') }}</span><button mat-stroked-button type="button" (click)="addGroupingNode()"><mat-icon>add</mat-icon>{{ tx('editor.grouping.add', 'Add group') }}</button></div>@for (group of groupingNodes.controls; track $index) {<div class="card" [formGroup]="group"><div class="head"><span>{{ groupLabel($index) }}</span><button praxisIconButton="delete" size="compact" type="button" [attr.aria-label]="tx('editor.grouping.removeAria', 'Remove group')" (click)="removeGroupingNode($index)"></button></div><div class="grid"><mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.kind', 'Kind') }}</mat-label><mat-select formControlName="kind"><mat-option value="section">section</mat-option><mat-option value="tabs">tabs</mat-option><mat-option value="hero">hero</mat-option><mat-option value="rail">rail</mat-option></mat-select></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.id', 'Id') }}</mat-label><input matInput formControlName="id" /></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.label', 'Label') }}</mat-label><input matInput formControlName="label" /></mat-form-field>@if (group.controls.kind.value === 'section') {<mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.layout', 'Layout') }}</mat-label><mat-select formControlName="layout"><mat-option value="">{{ tx('editor.grouping.layoutDefault', 'Default') }}</mat-option><mat-option value="stack">stack</mat-option><mat-option value="grid">grid</mat-option><mat-option value="row">row</mat-option></mat-select></mat-form-field>}@if (group.controls.kind.value === 'hero') {<mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.emphasis', 'Emphasis') }}</mat-label><mat-select formControlName="emphasis"><mat-option value="">{{ tx('editor.grouping.layoutDefault', 'Default') }}</mat-option><mat-option value="high">high</mat-option><mat-option value="medium">medium</mat-option></mat-select></mat-form-field>}@if (group.controls.kind.value === 'rail') {<mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.side', 'Side') }}</mat-label><mat-select formControlName="side"><mat-option value="left">left</mat-option><mat-option value="right">right</mat-option></mat-select></mat-form-field>}@if (group.controls.kind.value === 'tabs') {<mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.grouping.tabsJson', 'Tabs (JSON)') }}</mat-label><textarea matInput rows="4" formControlName="tabsJson"></textarea></mat-form-field>} @else {<mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.grouping.widgetKeysCsv', 'Widget keys (comma-separated)') }}</mat-label><input matInput formControlName="widgetKeysCsv" /></mat-form-field>}</div></div>}</div></mat-tab>
12234
+ <mat-tab [label]="tx('editor.tabs.devices', 'Device Layouts')">
12235
+ <div class="panel">
12236
+ <mat-button-toggle-group [value]="activeDevice()" (change)="activeDevice.set($event.value)" [attr.aria-label]="tx('editor.devices.target', 'Device to configure')">
12237
+ @for (device of deviceKeys; track device) {
12238
+ <mat-button-toggle [value]="device">{{ deviceLabels[device] }}</mat-button-toggle>
12239
+ }
12240
+ </mat-button-toggle-group>
12241
+ @for (device of deviceKeys; track device) {
12242
+ @if (activeDevice() === device) {
12243
+ <div class="card">
12244
+ <div class="head"><strong>{{ deviceLabels[device] }}</strong><button mat-stroked-button type="button" [attr.data-testid]="'stack-device-' + device" [disabled]="!hasCanvasWidgets" (click)="stackDevice(device)"><mat-icon aria-hidden="true">view_agenda</mat-icon>{{ tx('editor.devices.stack', 'Stack widgets') }}</button></div>
12245
+ <p class="hint">{{ tx('editor.devices.stackHelp', 'Use full width and natural height, in reading order. This replaces positions and width limits only for this device; other layouts stay unchanged.') }}</p>
12246
+ @if (stackedDevices.has(device)) { <p class="hint" role="status">{{ tx('editor.devices.stacked', 'Single-column layout prepared. Save to apply; Reset undoes the changes.') }}</p> }
12247
+ <div class="grid">
12248
+ <mat-form-field appearance="outline"><mat-label>{{ tx('editor.devices.columns', 'Columns') }}</mat-label><input matInput type="number" min="1" [formControl]="deviceForms[device].controls.columns" /></mat-form-field>
12249
+ <mat-form-field appearance="outline"><mat-label>{{ tx('editor.devices.gap', 'Gap') }}</mat-label><input matInput [formControl]="deviceForms[device].controls.gap" /></mat-form-field>
12250
+ <mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.autoRows', 'Auto rows') }}</mat-label><mat-select [formControl]="deviceForms[device].controls.autoRows"><mat-option value="">{{ tx('editor.devices.orientationDefault', 'Default') }}</mat-option><mat-option value="fixed">{{ tx('editor.layout.autoRowsFixed', 'Fixed height') }}</mat-option><mat-option value="content">{{ tx('editor.layout.autoRowsContent', 'Fit content') }}</mat-option></mat-select></mat-form-field>
12251
+ </div>
12252
+ <details><summary>{{ tx('editor.devices.advanced', 'Advanced overrides') }}</summary><div class="grid">
12253
+ <mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.rowUnit', 'Row unit') }}</mat-label><input matInput [formControl]="deviceForms[device].controls.rowUnit" /></mat-form-field>
12254
+ <mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.devices.groupingOverridesJson', 'Grouping overrides (JSON)') }}</mat-label><textarea matInput rows="4" [formControl]="deviceForms[device].controls.groupingOverrides"></textarea></mat-form-field>
12255
+ <mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.devices.widgetOverridesJson', 'Widget overrides (JSON)') }}</mat-label><textarea matInput rows="4" [formControl]="deviceForms[device].controls.widgetOverrides"></textarea></mat-form-field>
12256
+ </div></details>
12257
+ @if (!stackedDevices.has(device)) { <div class="hint">{{ tx('editor.preview.deviceCanvasHint', 'Existing canvas item overrides are preserved.') }}</div> }
12258
+ </div>
12259
+ }
12260
+ }
12261
+ </div>
12262
+ </mat-tab>
12099
12263
  <mat-tab [label]="tx('editor.tabs.context', 'Context')"><mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.context.json', 'Context (JSON)') }}</mat-label><textarea matInput rows="12" [formControl]="contextControl"></textarea></mat-form-field></mat-tab>
12100
12264
  <mat-tab [label]="tx('editor.tabs.state', 'Page State')"><mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.state.valuesJson', 'Primary values (JSON)') }}</mat-label><textarea matInput rows="14" [formControl]="stateControl"></textarea></mat-form-field></mat-tab>
12101
12265
  <mat-tab [label]="tx('editor.limits.tab', 'Widget limits')">
@@ -12169,7 +12333,7 @@ class DynamicPageConfigEditorComponent {
12169
12333
  </div>
12170
12334
  }
12171
12335
  }
12172
- @if (limitsError()) { <p class="limits-error" role="alert">{{ limitsError() }}</p> }
12336
+ @if (limitsSummaryError) { <p class="limits-error" role="alert">{{ limitsSummaryError }}</p> }
12173
12337
  } @else {
12174
12338
  <p>{{ tx('editor.limits.empty', 'Add widgets to the canvas to configure their limits.') }}</p>
12175
12339
  }
@@ -12177,27 +12341,68 @@ class DynamicPageConfigEditorComponent {
12177
12341
  </mat-tab>
12178
12342
  </mat-tab-group>
12179
12343
  </div>
12180
- `, isInline: true, styles: [":host{display:block;min-width:0;max-width:100%}.height-modes{width:fit-content;max-width:100%;flex-wrap:wrap;margin-bottom:16px}.editor,.panel{display:grid;gap:16px;min-width:0;max-width:100%;color:var(--md-sys-color-on-surface, currentColor)}mat-tab-group,mat-form-field{min-width:0;max-width:100%}.head,.summary{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.summary{color:var(--md-sys-color-on-surface-variant, currentColor)}.grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(min(220px,100%),1fr))}@media(max-width:600px){.grid{grid-template-columns:minmax(0,1fr)}.head,.summary,.preset-preview__head,.preset-preview__recommendation{align-items:stretch}.head>button,.preset-preview__recommendation button{width:100%}}.card{display:grid;gap:12px;padding:16px;border:1px solid var(--md-sys-color-outline-variant, color-mix(in srgb, currentColor 18%, transparent));border-radius:8px;background:var(--md-sys-color-surface-container-low, var(--md-sys-color-surface-container, transparent));color:var(--md-sys-color-on-surface, currentColor)}.limits-panel{display:grid;gap:8px;min-width:0}.limits-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:4px 16px}.limits-fields mat-form-field{min-width:0}.limits-notice{display:flex;gap:8px;align-items:flex-start;color:var(--md-sys-color-on-surface-variant, currentColor)}.limits-notice mat-icon{flex-shrink:0}.limits-error{color:var(--md-sys-color-error, #b3261e)}.wide{grid-column:1 / -1}.hint{color:var(--md-sys-color-on-surface-variant, currentColor);font-size:12px}.preset-preview{display:grid;gap:12px;margin-top:12px;padding:16px;border:1px solid var(--md-sys-color-outline-variant, color-mix(in srgb, currentColor 18%, transparent));border-radius:8px;background:color-mix(in srgb,var(--md-sys-color-surface-container-low, transparent) 88%,var(--md-sys-color-primary-container, transparent) 12%)}.preset-preview--theme{background:var(--md-sys-color-surface-container-low, transparent)}.preset-preview__head,.preset-preview__recommendation{display:flex;justify-content:space-between;align-items:center;gap:16px}.preset-preview__head>div,.preset-preview__recommendation>div{display:grid;gap:4px;min-width:0}.preset-preview p{margin:0;color:var(--md-sys-color-on-surface-variant, currentColor);line-height:1.45}.preset-preview__chips{display:flex;flex-wrap:wrap;gap:8px}.preset-preview__chips span{padding:5px 9px;border-radius:999px;background:var(--md-sys-color-surface-container, color-mix(in srgb, currentColor 8%, transparent));color:var(--md-sys-color-on-surface-variant, currentColor);font-size:12px;line-height:1.3}.preset-preview__recommendation{padding-top:12px;border-top:1px solid var(--md-sys-color-outline-variant, color-mix(in srgb, currentColor 14%, transparent))}.preset-preview__recommendation span:last-child{color:var(--md-sys-color-on-surface-variant, currentColor);font-size:13px;line-height:1.35}.eyebrow{color:var(--md-sys-color-primary, currentColor);font-size:11px;font-weight:700;letter-spacing:0;text-transform:uppercase}:host ::ng-deep .mat-mdc-tab-body-content{padding-top:12px;box-sizing:border-box}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1$1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatButtonToggleModule }, { kind: "directive", type: i3$1.MatButtonToggleGroup, selector: "mat-button-toggle-group", inputs: ["appearance", "name", "vertical", "value", "multiple", "disabled", "disabledInteractive", "hideSingleSelectionIndicator", "hideMultipleSelectionIndicator"], outputs: ["valueChange", "change"], exportAs: ["matButtonToggleGroup"] }, { kind: "component", type: i3$1.MatButtonToggle, selector: "mat-button-toggle", inputs: ["aria-label", "aria-labelledby", "id", "name", "value", "tabIndex", "disableRipple", "appearance", "checked", "disabled", "disabledInteractive"], outputs: ["change"], exportAs: ["matButtonToggle"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i2$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i5.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i10.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i10.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatTabsModule }, { kind: "component", type: i8.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass", "id"], exportAs: ["matTab"] }, { kind: "component", type: i8.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "mat-align-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "component", type: PraxisIconButtonComponent, selector: "button[praxisIconButton]", inputs: ["praxisIconButton", "size", "appearance", "presentation", "pressed", "busy"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
12344
+ `, isInline: true, styles: [":host{display:block;min-width:0;max-width:100%}.height-modes{width:fit-content;max-width:100%;flex-wrap:wrap;margin-bottom:16px}.editor,.panel{display:grid;gap:16px;min-width:0;max-width:100%;color:var(--md-sys-color-on-surface, currentColor)}mat-tab-group,mat-form-field{min-width:0;max-width:100%}.head,.summary{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.summary{color:var(--md-sys-color-on-surface-variant, currentColor)}.selection-identity{position:sticky;top:0;z-index:var(--praxis-layer-sticky-local, 100);display:flex;align-items:flex-start;gap:8px;padding-block:8px;background:var(--md-sys-color-surface-container-low, var(--md-sys-color-surface));overflow-wrap:anywhere}.selection-identity mat-icon{flex-shrink:0}:host ::ng-deep .editor-tabs>.mat-mdc-tab-header .mdc-tab{min-width:0;padding-inline:12px}:host ::ng-deep .editor-tabs>.mat-mdc-tab-header .mdc-tab__text-label{max-width:128px;white-space:normal;overflow-wrap:anywhere;text-align:center;letter-spacing:normal;line-height:1.25}.grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(min(220px,100%),1fr))}@media(max-width:600px){.grid{grid-template-columns:minmax(0,1fr)}.head,.summary,.preset-preview__head,.preset-preview__recommendation{align-items:stretch}.head>button,.preset-preview__recommendation button{width:100%}}.card{display:grid;gap:12px;padding:16px;border:1px solid var(--md-sys-color-outline-variant, color-mix(in srgb, currentColor 18%, transparent));border-radius:8px;background:var(--md-sys-color-surface-container-low, var(--md-sys-color-surface-container, transparent));color:var(--md-sys-color-on-surface, currentColor)}.limits-panel{display:grid;gap:8px;min-width:0}.limits-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:4px 16px}.limits-fields mat-form-field{min-width:0}.limits-notice{display:flex;gap:8px;align-items:flex-start;color:var(--md-sys-color-on-surface-variant, currentColor)}.limits-notice mat-icon{flex-shrink:0}.limits-error{color:var(--md-sys-color-error, #b3261e)}.wide{grid-column:1 / -1}.hint{color:var(--md-sys-color-on-surface-variant, currentColor);font-size:12px}.preset-preview{display:grid;gap:12px;margin-top:12px;padding:16px;border:1px solid var(--md-sys-color-outline-variant, color-mix(in srgb, currentColor 18%, transparent));border-radius:8px;background:color-mix(in srgb,var(--md-sys-color-surface-container-low, transparent) 88%,var(--md-sys-color-primary-container, transparent) 12%)}.preset-preview--theme{background:var(--md-sys-color-surface-container-low, transparent)}.preset-preview__head,.preset-preview__recommendation{display:flex;justify-content:space-between;align-items:center;gap:16px}.preset-preview__head>div,.preset-preview__recommendation>div{display:grid;gap:4px;min-width:0}.preset-preview p{margin:0;color:var(--md-sys-color-on-surface-variant, currentColor);line-height:1.45}.preset-preview__chips{display:flex;flex-wrap:wrap;gap:8px}.preset-preview__chips span{padding:5px 9px;border-radius:999px;background:var(--md-sys-color-surface-container, color-mix(in srgb, currentColor 8%, transparent));color:var(--md-sys-color-on-surface-variant, currentColor);font-size:12px;line-height:1.3}.preset-preview__recommendation{padding-top:12px;border-top:1px solid var(--md-sys-color-outline-variant, color-mix(in srgb, currentColor 14%, transparent))}.preset-preview__recommendation span:last-child{color:var(--md-sys-color-on-surface-variant, currentColor);font-size:13px;line-height:1.35}.eyebrow{color:var(--md-sys-color-primary, currentColor);font-size:11px;font-weight:700;letter-spacing:0;text-transform:uppercase}:host ::ng-deep .mat-mdc-tab-body-content{padding-top:12px;box-sizing:border-box}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1$1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatButtonToggleModule }, { kind: "directive", type: i3$1.MatButtonToggleGroup, selector: "mat-button-toggle-group", inputs: ["appearance", "name", "vertical", "value", "multiple", "disabled", "disabledInteractive", "hideSingleSelectionIndicator", "hideMultipleSelectionIndicator"], outputs: ["valueChange", "change"], exportAs: ["matButtonToggleGroup"] }, { kind: "component", type: i3$1.MatButtonToggle, selector: "mat-button-toggle", inputs: ["aria-label", "aria-labelledby", "id", "name", "value", "tabIndex", "disableRipple", "appearance", "checked", "disabled", "disabledInteractive"], outputs: ["change"], exportAs: ["matButtonToggle"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i2$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i5.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i10.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i10.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatTabsModule }, { kind: "component", type: i8.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass", "id"], exportAs: ["matTab"] }, { kind: "component", type: i8.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "mat-align-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "component", type: PraxisIconButtonComponent, selector: "button[praxisIconButton]", inputs: ["praxisIconButton", "size", "appearance", "presentation", "pressed", "busy"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
12181
12345
  }
12182
12346
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DynamicPageConfigEditorComponent, decorators: [{
12183
12347
  type: Component,
12184
12348
  args: [{ selector: 'praxis-dynamic-page-config-editor', standalone: true, imports: [ReactiveFormsModule, MatButtonModule, MatButtonToggleModule, MatFormFieldModule, MatIconModule, MatInputModule, MatSelectModule, MatTabsModule, PraxisIconButtonComponent], providers: [providePraxisPageBuilderI18n()], template: `
12185
12349
  <div class="editor">
12186
- <div class="head">
12350
+ @if (!isHostedInSettingsPanel) { <div class="head">
12187
12351
  <div>{{ tx('editor.title', 'Page settings') }}</div>
12188
12352
  <button mat-stroked-button type="button" (click)="reset()">{{ tx('editor.reload', 'Reload') }}</button>
12189
- </div>
12190
- <div class="summary" [attr.aria-label]="tx('editor.summaryAria', 'Configuration summary')">
12191
- <span>{{ validationStateLabel }}</span>
12192
- <span>{{ dirtyStateLabel }}</span>
12353
+ </div> }
12354
+ <details class="summary-details">
12355
+ <summary>{{ tx('editor.summaryAria', 'Configuration summary') }}</summary>
12356
+ <div class="summary">
12357
+ @if (!isHostedInSettingsPanel) {
12358
+ <span>{{ validationStateLabel }}</span>
12359
+ <span>{{ dirtyStateLabel }}</span>
12360
+ }
12193
12361
  <span>{{ selectedLayoutPresetLabel }}</span>
12194
12362
  <span>{{ selectedThemePresetLabel }}</span>
12195
12363
  <span>{{ canvasSummaryLabel }}</span>
12196
12364
  </div>
12197
- <mat-tab-group [selectedIndex]="selectedTabIndex" (selectedIndexChange)="onSelectedTabIndexChange($event)">
12365
+ </details>
12366
+ @if (nonLimitError) { <p class="limits-error" role="alert">{{ nonLimitError }}</p> }
12367
+ @if (activeTab() === 'limits' && canvasLimits.targets.length) {
12368
+ <div class="selection-identity" data-testid="canvas-limits-identity">
12369
+ <mat-icon aria-hidden="true">dashboard_customize</mat-icon>
12370
+ <strong>{{ limitsTargetLabel(limitsTargetControl.value) }}</strong>
12371
+ </div>
12372
+ }
12373
+ <mat-tab-group class="editor-tabs" [mat-stretch-tabs]="false" [selectedIndex]="selectedTabIndex" (selectedIndexChange)="onSelectedTabIndexChange($event)">
12198
12374
  <mat-tab [label]="tx('editor.tabs.layout', 'Layout')"><div class="grid"><mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.cols', 'Columns') }}</mat-label><input matInput type="number" min="1" [formControl]="canvasColumnsControl" /></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.rowUnit', 'Row unit') }}</mat-label><input matInput [formControl]="canvasRowUnitControl" /></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.gap', 'Gap') }}</mat-label><input matInput [formControl]="canvasGapControl" /></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.autoRows', 'Auto rows') }}</mat-label><mat-select [formControl]="canvasAutoRowsControl"><mat-option value="fixed">{{ tx('editor.layout.autoRowsFixed', 'Fixed height') }}</mat-option><mat-option value="content">{{ tx('editor.layout.autoRowsContent', 'Fit content') }}</mat-option></mat-select></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.layoutPreset', 'Layout preset') }}</mat-label><mat-select [formControl]="layoutPresetControl"><mat-option value="">{{ tx('editor.layout.none', 'None') }}</mat-option>@for (preset of layoutPresetEntries; track preset.id) {<mat-option [value]="preset.id">{{ preset.label }}</mat-option>}</mat-select></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.themePreset', 'Theme preset') }}</mat-label><mat-select [formControl]="themePresetControl"><mat-option value="">{{ tx('editor.layout.layoutDefault', 'Layout default') }}</mat-option>@for (preset of themePresetEntries; track preset.id) {<mat-option [value]="preset.id">{{ preset.label }}</mat-option>}</mat-select></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.shellPreset', 'Shell visual preset') }}</mat-label><mat-select [formControl]="shellPresetControl"><mat-option value="">{{ tx('editor.layout.none', 'None') }}</mat-option>@for (preset of shellPresetEntries; track preset.id) {<mat-option [value]="preset.id">{{ preset.label }}</mat-option>}</mat-select></mat-form-field></div>@if (selectedLayoutPresetEntry; as preset) {<div class="preset-preview"><div class="preset-preview__head"><div><span class="eyebrow">{{ tx('editor.preview.layoutEyebrow', 'Layout template') }} · {{ preset.category }}</span><strong>{{ preset.label }}</strong></div><span>{{ layoutPresetSummary(preset) }}</span></div><p>{{ preset.description || tx('editor.preview.layoutFallbackDescription', 'Page organization preset.') }}</p><div class="preset-preview__chips"><span>{{ slotsSummary(preset) }}</span><span>{{ requiredSlotsSummary(preset) }}</span><span>{{ devicePolicySummary(preset) }}</span></div>@if (recommendedThemePresetEntry; as theme) {<div class="preset-preview__recommendation"><div><span class="eyebrow">{{ tx('editor.preview.inheritedTheme', 'Inherited theme') }}</span><strong>{{ theme.label }}</strong><span>{{ inheritedThemeHelp(theme) }}</span></div><button mat-stroked-button type="button" [disabled]="themePresetControl.value === theme.id" (click)="pinRecommendedThemePreset()">{{ tx('editor.preview.pinTheme', 'Pin theme') }}</button></div>}</div>} @if (selectedThemePresetEntry; as theme) {<div class="preset-preview preset-preview--theme"><div class="preset-preview__head"><div><span class="eyebrow">{{ tx('editor.preview.explicitTheme', 'Explicit theme') }}</span><strong>{{ theme.label }}</strong></div><span>{{ theme.density }} · {{ theme.motion }}</span></div><p>{{ theme.description || tx('editor.preview.themeFallbackDescription', 'Page visual preset.') }}</p><div class="preset-preview__chips"><span>{{ tx('editor.preview.shellChip', 'Shell: {{value}}').replace('{{value}}', theme.shellPreset) }}</span><span>{{ tx('editor.preview.chartsChip', 'Charts: {{value}}').replace('{{value}}', theme.chartThemePreset) }}</span></div></div>}</mat-tab>
12199
- <mat-tab [label]="tx('editor.tabs.grouping', 'Grouping')"><div class="panel"><div class="head"><span>{{ tx('editor.grouping.title', 'Grouping') }}</span><button mat-stroked-button type="button" (click)="addGroupingNode()"><mat-icon>add</mat-icon>{{ tx('editor.grouping.add', 'Add group') }}</button></div>@for (group of groupingNodes.controls; track $index) {<div class="card" [formGroup]="group"><div class="head"><span>{{ groupLabel($index) }}</span><button praxisIconButton="delete" size="compact" type="button" [attr.aria-label]="tx('editor.grouping.removeAria', 'Remove group')" (click)="removeGroupingNode($index)"></button></div><div class="grid"><mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.kind', 'Kind') }}</mat-label><mat-select formControlName="kind"><mat-option value="section">section</mat-option><mat-option value="tabs">tabs</mat-option><mat-option value="hero">hero</mat-option><mat-option value="rail">rail</mat-option></mat-select></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.id', 'Id') }}</mat-label><input matInput formControlName="id" /></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.label', 'Label') }}</mat-label><input matInput formControlName="label" /></mat-form-field>@if (group.controls.kind.value === 'section') {<mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.layout', 'Layout') }}</mat-label><mat-select formControlName="layout"><mat-option value="">{{ tx('editor.grouping.layoutDefault', 'Default') }}</mat-option><mat-option value="stack">stack</mat-option><mat-option value="grid">grid</mat-option><mat-option value="row">row</mat-option></mat-select></mat-form-field>}@if (group.controls.kind.value === 'hero') {<mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.emphasis', 'Emphasis') }}</mat-label><mat-select formControlName="emphasis"><mat-option value="">{{ tx('editor.grouping.layoutDefault', 'Default') }}</mat-option><mat-option value="high">high</mat-option><mat-option value="medium">medium</mat-option></mat-select></mat-form-field>}@if (group.controls.kind.value === 'rail') {<mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.side', 'Side') }}</mat-label><mat-select formControlName="side"><mat-option value="left">left</mat-option><mat-option value="right">right</mat-option></mat-select></mat-form-field>}@if (group.controls.kind.value === 'tabs') {<mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.grouping.tabsJson', 'Tabs (JSON)') }}</mat-label><textarea matInput rows="4" formControlName="tabsJson"></textarea></mat-form-field>} @else {<mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.grouping.widgetKeysCsv', 'Widget keys (comma-separated)') }}</mat-label><input matInput formControlName="widgetKeysCsv" /></mat-form-field>}</div></div>}</div></mat-tab>
12200
- <mat-tab [label]="tx('editor.tabs.devices', 'Device Layouts')"><div class="panel">@for (device of deviceKeys; track device) {<div class="card"><div>{{ deviceLabels[device] }}</div><div class="grid"><mat-form-field appearance="outline"><mat-label>{{ tx('editor.devices.columns', 'Columns') }}</mat-label><input matInput type="number" min="1" [formControl]="deviceForms[device].controls.columns" /></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.rowUnit', 'Row unit') }}</mat-label><input matInput [formControl]="deviceForms[device].controls.rowUnit" /></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.devices.gap', 'Gap') }}</mat-label><input matInput [formControl]="deviceForms[device].controls.gap" /></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.autoRows', 'Auto rows') }}</mat-label><mat-select [formControl]="deviceForms[device].controls.autoRows"><mat-option value="">{{ tx('editor.devices.orientationDefault', 'Default') }}</mat-option><mat-option value="fixed">{{ tx('editor.layout.autoRowsFixed', 'Fixed height') }}</mat-option><mat-option value="content">{{ tx('editor.layout.autoRowsContent', 'Fit content') }}</mat-option></mat-select></mat-form-field><mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.devices.groupingOverridesJson', 'Grouping overrides (JSON)') }}</mat-label><textarea matInput rows="4" [formControl]="deviceForms[device].controls.groupingOverrides"></textarea></mat-form-field><mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.devices.widgetOverridesJson', 'Widget overrides (JSON)') }}</mat-label><textarea matInput rows="4" [formControl]="deviceForms[device].controls.widgetOverrides"></textarea></mat-form-field></div><div class="hint">{{ tx('editor.preview.deviceCanvasHint', 'Existing canvas item overrides are preserved.') }}</div></div>}</div></mat-tab>
12375
+ <mat-tab [label]="tx('editor.tabs.grouping', 'Grouping')"><div class="panel">
12376
+ <p class="hint" data-testid="canvas-grouping-notice">{{ tx('editor.grouping.canvasNotice', 'Canvas positions control this page. Groups below are preserved for flow layouts and do not arrange widgets in the canvas. Use the widget move and resize controls to place them side by side.') }}</p><div class="head"><span>{{ tx('editor.grouping.title', 'Grouping') }}</span><button mat-stroked-button type="button" (click)="addGroupingNode()"><mat-icon>add</mat-icon>{{ tx('editor.grouping.add', 'Add group') }}</button></div>@for (group of groupingNodes.controls; track $index) {<div class="card" [formGroup]="group"><div class="head"><span>{{ groupLabel($index) }}</span><button praxisIconButton="delete" size="compact" type="button" [attr.aria-label]="tx('editor.grouping.removeAria', 'Remove group')" (click)="removeGroupingNode($index)"></button></div><div class="grid"><mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.kind', 'Kind') }}</mat-label><mat-select formControlName="kind"><mat-option value="section">section</mat-option><mat-option value="tabs">tabs</mat-option><mat-option value="hero">hero</mat-option><mat-option value="rail">rail</mat-option></mat-select></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.id', 'Id') }}</mat-label><input matInput formControlName="id" /></mat-form-field><mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.label', 'Label') }}</mat-label><input matInput formControlName="label" /></mat-form-field>@if (group.controls.kind.value === 'section') {<mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.layout', 'Layout') }}</mat-label><mat-select formControlName="layout"><mat-option value="">{{ tx('editor.grouping.layoutDefault', 'Default') }}</mat-option><mat-option value="stack">stack</mat-option><mat-option value="grid">grid</mat-option><mat-option value="row">row</mat-option></mat-select></mat-form-field>}@if (group.controls.kind.value === 'hero') {<mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.emphasis', 'Emphasis') }}</mat-label><mat-select formControlName="emphasis"><mat-option value="">{{ tx('editor.grouping.layoutDefault', 'Default') }}</mat-option><mat-option value="high">high</mat-option><mat-option value="medium">medium</mat-option></mat-select></mat-form-field>}@if (group.controls.kind.value === 'rail') {<mat-form-field appearance="outline"><mat-label>{{ tx('editor.grouping.side', 'Side') }}</mat-label><mat-select formControlName="side"><mat-option value="left">left</mat-option><mat-option value="right">right</mat-option></mat-select></mat-form-field>}@if (group.controls.kind.value === 'tabs') {<mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.grouping.tabsJson', 'Tabs (JSON)') }}</mat-label><textarea matInput rows="4" formControlName="tabsJson"></textarea></mat-form-field>} @else {<mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.grouping.widgetKeysCsv', 'Widget keys (comma-separated)') }}</mat-label><input matInput formControlName="widgetKeysCsv" /></mat-form-field>}</div></div>}</div></mat-tab>
12377
+ <mat-tab [label]="tx('editor.tabs.devices', 'Device Layouts')">
12378
+ <div class="panel">
12379
+ <mat-button-toggle-group [value]="activeDevice()" (change)="activeDevice.set($event.value)" [attr.aria-label]="tx('editor.devices.target', 'Device to configure')">
12380
+ @for (device of deviceKeys; track device) {
12381
+ <mat-button-toggle [value]="device">{{ deviceLabels[device] }}</mat-button-toggle>
12382
+ }
12383
+ </mat-button-toggle-group>
12384
+ @for (device of deviceKeys; track device) {
12385
+ @if (activeDevice() === device) {
12386
+ <div class="card">
12387
+ <div class="head"><strong>{{ deviceLabels[device] }}</strong><button mat-stroked-button type="button" [attr.data-testid]="'stack-device-' + device" [disabled]="!hasCanvasWidgets" (click)="stackDevice(device)"><mat-icon aria-hidden="true">view_agenda</mat-icon>{{ tx('editor.devices.stack', 'Stack widgets') }}</button></div>
12388
+ <p class="hint">{{ tx('editor.devices.stackHelp', 'Use full width and natural height, in reading order. This replaces positions and width limits only for this device; other layouts stay unchanged.') }}</p>
12389
+ @if (stackedDevices.has(device)) { <p class="hint" role="status">{{ tx('editor.devices.stacked', 'Single-column layout prepared. Save to apply; Reset undoes the changes.') }}</p> }
12390
+ <div class="grid">
12391
+ <mat-form-field appearance="outline"><mat-label>{{ tx('editor.devices.columns', 'Columns') }}</mat-label><input matInput type="number" min="1" [formControl]="deviceForms[device].controls.columns" /></mat-form-field>
12392
+ <mat-form-field appearance="outline"><mat-label>{{ tx('editor.devices.gap', 'Gap') }}</mat-label><input matInput [formControl]="deviceForms[device].controls.gap" /></mat-form-field>
12393
+ <mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.autoRows', 'Auto rows') }}</mat-label><mat-select [formControl]="deviceForms[device].controls.autoRows"><mat-option value="">{{ tx('editor.devices.orientationDefault', 'Default') }}</mat-option><mat-option value="fixed">{{ tx('editor.layout.autoRowsFixed', 'Fixed height') }}</mat-option><mat-option value="content">{{ tx('editor.layout.autoRowsContent', 'Fit content') }}</mat-option></mat-select></mat-form-field>
12394
+ </div>
12395
+ <details><summary>{{ tx('editor.devices.advanced', 'Advanced overrides') }}</summary><div class="grid">
12396
+ <mat-form-field appearance="outline"><mat-label>{{ tx('editor.layout.rowUnit', 'Row unit') }}</mat-label><input matInput [formControl]="deviceForms[device].controls.rowUnit" /></mat-form-field>
12397
+ <mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.devices.groupingOverridesJson', 'Grouping overrides (JSON)') }}</mat-label><textarea matInput rows="4" [formControl]="deviceForms[device].controls.groupingOverrides"></textarea></mat-form-field>
12398
+ <mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.devices.widgetOverridesJson', 'Widget overrides (JSON)') }}</mat-label><textarea matInput rows="4" [formControl]="deviceForms[device].controls.widgetOverrides"></textarea></mat-form-field>
12399
+ </div></details>
12400
+ @if (!stackedDevices.has(device)) { <div class="hint">{{ tx('editor.preview.deviceCanvasHint', 'Existing canvas item overrides are preserved.') }}</div> }
12401
+ </div>
12402
+ }
12403
+ }
12404
+ </div>
12405
+ </mat-tab>
12201
12406
  <mat-tab [label]="tx('editor.tabs.context', 'Context')"><mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.context.json', 'Context (JSON)') }}</mat-label><textarea matInput rows="12" [formControl]="contextControl"></textarea></mat-form-field></mat-tab>
12202
12407
  <mat-tab [label]="tx('editor.tabs.state', 'Page State')"><mat-form-field appearance="outline" class="wide"><mat-label>{{ tx('editor.state.valuesJson', 'Primary values (JSON)') }}</mat-label><textarea matInput rows="14" [formControl]="stateControl"></textarea></mat-form-field></mat-tab>
12203
12408
  <mat-tab [label]="tx('editor.limits.tab', 'Widget limits')">
@@ -12271,7 +12476,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
12271
12476
  </div>
12272
12477
  }
12273
12478
  }
12274
- @if (limitsError()) { <p class="limits-error" role="alert">{{ limitsError() }}</p> }
12479
+ @if (limitsSummaryError) { <p class="limits-error" role="alert">{{ limitsSummaryError }}</p> }
12275
12480
  } @else {
12276
12481
  <p>{{ tx('editor.limits.empty', 'Add widgets to the canvas to configure their limits.') }}</p>
12277
12482
  }
@@ -12279,7 +12484,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
12279
12484
  </mat-tab>
12280
12485
  </mat-tab-group>
12281
12486
  </div>
12282
- `, changeDetection: ChangeDetectionStrategy.OnPush, host: { '(window:resize)': 'refreshRenderedValidation()' }, styles: [":host{display:block;min-width:0;max-width:100%}.height-modes{width:fit-content;max-width:100%;flex-wrap:wrap;margin-bottom:16px}.editor,.panel{display:grid;gap:16px;min-width:0;max-width:100%;color:var(--md-sys-color-on-surface, currentColor)}mat-tab-group,mat-form-field{min-width:0;max-width:100%}.head,.summary{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.summary{color:var(--md-sys-color-on-surface-variant, currentColor)}.grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(min(220px,100%),1fr))}@media(max-width:600px){.grid{grid-template-columns:minmax(0,1fr)}.head,.summary,.preset-preview__head,.preset-preview__recommendation{align-items:stretch}.head>button,.preset-preview__recommendation button{width:100%}}.card{display:grid;gap:12px;padding:16px;border:1px solid var(--md-sys-color-outline-variant, color-mix(in srgb, currentColor 18%, transparent));border-radius:8px;background:var(--md-sys-color-surface-container-low, var(--md-sys-color-surface-container, transparent));color:var(--md-sys-color-on-surface, currentColor)}.limits-panel{display:grid;gap:8px;min-width:0}.limits-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:4px 16px}.limits-fields mat-form-field{min-width:0}.limits-notice{display:flex;gap:8px;align-items:flex-start;color:var(--md-sys-color-on-surface-variant, currentColor)}.limits-notice mat-icon{flex-shrink:0}.limits-error{color:var(--md-sys-color-error, #b3261e)}.wide{grid-column:1 / -1}.hint{color:var(--md-sys-color-on-surface-variant, currentColor);font-size:12px}.preset-preview{display:grid;gap:12px;margin-top:12px;padding:16px;border:1px solid var(--md-sys-color-outline-variant, color-mix(in srgb, currentColor 18%, transparent));border-radius:8px;background:color-mix(in srgb,var(--md-sys-color-surface-container-low, transparent) 88%,var(--md-sys-color-primary-container, transparent) 12%)}.preset-preview--theme{background:var(--md-sys-color-surface-container-low, transparent)}.preset-preview__head,.preset-preview__recommendation{display:flex;justify-content:space-between;align-items:center;gap:16px}.preset-preview__head>div,.preset-preview__recommendation>div{display:grid;gap:4px;min-width:0}.preset-preview p{margin:0;color:var(--md-sys-color-on-surface-variant, currentColor);line-height:1.45}.preset-preview__chips{display:flex;flex-wrap:wrap;gap:8px}.preset-preview__chips span{padding:5px 9px;border-radius:999px;background:var(--md-sys-color-surface-container, color-mix(in srgb, currentColor 8%, transparent));color:var(--md-sys-color-on-surface-variant, currentColor);font-size:12px;line-height:1.3}.preset-preview__recommendation{padding-top:12px;border-top:1px solid var(--md-sys-color-outline-variant, color-mix(in srgb, currentColor 14%, transparent))}.preset-preview__recommendation span:last-child{color:var(--md-sys-color-on-surface-variant, currentColor);font-size:13px;line-height:1.35}.eyebrow{color:var(--md-sys-color-primary, currentColor);font-size:11px;font-weight:700;letter-spacing:0;text-transform:uppercase}:host ::ng-deep .mat-mdc-tab-body-content{padding-top:12px;box-sizing:border-box}\n"] }]
12487
+ `, changeDetection: ChangeDetectionStrategy.OnPush, host: { '(window:resize)': 'refreshRenderedValidation()' }, styles: [":host{display:block;min-width:0;max-width:100%}.height-modes{width:fit-content;max-width:100%;flex-wrap:wrap;margin-bottom:16px}.editor,.panel{display:grid;gap:16px;min-width:0;max-width:100%;color:var(--md-sys-color-on-surface, currentColor)}mat-tab-group,mat-form-field{min-width:0;max-width:100%}.head,.summary{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.summary{color:var(--md-sys-color-on-surface-variant, currentColor)}.selection-identity{position:sticky;top:0;z-index:var(--praxis-layer-sticky-local, 100);display:flex;align-items:flex-start;gap:8px;padding-block:8px;background:var(--md-sys-color-surface-container-low, var(--md-sys-color-surface));overflow-wrap:anywhere}.selection-identity mat-icon{flex-shrink:0}:host ::ng-deep .editor-tabs>.mat-mdc-tab-header .mdc-tab{min-width:0;padding-inline:12px}:host ::ng-deep .editor-tabs>.mat-mdc-tab-header .mdc-tab__text-label{max-width:128px;white-space:normal;overflow-wrap:anywhere;text-align:center;letter-spacing:normal;line-height:1.25}.grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(min(220px,100%),1fr))}@media(max-width:600px){.grid{grid-template-columns:minmax(0,1fr)}.head,.summary,.preset-preview__head,.preset-preview__recommendation{align-items:stretch}.head>button,.preset-preview__recommendation button{width:100%}}.card{display:grid;gap:12px;padding:16px;border:1px solid var(--md-sys-color-outline-variant, color-mix(in srgb, currentColor 18%, transparent));border-radius:8px;background:var(--md-sys-color-surface-container-low, var(--md-sys-color-surface-container, transparent));color:var(--md-sys-color-on-surface, currentColor)}.limits-panel{display:grid;gap:8px;min-width:0}.limits-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:4px 16px}.limits-fields mat-form-field{min-width:0}.limits-notice{display:flex;gap:8px;align-items:flex-start;color:var(--md-sys-color-on-surface-variant, currentColor)}.limits-notice mat-icon{flex-shrink:0}.limits-error{color:var(--md-sys-color-error, #b3261e)}.wide{grid-column:1 / -1}.hint{color:var(--md-sys-color-on-surface-variant, currentColor);font-size:12px}.preset-preview{display:grid;gap:12px;margin-top:12px;padding:16px;border:1px solid var(--md-sys-color-outline-variant, color-mix(in srgb, currentColor 18%, transparent));border-radius:8px;background:color-mix(in srgb,var(--md-sys-color-surface-container-low, transparent) 88%,var(--md-sys-color-primary-container, transparent) 12%)}.preset-preview--theme{background:var(--md-sys-color-surface-container-low, transparent)}.preset-preview__head,.preset-preview__recommendation{display:flex;justify-content:space-between;align-items:center;gap:16px}.preset-preview__head>div,.preset-preview__recommendation>div{display:grid;gap:4px;min-width:0}.preset-preview p{margin:0;color:var(--md-sys-color-on-surface-variant, currentColor);line-height:1.45}.preset-preview__chips{display:flex;flex-wrap:wrap;gap:8px}.preset-preview__chips span{padding:5px 9px;border-radius:999px;background:var(--md-sys-color-surface-container, color-mix(in srgb, currentColor 8%, transparent));color:var(--md-sys-color-on-surface-variant, currentColor);font-size:12px;line-height:1.3}.preset-preview__recommendation{padding-top:12px;border-top:1px solid var(--md-sys-color-outline-variant, color-mix(in srgb, currentColor 14%, transparent))}.preset-preview__recommendation span:last-child{color:var(--md-sys-color-on-surface-variant, currentColor);font-size:13px;line-height:1.35}.eyebrow{color:var(--md-sys-color-primary, currentColor);font-size:11px;font-weight:700;letter-spacing:0;text-transform:uppercase}:host ::ng-deep .mat-mdc-tab-body-content{padding-top:12px;box-sizing:border-box}\n"] }]
12283
12488
  }], ctorParameters: () => [], propDecorators: { page: [{
12284
12489
  type: Input
12285
12490
  }], layout: [{
@@ -15526,7 +15731,7 @@ const PRAXIS_PAGE_BUILDER_AUTHORING_MANIFEST = {
15526
15731
  scope: 'templating',
15527
15732
  targetKind: 'widgetShell',
15528
15733
  target: { kind: 'widgetShell', resolver: 'widget-shell-by-widget-key', ambiguityPolicy: 'fail', required: true },
15529
- inputSchema: { type: 'object', required: ['widgetKey'], properties: { widgetKey: { type: 'string' }, shell: { type: 'object' } } },
15734
+ inputSchema: { type: 'object', required: ['widgetKey'], properties: { widgetKey: { type: 'string' }, shell: { type: 'object', properties: { stickyHeader: { type: 'boolean', description: 'Retain the existing header within the widget during page scrolling; suspended for drag authoring, collapsed/overlay states and scroll recovery.' } } } } },
15530
15735
  effects: [{ kind: 'compile-domain-patch', handler: 'page-builder-widget-shell-configure', handlerContract: {
15531
15736
  reads: ['WidgetPageDefinition.widgets', 'WidgetShellEditorComponent', 'SettingsPanelBridge'],
15532
15737
  writes: ['page.widgets[].shell'],
@@ -23899,6 +24104,10 @@ class DynamicPageBuilderComponent {
23899
24104
  this.agenticAuthoringLastEtag.set(this.authoringDocument.etag?.trim() || null);
23900
24105
  }
23901
24106
  else if (authoringDocumentChange && !this.authoringDocument) {
24107
+ // Clearing the persisted document hands ownership back to the page input.
24108
+ const parsed = this.parsePage(this.page) || { widgets: [] };
24109
+ this.currentPage.set(parsed);
24110
+ this.lastRuntimePageChangeFingerprint = JSON.stringify(parsed);
23902
24111
  this.persistedUiCompositionAuthoringSource.set(null);
23903
24112
  this.agenticAuthoringLastEtag.set(null);
23904
24113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@praxisui/page-builder",
3
- "version": "9.0.66",
3
+ "version": "9.0.67",
4
4
  "description": "Page and widget builder utilities for Praxis UI (grid, dynamic widgets, editors).",
5
5
  "peerDependencies": {
6
6
  "@angular/common": "^21.0.0",
@@ -8,9 +8,9 @@
8
8
  "@angular/forms": "^21.0.0",
9
9
  "@angular/cdk": "^21.0.0",
10
10
  "@angular/material": "^21.0.0",
11
- "@praxisui/ai": "^9.0.66",
12
- "@praxisui/core": "^9.0.66",
13
- "@praxisui/settings-panel": "^9.0.66",
11
+ "@praxisui/ai": "^9.0.67",
12
+ "@praxisui/core": "^9.0.67",
13
+ "@praxisui/settings-panel": "^9.0.67",
14
14
  "rxjs": "~7.8.0"
15
15
  },
16
16
  "dependencies": {
@@ -2,7 +2,7 @@ import { MatDialogRef, MatDialog } from '@angular/material/dialog';
2
2
  import * as _angular_core from '@angular/core';
3
3
  import { EventEmitter, OnInit, WritableSignal, InjectionToken, Provider, OnChanges, OnDestroy, Type, SimpleChanges } from '@angular/core';
4
4
  import * as _praxisui_core from '@praxisui/core';
5
- import { ComponentDocMeta, ComponentMetadataRegistry, PraxisI18nService, WidgetShellConfig, WidgetPageAuthoringCapabilities, WidgetShellAction, EndpointRef, ComponentPortPathSegment, LinkIntent, CompositionLink, WidgetPageDefinition, TransformKind, SurfaceOpenPayload, WidgetPageDeviceKind, SettingsValueProvider as SettingsValueProvider$1, WidgetPageGroupingDefinition, BUILTIN_PAGE_LAYOUT_PRESETS, BUILTIN_PAGE_THEME_PRESETS, PageIdentity, WidgetStateNode, WidgetDerivedStateNode, AiCapability, AiCapabilityCategory, AiCapabilityCatalog, AiValueKind, WidgetPageCanvasLayout, WidgetPageDeviceLayouts, WidgetPageSlotAssignments, WidgetPageStateInput, WidgetInstance, TransformOutputHint, TransformSemanticKind, ConfigAuthoringSource, DomainKnowledgeChangeSetRequest, DomainKnowledgeChangeSet, DomainKnowledgeChangeSetTimelineResponse, DomainKnowledgeChangeSetFilters, DomainKnowledgeValidationResponse, DomainKnowledgeStatusTransitionRequest, DomainRuleIntakeRequest, DomainRuleIntakeResponse, DomainRuleDefinitionRequest, DomainRuleDefinition, DomainRuleSimulationRequest, DomainRuleSimulationResponse, DomainRuleStatusTransitionRequest, DomainRulePublicationRequest, DomainRulePublicationResponse, DomainRuleMaterializationFilters, DomainRuleMaterialization, DomainRuleTimelineResponse, PraxisRuntimeComponentObservationEnvelope, ComponentContextPack, ComponentAuthoringManifest, DiagnosticRecord, NestedPortCatalogRegistry, CompositionValidatorService, ApiUrlEntry, DynamicWidgetPageComponent, ConfigDocument, WidgetEventEnvelope, ObservabilityAgenticTurnMetrics, SettingsPanelBridge, ObservabilityAgenticTurnMetricBucket, RichTimelineItem } from '@praxisui/core';
5
+ import { ComponentDocMeta, ComponentMetadataRegistry, PraxisI18nService, WidgetShellConfig, WidgetPageAuthoringCapabilities, WidgetShellAction, EndpointRef, PortContract, ComponentPortPathSegment, LinkIntent, CompositionLink, WidgetPageDefinition, TransformKind, SurfaceOpenPayload, WidgetPageDeviceKind, SettingsValueProvider as SettingsValueProvider$1, WidgetPageGroupingDefinition, BUILTIN_PAGE_LAYOUT_PRESETS, BUILTIN_PAGE_THEME_PRESETS, PageIdentity, WidgetStateNode, WidgetDerivedStateNode, AiCapability, AiCapabilityCategory, AiCapabilityCatalog, AiValueKind, WidgetPageCanvasLayout, WidgetPageDeviceLayouts, WidgetPageSlotAssignments, WidgetPageStateInput, WidgetInstance, TransformOutputHint, TransformSemanticKind, ConfigAuthoringSource, DomainKnowledgeChangeSetRequest, DomainKnowledgeChangeSet, DomainKnowledgeChangeSetTimelineResponse, DomainKnowledgeChangeSetFilters, DomainKnowledgeValidationResponse, DomainKnowledgeStatusTransitionRequest, DomainRuleIntakeRequest, DomainRuleIntakeResponse, DomainRuleDefinitionRequest, DomainRuleDefinition, DomainRuleSimulationRequest, DomainRuleSimulationResponse, DomainRuleStatusTransitionRequest, DomainRulePublicationRequest, DomainRulePublicationResponse, DomainRuleMaterializationFilters, DomainRuleMaterialization, DomainRuleTimelineResponse, PraxisRuntimeComponentObservationEnvelope, ComponentContextPack, ComponentAuthoringManifest, DiagnosticRecord, NestedPortCatalogRegistry, CompositionValidatorService, ApiUrlEntry, DynamicWidgetPageComponent, ConfigDocument, WidgetEventEnvelope, ObservabilityAgenticTurnMetrics, SettingsPanelBridge, ObservabilityAgenticTurnMetricBucket, RichTimelineItem } from '@praxisui/core';
6
6
  import * as rxjs from 'rxjs';
7
7
  import { BehaviorSubject, Observable } from 'rxjs';
8
8
  import { FormGroup, FormControl, FormArray } from '@angular/forms';
@@ -164,7 +164,9 @@ type SetInputPreset = {
164
164
  declare class WidgetShellEditorComponent implements SettingsValueProvider {
165
165
  private registry?;
166
166
  private readonly i18n;
167
+ menuPlacementLabel(): string;
167
168
  appearanceError(): string;
169
+ stickyHeaderLabel(): string;
168
170
  form: FormGroup<{
169
171
  kind: FormControl<"none" | "dashboard-card">;
170
172
  preset: FormControl<string>;
@@ -173,6 +175,7 @@ declare class WidgetShellEditorComponent implements SettingsValueProvider {
173
175
  subtitle: FormControl<string>;
174
176
  icon: FormControl<string>;
175
177
  showHeader: FormControl<"auto" | "true" | "false">;
178
+ stickyHeader: FormControl<boolean>;
176
179
  collapsible: FormControl<boolean>;
177
180
  cardBg: FormControl<string>;
178
181
  cardBorder: FormControl<string>;
@@ -280,6 +283,8 @@ interface ConnectionEditorPort {
280
283
  label: string;
281
284
  kind: ConnectionEditorPortKind;
282
285
  endpoint: EndpointRef;
286
+ /** Transient registry evidence; never serialized into a composition endpoint. */
287
+ contract?: PortContract;
283
288
  nestedPath?: ComponentPortPathSegment[];
284
289
  }
285
290
  interface ConnectionEditorNode {
@@ -1016,9 +1021,12 @@ declare class DynamicPageConfigEditorComponent implements OnInit, SettingsValueP
1016
1021
  isErrorState: (control: FormControl | null) => boolean;
1017
1022
  };
1018
1023
  private readonly injectedData;
1024
+ readonly isHostedInSettingsPanel: boolean;
1019
1025
  private readonly i18n;
1020
1026
  private pageInput;
1021
1027
  private snapshot;
1028
+ readonly stackedDevices: Set<DeviceKey>;
1029
+ readonly activeDevice: WritableSignal<DeviceKey>;
1022
1030
  private hydrating;
1023
1031
  private renderedCandidate;
1024
1032
  private renderedValidationFingerprint;
@@ -1030,6 +1038,8 @@ declare class DynamicPageConfigEditorComponent implements OnInit, SettingsValueP
1030
1038
  get hasPendingChanges(): boolean;
1031
1039
  get validationStateLabel(): string;
1032
1040
  get dirtyStateLabel(): string;
1041
+ get nonLimitError(): string | null;
1042
+ get limitsSummaryError(): string | null;
1033
1043
  get selectedLayoutPresetLabel(): string;
1034
1044
  get selectedThemePresetLabel(): string;
1035
1045
  get selectedLayoutPresetEntry(): LayoutPresetEntry | null;
@@ -1041,6 +1051,9 @@ declare class DynamicPageConfigEditorComponent implements OnInit, SettingsValueP
1041
1051
  onSave(): WidgetPageDefinition | null;
1042
1052
  reset(): void;
1043
1053
  pinRecommendedThemePreset(): void;
1054
+ get hasCanvasWidgets(): boolean;
1055
+ stackDevice(device: DeviceKey): void;
1056
+ private stackedCanvas;
1044
1057
  addGroupingNode(seed?: WidgetPageGroupingDefinition): void;
1045
1058
  removeGroupingNode(index: number): void;
1046
1059
  layoutPresetSummary(preset: LayoutPresetEntry): string;
@@ -1064,6 +1077,8 @@ declare class DynamicPageConfigEditorComponent implements OnInit, SettingsValueP
1064
1077
  private replaceGroupingNodes;
1065
1078
  private buildCurrentPage;
1066
1079
  private buildGrouping;
1080
+ /** A grid must place every existing widget before replacing the flow layout. */
1081
+ private completeCanvasItems;
1067
1082
  private buildDeviceLayouts;
1068
1083
  private invalidate;
1069
1084
  private refreshPanelState;